diff --git a/adapters/llm/anthropic.py b/adapters/llm/anthropic.py index ced33e3..38b678f 100644 --- a/adapters/llm/anthropic.py +++ b/adapters/llm/anthropic.py @@ -1,16 +1,15 @@ """ adapters/llm/anthropic.py -Anthropic Claude adapter — Phase 2 stub. +Anthropic Claude LLM adapter — Phase 2 implementation. -TODO (Phase 2): - - Implement complete() using the anthropic SDK (anthropic.Anthropic client). - - Implement resolve_model() by reading config/team.yaml capability_map. - - Handle streaming responses, rate-limit retries, and token counting. - - Support system-prompt injection via context["system_prompt"]. - - Map capability → model using the provider's capability_map config. +Uses the ``anthropic`` SDK to call Claude models. Model selection is driven +by the capability_map in team.yaml so the adapter stays provider-agnostic in +configuration. """ from __future__ import annotations +import os + from adapters.base.llm import LLMAdapter @@ -18,27 +17,123 @@ class AnthropicAdapter(LLMAdapter): """ LLM adapter for Anthropic Claude models. - Reads model configuration from config/team.yaml: - models.provider: anthropic - models.capability_map.reasoning-heavy.anthropic: claude-opus-4-6 - models.capability_map.capable.anthropic: claude-sonnet-4-6 - models.capability_map.fast-cheap.anthropic: claude-haiku-3-5 + Reads model configuration from the loaded team.yaml config dict:: + + models: + default_max_tokens: 4096 # fallback max_tokens for all calls + default_temperature: 0 # fallback temperature for all calls + capability_map: + reasoning-heavy: + anthropic: claude-opus-4-6 + capable: + anthropic: claude-sonnet-4-6 + fast-cheap: + anthropic: claude-haiku-3-5 + + The provider key used when looking up ``capability_map`` is hardcoded to + ``"anthropic"`` — the adapter knows its own provider; there is no need for + a separate ``models.provider`` config field. + + Both ``default_max_tokens`` and ``default_temperature`` can be overridden + per-call via the ``context`` dict passed to :meth:`complete`. + + Environment variables + --------------------- + ANTHROPIC_API_KEY : Required. Authenticates with the Anthropic API. """ def __init__(self, config: dict) -> None: - # TODO (Phase 2): Accept loaded team.yaml config dict. - # Extract API key from environment (ANTHROPIC_API_KEY). - # Initialise the anthropic.Anthropic() client. - raise NotImplementedError("AnthropicAdapter.__init__ is not yet implemented.") + """ + Initialise the Anthropic adapter. + + Parameters + ---------- + config : Loaded team.yaml config dict. + + Raises + ------ + ValueError + If ANTHROPIC_API_KEY is not set in the environment. + """ + try: + import anthropic as _anthropic + except ModuleNotFoundError as exc: + raise ImportError( + "The 'anthropic' package is required for AnthropicAdapter. " + "Install it with: pip install anthropic" + ) from exc + + self._config = config + api_key = os.environ.get("ANTHROPIC_API_KEY") + if not api_key: + raise ValueError( + "ANTHROPIC_API_KEY environment variable is not set. " + "Export it before running the-agency." + ) + self._client = _anthropic.Anthropic(api_key=api_key) + self._models_cfg: dict = config.get("models", {}) + self._default_max_tokens: int = self._models_cfg.get("default_max_tokens", 4096) + self._default_temperature: float = self._models_cfg.get("default_temperature", 0) def complete(self, prompt: str, capability: str, context: dict) -> str: - # TODO (Phase 2): Call anthropic client messages.create(). - # Use resolve_model(capability) to pick the model. - # Support context keys: system_prompt, max_tokens, temperature. - # Return response text as a plain string. - raise NotImplementedError("AnthropicAdapter.complete is not yet implemented.") + """ + Send a prompt to a Claude model and return the text response. + + Parameters + ---------- + prompt : User-role prompt content. + capability : One of "reasoning-heavy" | "capable" | "fast-cheap". + context : Optional per-call overrides: + system_prompt (str) — prepended as the system turn. + max_tokens (int) — defaults to models.default_max_tokens in team.yaml. + temperature (float) — defaults to models.default_temperature in team.yaml. + + Returns + ------- + The model's text completion as a plain string. + """ + model = self.resolve_model(capability) + max_tokens: int = context.get("max_tokens", self._default_max_tokens) + temperature: float = context.get("temperature", self._default_temperature) + system_prompt: str = context.get("system_prompt", "") + + create_kwargs: dict = { + "model": model, + "max_tokens": max_tokens, + "messages": [{"role": "user", "content": prompt}], + } + if system_prompt: + create_kwargs["system"] = system_prompt + if temperature != 0.0: + create_kwargs["temperature"] = temperature + + response = self._client.messages.create(**create_kwargs) + return response.content[0].text def resolve_model(self, capability: str) -> str: - # TODO (Phase 2): Look up capability in team.yaml capability_map. - # Fall back to "capable" tier model if capability is unknown. - raise NotImplementedError("AnthropicAdapter.resolve_model is not yet implemented.") + """ + Map a capability string to the Anthropic model identifier. + + Looks up ``config.models.capability_map[capability][provider]``. + Falls back to the "capable" tier model if the capability is unknown. + + Parameters + ---------- + capability : One of "reasoning-heavy" | "capable" | "fast-cheap". + + Returns + ------- + Anthropic model identifier (e.g. "claude-opus-4-6"). + """ + # The adapter knows its own provider — no need to read it from config. + cap_map: dict = self._models_cfg.get("capability_map", {}) + + if capability in cap_map and "anthropic" in cap_map[capability]: + return cap_map[capability]["anthropic"] + + # Fall back to "capable" tier + if "capable" in cap_map and "anthropic" in cap_map["capable"]: + return cap_map["capable"]["anthropic"] + + # Hard-coded last resort + return "claude-sonnet-4-6" diff --git a/adapters/notify/openclaw.py b/adapters/notify/openclaw.py index fa0abf5..f637573 100644 --- a/adapters/notify/openclaw.py +++ b/adapters/notify/openclaw.py @@ -1,35 +1,93 @@ """ adapters/notify/openclaw.py -OpenClaw notification adapter — Phase 2 stub. +OpenClaw notification adapter — Phase 2 implementation. -TODO (Phase 2): - - Implement send() to dispatch notifications via the OpenClaw API. - - Support context keys: channel, severity, run_id, brief_id. - - Read endpoint and credentials from environment (OPENCLAW_API_KEY, OPENCLAW_URL). - - Handle rate limiting and delivery retries. +Sends notifications by shelling out to the ``openclaw`` CLI:: + + openclaw system event --text "" --mode now + +If the binary is not on PATH the method logs a warning and returns without +raising — notifications are best-effort and should never crash the pipeline. """ from __future__ import annotations +import logging +import os +import subprocess + from adapters.base.notify import NotifyAdapter +logger = logging.getLogger(__name__) + class OpenClawNotifyAdapter(NotifyAdapter): """ - Notification adapter that sends messages via OpenClaw. + Notification adapter that dispatches messages via the ``openclaw`` CLI. - Expects environment variables: - OPENCLAW_API_KEY — authentication token - OPENCLAW_URL — base URL for the OpenClaw API (optional, defaults to hosted) + Environment variables + --------------------- + OPENCLAW_SIGNAL_NUMBER : Optional. Direct signal target for OpenClaw sends. """ def __init__(self, config: dict) -> None: - # TODO (Phase 2): Accept loaded team.yaml config dict. - # Extract OPENCLAW_API_KEY and OPENCLAW_URL from environment. - # Initialise an HTTP client (e.g. httpx or requests). - raise NotImplementedError("OpenClawNotifyAdapter.__init__ is not yet implemented.") + """ + Initialise the OpenClaw notification adapter. + + Parameters + ---------- + config : Loaded team.yaml config dict (reserved for future options). + """ + self._config = config + self._signal_number: str = os.environ.get("OPENCLAW_SIGNAL_NUMBER", "") def send(self, message: str, context: dict) -> None: - # TODO (Phase 2): POST notification payload to OpenClaw API. - # Include message, context (channel, severity, run_id, brief_id). - # Log delivery confirmation or raise on failure. - raise NotImplementedError("OpenClawNotifyAdapter.send is not yet implemented.") + """ + Send a notification via ``openclaw system event``. + + Parameters + ---------- + message : Human-readable notification text. + context : Optional metadata. Recognised keys: + level (str) — "info" | "warning" | "error"; logged locally. + run_id (str) — included in the local log record. + brief_id (str) — included in the local log record. + + Notes + ----- + If the ``openclaw`` binary is not present on PATH, the method logs a + warning and returns silently. Notifications are best-effort. + """ + level: str = context.get("level", "info") + run_id: str = context.get("run_id", "") + brief_id: str = context.get("brief_id", "") + + # Always log locally regardless of CLI availability. + log_msg = "[notify:%s] %s (run=%s brief=%s)" % (level, message, run_id, brief_id) + if level == "error": + logger.error(log_msg) + elif level == "warning": + logger.warning(log_msg) + else: + logger.info(log_msg) + + cmd = ["openclaw", "system", "event", "--text", message, "--mode", "now"] + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode != 0: + logger.warning( + "openclaw event returned non-zero exit %d: %s", + result.returncode, + result.stderr.strip(), + ) + except FileNotFoundError: + logger.warning( + "openclaw CLI not found on PATH; notification not delivered: %s", + message, + ) + except subprocess.TimeoutExpired: + logger.warning("openclaw event timed out for message: %s", message) diff --git a/adapters/runtime/claude_code.py b/adapters/runtime/claude_code.py index bf69dec..329898a 100644 --- a/adapters/runtime/claude_code.py +++ b/adapters/runtime/claude_code.py @@ -1,51 +1,163 @@ """ adapters/runtime/claude_code.py -Claude Code agent runtime adapter — Phase 2 stub. +Claude Code sub-agent runtime adapter — Phase 2 implementation. -TODO (Phase 2): - - Implement spawn() to launch a Claude Code sub-agent via the Agent SDK. - - Implement get_result() to await agent completion and parse the output. - - Implement kill() to terminate the sub-agent process or session. - - Map task brief context (files, constraints, artifacts) into the agent's - system prompt and tool context. - - Handle Claude Code tool-use responses and extract structured output. +Spawns the ``claude`` CLI as a non-interactive subprocess for T4/T5 +implementation tasks:: + + claude --permission-mode bypassPermissions --print "" + +Each spawned process is tracked by a UUID job_id so callers can later poll +for the result or terminate the job. Stdout is captured and returned as the +agent output; stderr is included for debugging. """ from __future__ import annotations +import logging +import subprocess +import tempfile +import threading +import uuid + from adapters.base.runtime import RuntimeAdapter +logger = logging.getLogger(__name__) + class ClaudeCodeRuntimeAdapter(RuntimeAdapter): """ - Runtime adapter that spawns Claude Code sub-agents for coding tasks. + Runtime adapter that spawns ``claude`` CLI sub-agents for coding tasks. - Used when a TaskBrief has preferred_runtime == "coding_agent". + Credentials are inherited from the environment (``ANTHROPIC_API_KEY``). + The ``claude`` CLI must be installed and reachable on PATH. - Expects the Claude Code CLI / Agent SDK to be available in the environment. - Credentials are inherited from the environment (ANTHROPIC_API_KEY). + Used when a TaskBrief has ``preferred_runtime == "coding_agent"``. """ def __init__(self, config: dict) -> None: - # TODO (Phase 2): Accept loaded team.yaml config dict. - # Validate that Claude Code CLI or SDK is accessible. - # Initialise any agent session management state. - raise NotImplementedError("ClaudeCodeRuntimeAdapter.__init__ is not yet implemented.") + """ + Initialise the Claude Code runtime adapter. + + Parameters + ---------- + config : Loaded team.yaml config dict (reserved for future options). + """ + self._config = config + # Maps job_id → running Popen instance. + self._jobs: dict[str, subprocess.Popen] = {} + self._lock = threading.Lock() + + # ------------------------------------------------------------------ + # RuntimeAdapter interface + # ------------------------------------------------------------------ def spawn(self, task: str, capability: str, context: dict) -> str: - # TODO (Phase 2): Launch a Claude Code sub-agent. - # Compose a structured system prompt from task + context. - # Inject relevant files and constraints as tool context. - # Return an agent_id that maps to a running agent session. - raise NotImplementedError("ClaudeCodeRuntimeAdapter.spawn is not yet implemented.") + """ + Launch ``claude --permission-mode bypassPermissions --print ""`` + as a non-interactive subprocess. + + Parameters + ---------- + task : Full task description (typically a JSON-serialised brief). + capability : Capability hint (not forwarded; Claude Code resolves its + own model from the local environment). + context : Optional keys: + workdir (str) — cwd for the subprocess. A fresh + temporary directory is created if omitted. + + Returns + ------- + A UUID job_id string that uniquely identifies this subprocess. + """ + workdir: str = context.get("workdir") or tempfile.mkdtemp( + prefix="agency-claude-" + ) + job_id = str(uuid.uuid4()) + logger.info("Spawning Claude Code job %s in %s", job_id, workdir) + + proc = subprocess.Popen( + ["claude", "--permission-mode", "bypassPermissions", "--print", task], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + cwd=workdir, + ) + + with self._lock: + self._jobs[job_id] = proc + + return job_id def get_result(self, agent_id: str, timeout_s: int) -> dict: - # TODO (Phase 2): Await the Claude Code agent session to complete. - # Parse the agent's final message for structured JSON output. - # Return dict with: {"status": ..., "output": ..., "artifacts": [...]}. - # Raise TimeoutError if timeout_s elapses. - raise NotImplementedError("ClaudeCodeRuntimeAdapter.get_result is not yet implemented.") + """ + Wait for the Claude Code subprocess to complete and return its output. + + Parameters + ---------- + agent_id : Job id returned by spawn(). + timeout_s : Maximum seconds to wait before raising TimeoutError. + + Returns + ------- + dict with keys: + status ("completed" | "failed") + output (str — full stdout) + artifacts (list — always empty; callers must parse output) + stderr (str — full stderr) + + Raises + ------ + KeyError + If agent_id does not correspond to a known job. + TimeoutError + If the subprocess does not finish within timeout_s seconds. + """ + with self._lock: + proc = self._jobs.get(agent_id) + + if proc is None: + raise KeyError(f"No Claude Code job found for agent_id={agent_id!r}") + + try: + stdout, stderr = proc.communicate(timeout=timeout_s) + except subprocess.TimeoutExpired: + proc.kill() + stdout, stderr = proc.communicate() + raise TimeoutError( + f"Claude Code job {agent_id!r} did not complete within {timeout_s}s." + ) + + status = "completed" if proc.returncode == 0 else "failed" + logger.info( + "Claude Code job %s finished: status=%s returncode=%d", + agent_id, + status, + proc.returncode, + ) + + return { + "status": status, + "output": stdout, + "artifacts": [], + "stderr": stderr, + } def kill(self, agent_id: str) -> None: - # TODO (Phase 2): Terminate the Claude Code agent session. - # Clean up any temporary files or session state. - raise NotImplementedError("ClaudeCodeRuntimeAdapter.kill is not yet implemented.") + """ + Terminate a running Claude Code subprocess. + + Silently succeeds if the job has already finished or the id is unknown. + + Parameters + ---------- + agent_id : Job id returned by spawn(). + """ + with self._lock: + proc = self._jobs.get(agent_id) + + if proc is not None: + try: + proc.terminate() + logger.info("Terminated Claude Code job %s", agent_id) + except OSError: + pass # Process already gone — that is fine. diff --git a/adapters/runtime/openclaw.py b/adapters/runtime/openclaw.py index 52102ab..cc3a239 100644 --- a/adapters/runtime/openclaw.py +++ b/adapters/runtime/openclaw.py @@ -1,48 +1,241 @@ """ adapters/runtime/openclaw.py -OpenClaw agent runtime adapter — Phase 2 stub. +OpenClaw agent runtime adapter — Phase 2 implementation. -TODO (Phase 2): - - Implement spawn() to submit a task to an OpenClaw worker pool. - - Implement get_result() to poll or subscribe for agent completion. - - Implement kill() to cancel a running OpenClaw agent job. - - Read endpoint and credentials from environment (OPENCLAW_API_KEY, OPENCLAW_URL). - - Map capability hint to an appropriate worker class/queue. +Spawns sub-agents by shelling out to the ``openclaw`` CLI:: + + openclaw session spawn --task "" --mode run + openclaw session get + openclaw session kill + +If the ``openclaw`` binary is unavailable, all methods raise +``NotImplementedError`` with a helpful message rather than crashing with a +raw ``FileNotFoundError``. """ from __future__ import annotations +import json +import logging +import re +import subprocess +import time + from adapters.base.runtime import RuntimeAdapter +logger = logging.getLogger(__name__) + +# Status strings from the openclaw CLI that indicate a session has finished. +_TERMINAL_STATUSES = frozenset( + {"done", "completed", "failed", "partial", "blocked", "error"} +) + class OpenClawRuntimeAdapter(RuntimeAdapter): """ - Runtime adapter that dispatches agent tasks to OpenClaw workers. + Runtime adapter that dispatches agent tasks to OpenClaw worker sessions. - Expects environment variables: - OPENCLAW_API_KEY — authentication token - OPENCLAW_URL — base URL for the OpenClaw API + All interactions use the ``openclaw`` CLI. No additional credentials are + required beyond what OpenClaw manages in the local environment. """ def __init__(self, config: dict) -> None: - # TODO (Phase 2): Accept loaded team.yaml config dict. - # Extract OPENCLAW_API_KEY and OPENCLAW_URL from environment. - # Initialise HTTP client and any job-tracking state. - raise NotImplementedError("OpenClawRuntimeAdapter.__init__ is not yet implemented.") + """ + Initialise the OpenClaw runtime adapter. + + Parameters + ---------- + config : Loaded team.yaml config dict (reserved for future options). + """ + self._config = config + + # ------------------------------------------------------------------ + # RuntimeAdapter interface + # ------------------------------------------------------------------ def spawn(self, task: str, capability: str, context: dict) -> str: - # TODO (Phase 2): Submit task to OpenClaw worker pool. - # Map capability ("reasoning-heavy" | "capable" | "fast-cheap") to - # an appropriate worker queue or model hint. - # Return an agent_id string that can be used to poll for results. - raise NotImplementedError("OpenClawRuntimeAdapter.spawn is not yet implemented.") + """ + Spawn an OpenClaw agent session for the given task. + + Parameters + ---------- + task : Natural-language task description. + capability : Capability hint ("reasoning-heavy" | "capable" | "fast-cheap"). + Passed informally; actual routing is handled by OpenClaw. + context : Arbitrary context bag (currently unused by this adapter). + + Returns + ------- + session_id string parsed from the CLI output. + + Raises + ------ + NotImplementedError + If the ``openclaw`` CLI is not available on PATH. + RuntimeError + If the session_id cannot be parsed from the CLI output. + """ + # TODO: map capability to an openclaw worker tier / model hint if the + # openclaw CLI gains that flag in a future release. + cmd = ["openclaw", "session", "spawn", "--task", task, "--mode", "run"] + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + check=True, + ) + except FileNotFoundError: + raise NotImplementedError( + "openclaw CLI not found on PATH. " + "Install OpenClaw or configure a different runtime adapter " + "(e.g. adapters.runtime.claude_code.ClaudeCodeRuntimeAdapter)." + ) + except subprocess.CalledProcessError as exc: + raise RuntimeError( + f"openclaw session spawn failed (exit {exc.returncode}): " + f"{exc.stderr.strip()}" + ) from exc + + return self._parse_session_id(result.stdout) def get_result(self, agent_id: str, timeout_s: int) -> dict: - # TODO (Phase 2): Poll or long-poll the OpenClaw API for job completion. - # Raise TimeoutError if timeout_s elapses before the job finishes. - # Return a dict with at minimum: {"status": ..., "output": ..., "artifacts": [...]}. - raise NotImplementedError("OpenClawRuntimeAdapter.get_result is not yet implemented.") + """ + Poll ``openclaw session get`` until the session reaches a terminal + state or *timeout_s* seconds elapse. + + Parameters + ---------- + agent_id : Session ID returned by spawn(). + timeout_s : Maximum seconds to wait before raising TimeoutError. + + Returns + ------- + dict with keys: ``status``, ``output``, ``artifacts``. + + Raises + ------ + TimeoutError + If the session does not finish within timeout_s seconds. + NotImplementedError + If the ``openclaw`` CLI is not available on PATH. + """ + deadline = time.monotonic() + timeout_s + poll_interval = 2.0 + + while time.monotonic() < deadline: + try: + result = subprocess.run( + ["openclaw", "session", "get", agent_id], + capture_output=True, + text=True, + timeout=15, + ) + except FileNotFoundError: + raise NotImplementedError( + "openclaw CLI not found on PATH. " + "Install OpenClaw or switch to a different runtime adapter." + ) + except subprocess.TimeoutExpired: + logger.debug("openclaw session get timed out; will retry") + time.sleep(poll_interval) + continue + + if result.returncode == 0 and result.stdout.strip(): + parsed = self._parse_get_output(result.stdout) + if parsed.get("status", "").lower() in _TERMINAL_STATUSES: + return parsed + else: + logger.debug( + "openclaw session get returned exit=%d; retrying. stderr=%s", + result.returncode, + result.stderr.strip(), + ) + + time.sleep(poll_interval) + + raise TimeoutError( + f"Agent {agent_id!r} did not complete within {timeout_s}s." + ) def kill(self, agent_id: str) -> None: - # TODO (Phase 2): Send a cancellation request to the OpenClaw API. - # Silently succeed if the agent has already finished. - raise NotImplementedError("OpenClawRuntimeAdapter.kill is not yet implemented.") + """ + Terminate an OpenClaw session unconditionally. + + Silently succeeds if the session has already finished. + + Parameters + ---------- + agent_id : Session ID returned by spawn(). + + Raises + ------ + NotImplementedError + If the ``openclaw`` CLI is not available on PATH. + """ + try: + subprocess.run( + ["openclaw", "session", "kill", agent_id], + capture_output=True, + text=True, + timeout=15, + ) + except FileNotFoundError: + raise NotImplementedError( + "openclaw CLI not found on PATH. " + "Install OpenClaw or switch to a different runtime adapter." + ) + except subprocess.TimeoutExpired: + logger.warning("openclaw session kill timed out for agent %s", agent_id) + + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ + + def _parse_session_id(self, output: str) -> str: + """Extract a session_id from the raw stdout of ``openclaw session spawn``.""" + output = output.strip() + + # Prefer structured JSON output. + try: + data = json.loads(output) + for key in ("session_id", "sessionId", "id"): + if key in data: + return str(data[key]) + except (json.JSONDecodeError, TypeError): + pass + + # Regex: look for "session_id: " or similar. + m = re.search( + r"(?:session[_\s]?id|sessionId)[:\s]+([a-zA-Z0-9_\-]+)", + output, + re.IGNORECASE, + ) + if m: + return m.group(1) + + # Last resort: return the first non-empty line. + lines = [ln.strip() for ln in output.splitlines() if ln.strip()] + if lines: + return lines[0] + + raise RuntimeError( + f"Could not parse session_id from openclaw output: {output!r}" + ) + + def _parse_get_output(self, output: str) -> dict: + """Parse the stdout of ``openclaw session get`` into a result dict.""" + output = output.strip() + try: + data = json.loads(output) + return { + "status": data.get("status", "done"), + "output": data.get("output", output), + "artifacts": data.get("artifacts", []), + } + except (json.JSONDecodeError, TypeError): + # Non-JSON output — treat as completed with raw text output. + return { + "status": "done", + "output": output, + "artifacts": [], + } diff --git a/adapters/vcs/github.py b/adapters/vcs/github.py index 8407745..68a7431 100644 --- a/adapters/vcs/github.py +++ b/adapters/vcs/github.py @@ -1,16 +1,30 @@ """ adapters/vcs/github.py -GitHub VCS adapter — Phase 2 stub. +GitHub VCS adapter — Phase 2 implementation. -TODO (Phase 2): - - Implement create_branch() using PyGithub or gh CLI subprocess. - - Implement commit() — stage files and push via git subprocess or API. - - Implement create_pr() using GitHub REST API (POST /repos/{owner}/{repo}/pulls). - - Implement get_pr_status() using GET /repos/{owner}/{repo}/pulls/{pull_number}. - - Read repo and credentials from config/team.yaml and environment (GITHUB_TOKEN). +Uses PyGithub (``pip install PyGithub``) to interact with the GitHub REST API. +Reads the repository URL and base branch from the team.yaml config dict. + +Note on commit() signature +-------------------------- +The base class declares ``commit(files: list[str], message: str)``, which is +insufficient for the GitHub Contents API (which requires file *content*, not +just paths). This implementation extends the signature to accept either: + +* ``dict[str, str]`` — ``{path: content}`` mapping (preferred; uses the API). +* ``list[str]`` — local file paths; content is read from disk and pushed. + +The optional ``branch`` keyword argument targets a specific branch; it +defaults to the configured base branch. """ from __future__ import annotations +import os +import re +from typing import Union + +from github import Github, GithubException + from adapters.base.vcs import VCSAdapter @@ -18,34 +32,175 @@ class GitHubAdapter(VCSAdapter): """ VCS adapter for GitHub repositories. - Expects environment variable GITHUB_TOKEN and config values: - run.repo — SSH or HTTPS clone URL - run.base_branch — default base branch (e.g. "main") + Authenticates via GITHUB_TOKEN and interacts with the GitHub REST API + through PyGithub. + + Environment variables + --------------------- + GITHUB_TOKEN : Required. Personal access token or GitHub App installation token. + + Config keys (from team.yaml) + ---------------------------- + run.repo : SSH or HTTPS clone URL (e.g. "git@github.com:org/repo.git"). + run.base_branch : Default base branch (e.g. "main"). """ def __init__(self, config: dict) -> None: - # TODO (Phase 2): Accept loaded team.yaml config dict. - # Extract GITHUB_TOKEN from environment. - # Parse owner/repo from config.run.repo. - raise NotImplementedError("GitHubAdapter.__init__ is not yet implemented.") + """ + Initialise the GitHub adapter. + + Parameters + ---------- + config : Loaded team.yaml config dict. + + Raises + ------ + ValueError + If GITHUB_TOKEN is not set or the repo URL cannot be parsed. + """ + self._config = config + token = os.environ.get("GITHUB_TOKEN") + if not token: + raise ValueError( + "GITHUB_TOKEN environment variable is not set. " + "Create a personal access token and export it before running the-agency." + ) + self._g = Github(token) + + run_cfg: dict = config.get("run", {}) + repo_url: str = run_cfg.get("repo", "") + self._base_branch: str = run_cfg.get("base_branch", "main") + + self._owner, self._repo_name = self._parse_repo_url(repo_url) + self._repo = self._g.get_repo(f"{self._owner}/{self._repo_name}") + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _parse_repo_url(self, url: str) -> tuple[str, str]: + """Parse *owner* and *repo* name from an SSH or HTTPS GitHub URL.""" + # git@github.com:owner/repo.git + m = re.match(r"git@github\.com:([^/]+)/([^/]+?)(?:\.git)?$", url) + if m: + return m.group(1), m.group(2) + # https://github.com/owner/repo[.git] + m = re.match(r"https?://github\.com/([^/]+)/([^/]+?)(?:\.git)?/?$", url) + if m: + return m.group(1), m.group(2) + raise ValueError( + f"Cannot parse GitHub owner/repo from URL: {url!r}. " + "Expected SSH (git@github.com:owner/repo.git) or " + "HTTPS (https://github.com/owner/repo.git) format." + ) + + # ------------------------------------------------------------------ + # VCSAdapter interface + # ------------------------------------------------------------------ def create_branch(self, name: str) -> None: - # TODO (Phase 2): Create branch via GitHub API or local git subprocess. - # Use config.run.base_branch as the branch point. - raise NotImplementedError("GitHubAdapter.create_branch is not yet implemented.") + """ + Create a new branch off ``self._base_branch`` on the remote. - def commit(self, files: list[str], message: str) -> str: - # TODO (Phase 2): Stage files (git add), create commit (git commit), push. - # Return the resulting commit SHA. - raise NotImplementedError("GitHubAdapter.commit is not yet implemented.") + Parameters + ---------- + name : New branch name (e.g. "feat/webhook-ingestion"). + """ + base_ref = self._repo.get_git_ref(f"heads/{self._base_branch}") + self._repo.create_git_ref(f"refs/heads/{name}", base_ref.object.sha) + + def commit( + self, + files: Union[dict[str, str], list[str]], + message: str, + branch: str | None = None, + ) -> str: + """ + Commit files to the repository via the GitHub Contents API. + + Parameters + ---------- + files : Either a ``dict[path, content]`` mapping (preferred), or a + ``list[path]`` of local file paths whose content is read from + disk. + message : Commit message. + branch : Target branch. Defaults to ``self._base_branch``. + + Returns + ------- + SHA of the last created/updated commit, or empty string if no files + were committed. + """ + target_branch = branch or self._base_branch + + # Normalise to {path: content} + if isinstance(files, list): + files_dict: dict[str, str] = {} + for path in files: + with open(path, "r", encoding="utf-8") as fh: + files_dict[path] = fh.read() + else: + files_dict = files + + last_sha: str = "" + for path, content in files_dict.items(): + try: + existing = self._repo.get_contents(path, ref=target_branch) + result = self._repo.update_file( + path=path, + message=message, + content=content, + sha=existing.sha, # type: ignore[union-attr] + branch=target_branch, + ) + except GithubException: + # File does not exist yet — create it + result = self._repo.create_file( + path=path, + message=message, + content=content, + branch=target_branch, + ) + last_sha = result["commit"].sha + + return last_sha def create_pr(self, title: str, body: str, head: str, base: str) -> str: - # TODO (Phase 2): POST to GitHub API /repos/{owner}/{repo}/pulls. - # Return the HTML URL of the created PR. - raise NotImplementedError("GitHubAdapter.create_pr is not yet implemented.") + """ + Open a pull request on GitHub. + + Parameters + ---------- + title : PR title. + body : PR description / body markdown. + head : Head branch name (the branch with changes). + base : Base branch name (e.g. "main"). + + Returns + ------- + HTML URL of the created pull request. + """ + pr = self._repo.create_pull( + title=title, + body=body, + head=head, + base=base, + ) + return pr.html_url def get_pr_status(self, pr_id: str) -> str: - # TODO (Phase 2): GET /repos/{owner}/{repo}/pulls/{number}. - # Map GitHub PR state ("open", "closed") + merged flag to - # our schema: "open" | "merged" | "closed". - raise NotImplementedError("GitHubAdapter.get_pr_status is not yet implemented.") + """ + Fetch the current status of a pull request. + + Parameters + ---------- + pr_id : Pull request number as a string (e.g. "42"). + + Returns + ------- + One of: "open" | "merged" | "closed". + """ + pr = self._repo.get_pull(int(pr_id)) + if pr.merged: + return "merged" + return pr.state # "open" or "closed" diff --git a/requirements.txt b/requirements.txt index 0cac1d0..6076b6b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,6 +10,9 @@ pyyaml # Environment variable management python-dotenv +# GitHub VCS adapter +PyGithub + # --- stdlib-only (no pip install needed) --- # sqlite3 — blackboard persistence # dataclasses — task_brief schema