diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0253077 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + pull_request: + push: + branches: ["main"] + +permissions: + contents: read + +jobs: + ruff: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: requirements-dev.txt + + - name: Install dev dependencies + run: pip install -r requirements-dev.txt + + - name: Ruff lint + run: ruff check . + + - name: Ruff format + run: ruff format --check . diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2ebac50..b9494ad 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -59,7 +59,7 @@ Merci de contribuer a PatchworkAgent ! Ce guide explique comment ajouter un nouv ## Ajouter un nouveau provider IA -Pour ajouter un provider `myprovider` avec le label de declenchement `ai-pr-myprovider` : +Pour ajouter un provider `myprovider` configure par le label `ai-pr-myprovider` : ### 1. Script provider : `providers/myprovider.sh` @@ -191,7 +191,7 @@ docker save worker-myprovider:latest | sudo k3s ctr images import - kubectl -n ai-bot apply -f k8s/debug-myprovider.yaml kubectl -n ai-bot logs -f job/debug-myprovider -# Test end-to-end : ajouter le label ai-pr-myprovider sur une issue +# Test end-to-end : ajouter le label ai-pr-myprovider puis commenter /agent implement sur une issue ``` --- @@ -242,7 +242,8 @@ kubectl -n ai-bot logs -f deploy/orchestrator --tail=200 | Element | Convention | Exemple | |---------|-----------|---------| -| Label GitHub | `ai-pr-` | `ai-pr-claude` | +| Label provider GitHub | `ai-pr-` | `ai-pr-claude` | +| Commande agent | `/agent ` | `/agent implement` | | Image Docker | `worker-:latest` | `worker-aider:latest` | | Secret K8s | `-api-key` | `openrouter-api-key` | | Script provider | `providers/.sh` | `providers/aider.sh` | diff --git a/README.md b/README.md index bc187d3..b65843f 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Kubernetes orchestrator that turns GitHub issues into pull requests using AI age ## Why this project -This project automates the **Issue -> Label -> Pull Request** flow: an `ai-pr-*` label on an issue triggers an AI worker that clones the repo, solves the problem, and opens a PR. +This project automates the **Issue -> Provider Label -> /agent command -> Pull Request** flow: an `ai-pr-*` label configures the AI worker provider, and an issue comment such as `/agent implement` starts the worker that clones the repo, solves the problem, and opens a PR. It avoids AI vendor lock-in with 3 built-in worker providers: @@ -32,7 +32,7 @@ Tested on: VPS / 8 GB RAM / 4 vCPU / k3s single-node. ![PatchworkAgent Architecture](illustration.png) ``` -GitHub Issue (label ai-pr-*) +GitHub Issue (ai-pr-* label + /agent implement comment) | v POST /webhook/github @@ -59,9 +59,10 @@ GitHub Issue (label ai-pr-*) Workers receive only the short-lived token and never receive the PEM key. **Job environment (metadata)** : the orchestrator injects source-agnostic -variables for each worker Job: `SOURCE_REPO`, `SOURCE_ISSUE_NUMBER`, -`SOURCE_ISSUE_TITLE`, `SOURCE_ISSUE_BODY` (GitHub issue description, bounded to -64 KiB), `SOURCE_ISSUE_URL`, `SOURCE_EVENT_ACTION`, `SOURCE_INSTALLATION_ID`. +variables for each worker Job: `SOURCE_REPO`, `SOURCE_ISSUE_NUMBER`, +`SOURCE_ISSUE_TITLE`, `SOURCE_ISSUE_BODY` (GitHub issue description, bounded to +64 KiB), `SOURCE_ISSUE_URL`, `SOURCE_INSTALLATION_ID`, `SOURCE_ACTION`, +`SOURCE_TRIGGER`, `SOURCE_TRIGGER_COMMAND`. See `docs/adr/0001-source-provider-abstraction.md`. The clone credential secret key remains `GITHUB_TOKEN` (installation token). @@ -158,7 +159,10 @@ kubectl -n ai-bot apply -f k8s/orchestrator.yaml ### 4. Usage -Add a label `ai-pr-claude`, `ai-pr-codex`, or `ai-pr-aider` to a GitHub issue. The bot automatically creates a PR. +Add a label `ai-pr-claude`, `ai-pr-codex`, or `ai-pr-aider` to configure the provider, then comment `/agent implement` on the issue. The bot creates a PR. + +Migration note: `TRIGGER_PREFIX` has been removed. Use +`PROVIDER_LABEL_PREFIX` to configure provider label matching. --- @@ -330,9 +334,9 @@ sudo systemctl status k3s --no-pager -l | |-- ai-issue-*.yaml # Manual jobs per provider | |-- debug-*.yaml # Debug jobs per provider | `-- secrets/ # Templates (no values) -|-- prompt/ -| |-- issue_prompt.sh # Optional SOURCE_ISSUE_BODY appendix -| `-- issue_start_prompt.sh # Shared task instructions (all workers) +|-- prompt/ +| |-- action_prompt.sh # Dispatches prompts by SOURCE_ACTION +| `-- actions/ # Action-specific worker prompts |-- providers/ | |-- source/ # SourceProvider interface + GitHub implementation | |-- git_workflow.sh # Shared Git logic diff --git a/app/agent_orchestrator.py b/app/agent_orchestrator.py new file mode 100644 index 0000000..5f521e9 --- /dev/null +++ b/app/agent_orchestrator.py @@ -0,0 +1,296 @@ +from __future__ import annotations + +import logging +from collections.abc import Callable +from datetime import datetime, timezone +from typing import Any + +from fastapi import HTTPException +from kubernetes.client.rest import ApiException + +from app.config import PROVIDER_CONFIG, ProviderConfig +from providers.source import ( + SourceProvider, + SourceProviderAPIError, + SourceProviderConfigurationError, + SourceProviderError, +) + +MAX_ISSUE_BODY_CHARS = 65536 + +IMPLEMENTED_ACTIONS = {"implement"} +AGENT_STATUS_PREFIX = "agent:status:" +AGENT_STATUS_IDLE = "agent:status:idle" +AGENT_STATUS_RUNNING = "agent:status:running" +AGENT_STATUS_AWAITING_HUMAN = "agent:status:awaiting-human" +AGENT_STATUS_BLOCKED = "agent:status:blocked" +AGENT_STATUS_FAILED = "agent:status:failed" + +logger = logging.getLogger("uvicorn.error") + + +class AgentOrchestrator: + def __init__( + self, + *, + namespace: str, + provider_label_prefix: str, + load_k8s_client: Callable[[], tuple[Any, Any]], + build_worker_job: Callable[[str, ProviderConfig, str, dict[str, str], str], Any], + create_or_replace_secret: Callable[[Any, str, dict[str, str]], None], + delete_secret_if_exists: Callable[[Any, str], None], + attach_job_owner_to_secret: Callable[[Any, str, str, str], None], + safe_name: Callable[[str], str], + ) -> None: + self.namespace = namespace + self.provider_label_prefix = provider_label_prefix + self.load_k8s_client = load_k8s_client + self.build_worker_job = build_worker_job + self.create_or_replace_secret = create_or_replace_secret + self.delete_secret_if_exists = delete_secret_if_exists + self.attach_job_owner_to_secret = attach_job_owner_to_secret + self.safe_name = safe_name + + async def handle_webhook(self, source_provider: SourceProvider, headers: dict, body: bytes) -> dict: + if not await source_provider.verify_webhook(headers, body): + logger.warning("Invalid webhook signature") + raise HTTPException(status_code=401, detail="invalid webhook signature") + + source_event = await source_provider.parse_event(headers, body) + if not source_event: + logger.info("Ignored unhandled source event") + return {"ok": True, "ignored": True, "reason": "no source event"} + + logger.info("Webhook received: type=%s repo=%s", source_event.type, source_event.repo) + + if source_event.issue and source_event.type in {"issue_opened", "issue_labeled"}: + provider = self.find_provider(source_event.issue.labels) + if provider: + await self.replace_agent_status( + source_provider, + source_event.repo, + str(source_event.issue.number), + source_event.issue.labels, + AGENT_STATUS_IDLE, + ) + + trigger = source_provider.get_action_trigger(source_event) + if not trigger: + reason = f"no action trigger for source_event={source_event.type}" + logger.info("Not triggering: %s", reason) + return {"ok": True, "ignored": True, "reason": reason} + + if not source_event.issue: + logger.error("Action trigger has no issue context") + raise HTTPException(status_code=400, detail="missing source issue") + + repo_full = source_event.repo + issue = source_event.issue + issue_number = issue.number + issue_id = str(issue_number) + + try: + can_trigger = await source_provider.can_trigger_action(source_event, trigger) + except SourceProviderAPIError as ex: + await self.replace_agent_status(source_provider, repo_full, issue_id, issue.labels, AGENT_STATUS_BLOCKED) + await source_provider.add_issue_comment( + repo_full, issue_id, "Agent is blocked: unable to verify command permissions." + ) + logger.exception("Source provider permission check failed: %s", ex) + raise HTTPException(status_code=502, detail="source provider API request failed") from ex + + if not can_trigger: + logger.info("Actor %s is not allowed to trigger agent action %s", source_event.actor, trigger.action) + return {"ok": True, "ignored": True, "reason": "actor not allowed"} + + if trigger.action not in IMPLEMENTED_ACTIONS: + await self.replace_agent_status( + source_provider, + repo_full, + issue_id, + issue.labels, + AGENT_STATUS_AWAITING_HUMAN, + ) + await source_provider.add_issue_comment( + repo_full, + issue_id, + f"`{trigger.raw_command}` is recognized, but `{trigger.action}` is not implemented yet.", + ) + return { + "ok": True, + "ignored": True, + "reason": f"action not implemented: {trigger.action}", + "action": trigger.action, + } + + provider = self.find_provider(issue.labels) + if not provider: + await self.replace_agent_status( + source_provider, + repo_full, + issue_id, + issue.labels, + AGENT_STATUS_AWAITING_HUMAN, + ) + await source_provider.add_issue_comment( + repo_full, + issue_id, + f"`{trigger.raw_command}` needs a provider label such as `{self.provider_label_prefix}aider`.", + ) + reason = f"no label matching {self.provider_label_prefix}* for action={trigger.action}" + logger.info("Not triggering: %s", reason) + return {"ok": True, "ignored": True, "reason": reason} + + cfg = PROVIDER_CONFIG.get(provider) + if not cfg: + raise HTTPException(status_code=400, detail=f"unknown provider: {provider[:20]}") + + installation_id = source_event.source_installation_id + if not installation_id: + logger.error("Missing source installation id in source event") + raise HTTPException(status_code=400, detail="missing source installation id") + + job_name = self.safe_name(f"ai-pr-{repo_full.replace('/', '-')}-{issue_number}-{provider}") + batch, core = self.load_k8s_client() + + try: + _, github_token = await source_provider.get_clone_credentials(repo_full) + except SourceProviderConfigurationError as ex: + await self.replace_agent_status(source_provider, repo_full, issue_id, issue.labels, AGENT_STATUS_BLOCKED) + await source_provider.add_issue_comment( + repo_full, issue_id, "Agent is blocked: source provider is not configured." + ) + logger.exception("Source provider configuration error: %s", ex) + raise HTTPException(status_code=500, detail="source provider is not configured") from ex + except SourceProviderAPIError as ex: + await self.replace_agent_status(source_provider, repo_full, issue_id, issue.labels, AGENT_STATUS_BLOCKED) + await source_provider.add_issue_comment( + repo_full, issue_id, "Agent is blocked: source provider API request failed." + ) + logger.exception("Source provider API error: %s", ex) + raise HTTPException(status_code=502, detail="source provider API request failed") from ex + except SourceProviderError as ex: + await self.replace_agent_status(source_provider, repo_full, issue_id, issue.labels, AGENT_STATUS_BLOCKED) + await source_provider.add_issue_comment(repo_full, issue_id, "Agent is blocked: source provider error.") + logger.exception("Source provider error: %s", ex) + raise HTTPException(status_code=500, detail="source provider error") from ex + + token_secret_name = self.safe_name(f"{job_name}-gh-token") + self.create_or_replace_secret(core, token_secret_name, {"GITHUB_TOKEN": github_token}) + + job = self.build_worker_job( + job_name, + cfg, + provider, + { + "SOURCE_REPO": repo_full, + "SOURCE_ISSUE_NUMBER": issue_id, + "SOURCE_ISSUE_TITLE": issue.title[:200], + "SOURCE_ISSUE_BODY": issue.body[:MAX_ISSUE_BODY_CHARS], + "SOURCE_ISSUE_URL": issue.url, + "SOURCE_INSTALLATION_ID": str(installation_id), + "SOURCE_ACTION": trigger.action, + "SOURCE_TRIGGER": trigger.source, + "SOURCE_TRIGGER_COMMAND": trigger.raw_command, + }, + token_secret_name, + ) + + logger.info("Creating Job: name=%s namespace=%s image=%s", job_name, self.namespace, cfg.image) + try: + created_obj = batch.create_namespaced_job(namespace=self.namespace, body=job) + created_name = getattr(created_obj.metadata, "name", None) + created_uid = getattr(created_obj.metadata, "uid", None) + logger.info("K8s Job created: name=%s uid=%s", created_name, created_uid) + if created_name and created_uid: + try: + self.attach_job_owner_to_secret(core, token_secret_name, created_name, created_uid) + except Exception as ex: + logger.warning("Unable to attach ownerReference on secret %s: %s", token_secret_name, ex) + await self.replace_agent_status( + source_provider, + repo_full, + issue_id, + issue.labels, + AGENT_STATUS_RUNNING, + ) + created = True + except ApiException as e: + logger.exception( + "ApiException creating job: status=%s reason=%s ", + getattr(e, "status", None), + getattr(e, "reason", None), + ) + self.delete_secret_if_exists(core, token_secret_name) + if getattr(e, "status", None) == 409: + logger.info("Job already exists (idempotent): %s", job_name) + await self.replace_agent_status( + source_provider, + repo_full, + issue_id, + issue.labels, + AGENT_STATUS_AWAITING_HUMAN, + ) + await source_provider.add_issue_comment( + repo_full, + issue_id, + "Agent command was received, but a worker job for this issue already exists.", + ) + created = False + else: + await self.replace_agent_status(source_provider, repo_full, issue_id, issue.labels, AGENT_STATUS_FAILED) + raise HTTPException( + status_code=500, + detail="k8s error creating job", + ) from e + except Exception as ex: + self.delete_secret_if_exists(core, token_secret_name) + await self.replace_agent_status(source_provider, repo_full, issue_id, issue.labels, AGENT_STATUS_FAILED) + logger.exception("Unexpected error creating job: %s", ex) + raise HTTPException(status_code=500, detail="internal error") from ex + + return { + "ok": True, + "triggered": True, + "created": created, + "job": job_name, + "namespace": self.namespace, + "repo": repo_full, + "issue_number": issue_number, + "action": trigger.action, + "provider": provider, + "timestamp": datetime.now(timezone.utc).isoformat(), + } + + def find_provider(self, labels: list[str]) -> str | None: + for label in labels: + result = self.extract_provider_from_label(label) + if result: + return result + return None + + def extract_provider_from_label(self, label_name: str | None) -> str | None: + if ( + not label_name + or not label_name.startswith(self.provider_label_prefix) + or len(label_name) <= len(self.provider_label_prefix) + ): + return None + suffix = label_name[len(self.provider_label_prefix) :] + if suffix in PROVIDER_CONFIG: + return suffix + return None + + async def replace_agent_status( + self, + source_provider: SourceProvider, + repo: str, + issue_id: str, + labels: list[str], + status: str, + ) -> None: + next_labels = [label for label in labels if not label.startswith(AGENT_STATUS_PREFIX)] + if status not in next_labels: + next_labels.append(status) + if next_labels != labels: + await source_provider.replace_issue_labels(repo, issue_id, next_labels) diff --git a/app/app.py b/app/app.py index 45b2fe2..73d667a 100644 --- a/app/app.py +++ b/app/app.py @@ -13,7 +13,7 @@ import re import secrets import uuid -from datetime import UTC, datetime +from datetime import datetime, timezone from fastapi import Body, Depends, FastAPI, HTTPException, Request from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer @@ -21,16 +21,9 @@ from kubernetes.client.rest import ApiException from pydantic import BaseModel, Field -from app.config import PROVIDER_CONFIG, ProviderConfig, settings -from providers.source import ( - SourceProviderAPIError, - SourceProviderConfigurationError, - SourceProviderError, - get_provider, -) - -# Issue body in a pod env var must stay bounded (etcd / API limits, huge GitHub bodies). -_MAX_ISSUE_BODY_CHARS = 65536 +from app.agent_orchestrator import AgentOrchestrator +from app.config import ProviderConfig, settings +from providers.source import get_provider # --- Logging setup --- # Use uvicorn's logger so messages aren't disabled by uvicorn's dictConfig @@ -88,6 +81,19 @@ def get_source_provider(): ) +def get_agent_orchestrator() -> AgentOrchestrator: + return AgentOrchestrator( + namespace=settings.namespace, + provider_label_prefix=settings.provider_label_prefix, + load_k8s_client=load_k8s_client, + build_worker_job=_build_worker_job, + create_or_replace_secret=_create_or_replace_secret, + delete_secret_if_exists=_delete_secret_if_exists, + attach_job_owner_to_secret=_attach_job_owner_to_secret, + safe_name=safe_name, + ) + + def _build_worker_job( job_name: str, cfg: ProviderConfig, @@ -261,7 +267,7 @@ def create_or_update_github_app_secret(payload: SecretPayload = Body(...), _auth "result": action, "secret": name, "namespace": settings.namespace, - "updated_at": datetime.now(UTC).isoformat(), + "updated_at": datetime.now(timezone.utc).isoformat(), } @@ -270,158 +276,8 @@ async def github_webhook(request: Request): body = await request.body() headers = dict(request.headers) source_provider = get_source_provider() - - if not await source_provider.verify_webhook(headers, body): - logger.warning("Invalid webhook signature") - raise HTTPException(status_code=401, detail="invalid webhook signature") - - event = request.headers.get("X-GitHub-Event", "") - if event != "issues": - logger.info("Ignored event=%s", event) - return {"ok": True, "ignored": True, "reason": f"event={event}"} - - source_event = await source_provider.parse_event(headers, body) - if not source_event: - logger.info("Ignored unhandled GitHub event=%s", event) - return {"ok": True, "ignored": True, "reason": f"event={event}"} - - payload = source_event.raw - action = payload.get("action") - - logger.info( - "Webhook received: event=issues action=%s repo=%s", - action, - ((payload.get("repository") or {}).get("full_name")), - ) - - logger.debug("TRIGGER_PREFIX: %s", settings.trigger_prefix) - logger.debug("PAYLOAD LABEL: %s", payload.get("label")) - - # Decide whether we trigger — match any label starting with TRIGGER_PREFIX - # e.g. "ai-pr-claude", "ai-pr-openai" - provider = None - - if action == "opened": - provider = issue_find_provider(payload, settings.trigger_prefix) - elif action == "labeled": - label = payload.get("label") or {} - label_name = label.get("name") if isinstance(label, dict) else str(label) - provider = _extract_provider_from_label(label_name, settings.trigger_prefix) - # else: action not handled - - if not provider: - reason = f"no label matching {settings.trigger_prefix}* for action={action}" - logger.info("Not triggering: %s", reason) - return {"ok": True, "ignored": True, "reason": reason} - - # Validate provider before any logging or further use - cfg = PROVIDER_CONFIG.get(provider) - if not cfg: - raise HTTPException(status_code=400, detail=f"unknown provider: {provider[:20]}") - - logger.info("Triggered with provider=%s", provider) - - repo_full = (payload.get("repository") or {}).get("full_name") - issue = payload.get("issue") or {} - issue_number = issue.get("number") - issue_title = issue.get("title", "")[:200] - raw_issue_body = issue.get("body") - issue_body = (raw_issue_body if isinstance(raw_issue_body, str) else "")[:_MAX_ISSUE_BODY_CHARS] - issue_url = issue.get("html_url", "") - installation_id = (payload.get("installation") or {}).get("id") - - if not repo_full or not issue_number: - logger.error("Missing repo_full or issue_number") - raise HTTPException(status_code=400, detail="missing repository.full_name or issue.number") - - if not installation_id: - logger.error("Missing installation.id in webhook payload") - raise HTTPException(status_code=400, detail="missing installation.id (is the GitHub App installed?)") - - job_name = safe_name(f"ai-pr-{repo_full.replace('/', '-')}-{issue_number}-{provider}") - - batch, core = load_k8s_client() - - try: - _, github_token = await source_provider.get_clone_credentials(repo_full) - except SourceProviderConfigurationError as ex: - logger.exception("Source provider configuration error: %s", ex) - raise HTTPException(status_code=500, detail="source provider is not configured") from ex - except SourceProviderAPIError as ex: - logger.exception("Source provider API error: %s", ex) - raise HTTPException(status_code=502, detail="source provider API request failed") from ex - except SourceProviderError as ex: - logger.exception("Source provider error: %s", ex) - raise HTTPException(status_code=500, detail="source provider error") from ex - token_secret_name = safe_name(f"{job_name}-gh-token") - _create_or_replace_secret(core, token_secret_name, {"GITHUB_TOKEN": github_token}) - - job = _build_worker_job( - job_name=job_name, - cfg=cfg, - provider=provider, - env_vars={ - # Source-provider-agnostic metadata (see docs/adr/0001-source-provider-abstraction.md). - "SOURCE_REPO": repo_full, - "SOURCE_ISSUE_NUMBER": str(issue_number), - "SOURCE_EVENT_ACTION": str(action), - "SOURCE_ISSUE_TITLE": issue_title, - "SOURCE_ISSUE_BODY": issue_body, - "SOURCE_ISSUE_URL": issue_url, - "SOURCE_INSTALLATION_ID": str(installation_id), - }, - github_token_secret_name=token_secret_name, - ) - - # --- Logging around create_namespaced_job --- - logger.info("Creating Job: name=%s namespace=%s image=%s", job_name, settings.namespace, cfg.image) - # logger.debug("Job body: %s", job) # inutile en prod (peut contenir secrets) - - try: - created_obj = batch.create_namespaced_job(namespace=settings.namespace, body=job) - created_name = getattr(created_obj.metadata, "name", None) - created_uid = getattr(created_obj.metadata, "uid", None) - logger.info("K8s Job created: name=%s uid=%s", created_name, created_uid) - if created_name and created_uid: - try: - _attach_job_owner_to_secret(core, token_secret_name, created_name, created_uid) - except Exception as ex: - logger.warning("Unable to attach ownerReference on secret %s: %s", token_secret_name, ex) - created = True - except ApiException as e: - # show useful details for debugging - logger.exception( - "ApiException creating job: status=%s reason=%s ", - getattr(e, "status", None), - getattr(e, "reason", None), - ) - if getattr(e, "status", None) == 409: - _delete_secret_if_exists(core, token_secret_name) - logger.info("Job already exists (idempotent): %s", job_name) - created = False - else: - _delete_secret_if_exists(core, token_secret_name) - raise HTTPException( - status_code=500, - detail=f"k8s error creating job: {getattr(e, 'reason', None)}", - ) from e - except Exception as ex: - _delete_secret_if_exists(core, token_secret_name) - logger.exception("Unexpected error creating job: %s", ex) - raise HTTPException(status_code=500, detail="internal error") from ex - - return { - "ok": True, - "triggered": True, - "created": created, - "job": job_name, - "namespace": settings.namespace, - "repo": repo_full, - "issue_number": issue_number, - "action": action, - "provider": provider, - "timestamp": datetime.now(UTC).isoformat(), - } + orchestrator = get_agent_orchestrator() + return await orchestrator.handle_webhook(source_provider, headers, body) @app.post("/jobs/run") @@ -464,33 +320,12 @@ def run_job(_auth: bool = Depends(verify_admin_token)): ) raise HTTPException( status_code=500, - detail=f"k8s error creating job: {getattr(e, 'reason', None)}", + detail="k8s error creating job", ) from e return {"status": "started", "job_name": job_name} -def _extract_provider_from_label(label_name: str | None, prefix: str) -> str | None: - """Return validated provider suffix if label matches prefix, else None.""" - if not label_name or not label_name.startswith(prefix) or len(label_name) <= len(prefix): - return None - suffix = label_name[len(prefix) :] - if suffix in PROVIDER_CONFIG: - return suffix - return None - - -def issue_find_provider(payload: dict, prefix: str) -> str | None: - """Find the first label matching prefix and return the provider suffix, or None.""" - labels = (payload.get("issue") or {}).get("labels") or [] - for lb in labels: - name = lb.get("name") if isinstance(lb, dict) else str(lb) - result = _extract_provider_from_label(name, prefix) - if result: - return result - return None - - if __name__ == "__main__": import uvicorn diff --git a/app/config.py b/app/config.py index fd23948..e5d9e7d 100644 --- a/app/config.py +++ b/app/config.py @@ -12,7 +12,7 @@ def _env_bool(name: str, default: str = "false") -> bool: class Settings: namespace: str = os.getenv("NAMESPACE", "ai-bot") job_ttl_seconds: int = int(os.getenv("JOB_TTL_SECONDS", "3600")) - trigger_prefix: str = os.getenv("TRIGGER_PREFIX", "ai-pr-") + provider_label_prefix: str = os.getenv("PROVIDER_LABEL_PREFIX", "ai-pr-") webhook_secret: str = os.getenv("WEBHOOK_SECRET", "") admin_token: str = os.getenv("ADMIN_TOKEN", "") enable_k8s_debug: bool = _env_bool("ENABLE_K8S_DEBUG") diff --git a/docs/adr/0001-source-provider-abstraction.md b/docs/adr/0001-source-provider-abstraction.md index 8a029d6..bb89b5b 100644 --- a/docs/adr/0001-source-provider-abstraction.md +++ b/docs/adr/0001-source-provider-abstraction.md @@ -72,7 +72,8 @@ Tradeoffs: Worker jobs receive **source-agnostic metadata** as `SOURCE_*` environment variables (for example `SOURCE_REPO`, `SOURCE_ISSUE_NUMBER`, `SOURCE_ISSUE_BODY`, -`SOURCE_ISSUE_URL`, `SOURCE_INSTALLATION_ID`, `SOURCE_EVENT_ACTION`) populated +`SOURCE_ISSUE_URL`, `SOURCE_INSTALLATION_ID`, `SOURCE_ACTION`, `SOURCE_TRIGGER`, +`SOURCE_TRIGGER_COMMAND`) populated from the active `SourceProvider`. The HTTPS clone credential still uses the secret key **`GITHUB_TOKEN`** today (GitHub App installation token). Renaming that credential for non-GitHub hosts is a separate change. diff --git a/docs/adr/0002-agentic-command-flow.md b/docs/adr/0002-agentic-command-flow.md new file mode 100644 index 0000000..ccd7271 --- /dev/null +++ b/docs/adr/0002-agentic-command-flow.md @@ -0,0 +1,137 @@ +# ADR 0002: Agentic Command Flow + +## Status + +Accepted + +## Context + +The previous orchestration model used `ai-pr-*` labels for two different +purposes: + +- selecting the AI worker provider +- triggering worker execution + +That coupling made work easy to start accidentally and made it hard to add +multi-step agent flows such as spec, plan, implement, review, and continue. + +The source-provider abstraction already exposes normalized issue, comment, +label, and webhook event concepts. GitHub can parse `issue_comment` webhooks +into `issue_commented` events, which gives the system a source-agnostic place +to detect explicit user commands. + +## Decision + +Use an explicit command-based agent flow. + +Provider labels are declarative configuration only: + +```text +ai-pr-aider +ai-pr-codex +ai-pr-claude +``` + +Issue comments trigger agent actions: + +```text +/agent spec +/agent plan +/agent implement +/agent review +/agent continue +``` + +For the first milestone, only `/agent implement` starts a worker. Other known +actions are recognized but do not create a worker job until their flows exist. + +Source providers own source-specific webhook parsing and command extraction. +The orchestrator must not branch on provider-specific webhook names such as +GitHub's `issues` or `issue_comment`. + +The orchestrator delegates command flow logic to an `AgentOrchestrator` class +instead of keeping that behavior inside the FastAPI route handler. + +Agent status is represented with one source label at a time: + +```text +agent:status:idle +agent:status:running +agent:status:awaiting-human +agent:status:blocked +agent:status:failed +agent:status:done +``` + +## Consequences + +### Positive + +- Adding or changing provider labels no longer starts work. +- Agent execution is explicit and auditable through issue comments. +- The orchestration route stays small and delegates flow behavior to a focused + class. +- Source-specific webhook details remain inside source providers. +- Future actions can be added without changing the trigger model. +- Issue state is visible through labels. + +### Negative + +- A user now needs two steps to start work: add provider label, then comment + `/agent implement`. +- Worker scripts need source API access to mark terminal states such as + `done` and `failed`. +- The system needs to keep state labels consistent and avoid accumulating + multiple `agent:status:*` labels. +- The known action list is duplicated between Python and shell for the MVP. + Introduce a single generated source of truth when multiple actions become + executable. + +## Implementation Notes + +- `SourceProvider.get_action_trigger(event)` returns an `ActionTrigger | None`. +- `SourceProvider.can_trigger_action(event, trigger)` enforces source-specific + actor authorization. The GitHub provider allows actors with `write`, + `maintain`, or `admin` repository permission. +- GitHub extracts commands only from `issue_commented` events. +- Unknown commands such as `/agent foo` return no trigger. +- Action triggers store a normalized command such as `/agent implement`, not the + raw user comment line. +- Known but unimplemented actions set `agent:status:awaiting-human` and comment + back. +- Missing provider label sets `agent:status:awaiting-human` and comments back. +- Accepted `/agent implement` sets `agent:status:running` and creates a worker + job. +- Worker jobs receive: + +```text +SOURCE_ACTION +SOURCE_TRIGGER +SOURCE_TRIGGER_COMMAND +``` + +- Worker prompts are dispatched through `prompt/action_prompt.sh`. + +## Alternatives Considered + +### Keep Label-Based Triggering + +Rejected. It preserves accidental execution risk and does not support a clean +multi-action flow. + +### Put Command Parsing In The FastAPI Route + +Rejected. It would reintroduce source-specific branching into the orchestrator +HTTP layer and make future providers harder to add. + +### Put Orchestration Directly In `app.py` + +Rejected. The endpoint would become responsible for verification, parsing, +state transitions, provider resolution, worker creation, and user feedback. A +dedicated orchestration class keeps the route thin and the flow testable. + +## Related + +- ADR 0001: Source Provider Abstraction +- `.local/agentic-flow-prd.md` +- `.local/agentic-flow-plan.md` diff --git a/docs/workspace.dsl b/docs/workspace.dsl index dd2e81a..c1f4fbb 100644 --- a/docs/workspace.dsl +++ b/docs/workspace.dsl @@ -26,10 +26,10 @@ workspace "PatchworkAgent" "Kubernetes-native system that automatically solves G } # --- Relationships: Developer --- - developer -> github "Opens issues, adds labels (ai-pr-*), reviews PRs" + developer -> github "Opens issues, configures provider labels (ai-pr-*), comments /agent commands, reviews PRs" # --- Relationships: GitHub <-> System --- - github -> orchestrator "Sends issue webhook (POST /webhook/github)" "HTTPS / JSON" + github -> orchestrator "Sends issue and issue_comment webhooks (POST /webhook/github)" "HTTPS / JSON" orchestrator -> github "Generates ephemeral installation tokens" "HTTPS / GitHub App JWT (RS256)" workerClaude -> github "Clones repo, pushes branch, creates PR" "HTTPS / git + gh CLI" @@ -111,21 +111,22 @@ workspace "PatchworkAgent" "Kubernetes-native system that automatically solves G } # --- Dynamic: Issue Resolution Flow --- - dynamic aiPrBot "IssueResolutionFlow" "End-to-end flow when a developer labels an issue with ai-pr-claude" { - developer -> github "1. Adds label 'ai-pr-claude' to issue" - github -> orchestrator "2. Webhook POST /webhook/github" - orchestrator -> secrets "3. Reads GitHub App PEM + webhook secret" - orchestrator -> github "4. Generates installation token (JWT -> installation token)" - orchestrator -> k8sApi "5. Creates ephemeral Secret with GITHUB_TOKEN" - orchestrator -> k8sApi "6. Creates Job (ai-pr-*-claude)" - k8sApi -> workerClaude "7. Schedules worker pod" - workerClaude -> secrets "8. Reads ANTHROPIC_API_KEY + ephemeral GITHUB_TOKEN" - workerClaude -> github "9. Clones repository" - workerClaude -> anthropicApi "10. Invokes Claude Code CLI for fix" - workerClaude -> github "11. Pushes branch + creates PR" - developer -> github "12. Reviews and merges PR" - autoLayout - } + dynamic aiPrBot "IssueResolutionFlow" "End-to-end flow when a developer comments /agent implement on an ai-pr-claude issue" { + developer -> github "1. Adds label 'ai-pr-claude' to configure provider" + developer -> github "2. Comments '/agent implement' on issue" + github -> orchestrator "3. Webhook POST /webhook/github" + orchestrator -> secrets "4. Reads GitHub App PEM + webhook secret" + orchestrator -> github "5. Generates installation token (JWT -> installation token)" + orchestrator -> k8sApi "6. Creates ephemeral Secret with GITHUB_TOKEN" + orchestrator -> k8sApi "7. Creates Job (ai-pr-*-claude)" + k8sApi -> workerClaude "8. Schedules worker pod" + workerClaude -> secrets "9. Reads ANTHROPIC_API_KEY + ephemeral GITHUB_TOKEN" + workerClaude -> github "10. Clones repository" + workerClaude -> anthropicApi "11. Invokes Claude Code CLI for fix" + workerClaude -> github "12. Pushes branch + creates PR" + developer -> github "13. Reviews and merges PR" + autoLayout + } styles { element "Person" { diff --git a/images/worker-aider/Dockerfile b/images/worker-aider/Dockerfile index e8b02b2..8214fa1 100644 --- a/images/worker-aider/Dockerfile +++ b/images/worker-aider/Dockerfile @@ -19,8 +19,9 @@ WORKDIR /app COPY --chown=worker:worker images/worker-aider/run.sh /app/run.sh COPY --chown=worker:worker providers/ /app/providers/ COPY --chown=worker:worker prompt/ /app/prompt/ -RUN sed -i 's/\r$//' /app/run.sh /app/providers/*.sh /app/prompt/*.sh \ - && chmod +x /app/run.sh /app/providers/*.sh /app/prompt/*.sh +RUN sed -i 's/\r$//' /app/run.sh \ + && find /app/providers /app/prompt -name '*.sh' -exec sed -i 's/\r$//' {} + \ + && chmod +x /app/run.sh /app/providers/*.sh /app/prompt/*.sh /app/prompt/actions/*.sh ENV PATH="/home/worker/.local/bin:${PATH}" WORKDIR /work diff --git a/images/worker-claude/Dockerfile b/images/worker-claude/Dockerfile index 2b182a2..cb43e56 100644 --- a/images/worker-claude/Dockerfile +++ b/images/worker-claude/Dockerfile @@ -19,8 +19,9 @@ WORKDIR /app COPY --chown=worker:worker images/worker-claude/run.sh /app/run.sh COPY --chown=worker:worker providers/ /app/providers/ COPY --chown=worker:worker prompt/ /app/prompt/ -RUN sed -i 's/\r$//' /app/run.sh /app/providers/*.sh /app/prompt/*.sh \ - && chmod +x /app/run.sh /app/providers/*.sh /app/prompt/*.sh +RUN sed -i 's/\r$//' /app/run.sh \ + && find /app/providers /app/prompt -name '*.sh' -exec sed -i 's/\r$//' {} + \ + && chmod +x /app/run.sh /app/providers/*.sh /app/prompt/*.sh /app/prompt/actions/*.sh ENV PATH="/home/worker/.local/bin:${PATH}" WORKDIR /work diff --git a/images/worker-codex/Dockerfile b/images/worker-codex/Dockerfile index 7ac0a7a..d1ad590 100644 --- a/images/worker-codex/Dockerfile +++ b/images/worker-codex/Dockerfile @@ -26,8 +26,9 @@ WORKDIR /app COPY --chown=worker:worker images/worker-codex/run.sh /app/run.sh COPY --chown=worker:worker providers/ /app/providers/ COPY --chown=worker:worker prompt/ /app/prompt/ -RUN sed -i 's/\r$//' /app/run.sh /app/providers/*.sh /app/prompt/*.sh \ - && chmod +x /app/run.sh /app/providers/*.sh /app/prompt/*.sh +RUN sed -i 's/\r$//' /app/run.sh \ + && find /app/providers /app/prompt -name '*.sh' -exec sed -i 's/\r$//' {} + \ + && chmod +x /app/run.sh /app/providers/*.sh /app/prompt/*.sh /app/prompt/actions/*.sh WORKDIR /work diff --git a/k8s/orchestrator.yaml b/k8s/orchestrator.yaml index 3cf979a..76d1038 100644 --- a/k8s/orchestrator.yaml +++ b/k8s/orchestrator.yaml @@ -28,8 +28,8 @@ spec: secretKeyRef: name: orchestrator-config key: JOB_TTL_SECONDS - - name: TRIGGER_PREFIX - value: "ai-pr-" + - name: PROVIDER_LABEL_PREFIX + value: "ai-pr-" - name: ADMIN_TOKEN valueFrom: secretKeyRef: diff --git a/prompt/action_prompt.sh b/prompt/action_prompt.sh new file mode 100644 index 0000000..15d0a50 --- /dev/null +++ b/prompt/action_prompt.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# action_prompt.sh - dispatch worker prompts by SOURCE_ACTION. + +PROMPT_DIR="${PROMPT_DIR:?PROMPT_DIR is required}" + +source "$PROMPT_DIR/actions/spec.sh" +source "$PROMPT_DIR/actions/plan.sh" +source "$PROMPT_DIR/actions/implement.sh" +source "$PROMPT_DIR/actions/review.sh" +source "$PROMPT_DIR/actions/continue.sh" + +build_action_prompt() { + case "${SOURCE_ACTION:?SOURCE_ACTION is required}" in + spec) action_spec_prompt ;; + plan) action_plan_prompt ;; + implement) action_implement_prompt ;; + review) action_review_prompt ;; + continue) action_continue_prompt ;; + *) + echo "Unsupported SOURCE_ACTION: ${SOURCE_ACTION}" >&2 + return 2 + ;; + esac +} diff --git a/prompt/actions/continue.sh b/prompt/actions/continue.sh new file mode 100644 index 0000000..b82112c --- /dev/null +++ b/prompt/actions/continue.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +action_continue_prompt() { + echo "Unsupported SOURCE_ACTION: continue" >&2 + return 2 +} diff --git a/prompt/issue_start_prompt.sh b/prompt/actions/implement.sh similarity index 83% rename from prompt/issue_start_prompt.sh rename to prompt/actions/implement.sh index 48a1671..4cf7e92 100644 --- a/prompt/issue_start_prompt.sh +++ b/prompt/actions/implement.sh @@ -1,14 +1,18 @@ #!/usr/bin/env bash -# issue_start_prompt.sh — opening task instructions for all AI worker CLIs. -# Requires: ISSUE_NUMBER, SOURCE_ISSUE_TITLE (env; set by git_workflow / orchestrator). -issue_start_prompt() { +action_implement_prompt() { cat <&2 + return 2 +} diff --git a/prompt/actions/review.sh b/prompt/actions/review.sh new file mode 100644 index 0000000..6bc6339 --- /dev/null +++ b/prompt/actions/review.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +action_review_prompt() { + echo "Unsupported SOURCE_ACTION: review" >&2 + return 2 +} diff --git a/prompt/actions/spec.sh b/prompt/actions/spec.sh new file mode 100644 index 0000000..58452ee --- /dev/null +++ b/prompt/actions/spec.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +action_spec_prompt() { + echo "Unsupported SOURCE_ACTION: spec" >&2 + return 2 +} diff --git a/prompt/issue_prompt.sh b/prompt/issue_prompt.sh deleted file mode 100644 index 1767358..0000000 --- a/prompt/issue_prompt.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env bash -# issue_prompt.sh — append optional SOURCE_ISSUE_BODY to a base worker prompt. -# Sourced from providers/*.sh via PROMPT_DIR (repo: prompt/ ; image: /app/prompt/). -# Uses: SOURCE_ISSUE_BODY (optional), set by orchestrator from SourceProvider. - -issue_append_issue_body() { - local base="$1" - if [ -n "${SOURCE_ISSUE_BODY:-}" ]; then - printf '%s\n\nIssue description:\n%s' "$base" "${SOURCE_ISSUE_BODY}" - else - printf '%s' "$base" - fi -} diff --git a/providers/aider.sh b/providers/aider.sh index 2e5326f..5032b7f 100644 --- a/providers/aider.sh +++ b/providers/aider.sh @@ -7,14 +7,15 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROMPT_DIR="$(cd "$SCRIPT_DIR/../prompt" && pwd)" -source "$PROMPT_DIR/issue_prompt.sh" -source "$PROMPT_DIR/issue_start_prompt.sh" +source "$PROMPT_DIR/action_prompt.sh" # ── 1. Authenticate ── : "${GITHUB_TOKEN:?GITHUB_TOKEN is required}" # ── 2. Clone & branch ── source "$SCRIPT_DIR/git_workflow.sh" +trap 'agent_set_status "agent:status:failed" || true' ERR +FULL_MSG="$(build_action_prompt)" git_clone_and_branch # ── 3. Run Aider via OpenRouter ── @@ -22,9 +23,6 @@ AIDER_MODEL="${AIDER_MODEL:-openrouter/anthropic/claude-sonnet-4}" echo "Running Aider (model=$AIDER_MODEL) for issue #${ISSUE_NUMBER} ..." -BASE_MSG="$(issue_start_prompt)" -FULL_MSG="$(issue_append_issue_body "$BASE_MSG")" - aider \ --model "$AIDER_MODEL" \ --api-key openrouter="$OPENROUTER_API_KEY" \ @@ -40,5 +38,6 @@ fi # ── 4. Push & create PR ── git_push_and_pr "Automated PR created by Aider (OpenRouter) for issue #${ISSUE_NUMBER}." +agent_set_status "agent:status:done" echo "Done" diff --git a/providers/claude_code.sh b/providers/claude_code.sh index b3b58fa..7985126 100644 --- a/providers/claude_code.sh +++ b/providers/claude_code.sh @@ -7,8 +7,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROMPT_DIR="$(cd "$SCRIPT_DIR/../prompt" && pwd)" -source "$PROMPT_DIR/issue_prompt.sh" -source "$PROMPT_DIR/issue_start_prompt.sh" +source "$PROMPT_DIR/action_prompt.sh" # ── 1. Authenticate ── # GITHUB_TOKEN is provided by the orchestrator (ephemeral installation token) @@ -16,17 +15,17 @@ source "$PROMPT_DIR/issue_start_prompt.sh" # ── 2. Clone & branch ── source "$SCRIPT_DIR/git_workflow.sh" +trap 'agent_set_status "agent:status:failed" || true' ERR +FULL_PROMPT="$(build_action_prompt)" git_clone_and_branch # ── 3. Run Claude Code ── echo "Running Claude Code for issue #${ISSUE_NUMBER} ..." -BASE_PROMPT="$(issue_start_prompt)" -FULL_PROMPT="$(issue_append_issue_body "$BASE_PROMPT")" - claude -p --dangerously-skip-permissions "$FULL_PROMPT" # ── 4. Push & create PR ── git_push_and_pr "Automated PR created by Claude Code for issue #${ISSUE_NUMBER}." +agent_set_status "agent:status:done" echo "Done" diff --git a/providers/git_workflow.sh b/providers/git_workflow.sh index 4c78662..7f1b221 100644 --- a/providers/git_workflow.sh +++ b/providers/git_workflow.sh @@ -13,6 +13,25 @@ set -euo pipefail REPO="${SOURCE_REPO:?}" ISSUE_NUMBER="${SOURCE_ISSUE_NUMBER:?}" +agent_set_status() { + local status="${1:?status is required}" + local labels_json + + labels_json="$( + curl -sf \ + -H "Authorization: token ${GITHUB_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/${REPO}/issues/${ISSUE_NUMBER}/labels" \ + | jq -c --arg status "$status" '[.[].name | select(startswith("agent:status:") | not)] + [$status]' + )" + + curl -sf -X PUT \ + -H "Authorization: token ${GITHUB_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/${REPO}/issues/${ISSUE_NUMBER}/labels" \ + -d "$(jq -nc --argjson labels "$labels_json" '{labels:$labels}')" +} + # Use GIT_ASKPASS to avoid leaking token in process list / git error logs _setup_git_askpass() { local askpass_script="/tmp/git-askpass.sh" diff --git a/providers/openai.sh b/providers/openai.sh index a699989..5d4f931 100644 --- a/providers/openai.sh +++ b/providers/openai.sh @@ -7,8 +7,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROMPT_DIR="$(cd "$SCRIPT_DIR/../prompt" && pwd)" -source "$PROMPT_DIR/issue_prompt.sh" -source "$PROMPT_DIR/issue_start_prompt.sh" +source "$PROMPT_DIR/action_prompt.sh" # ── 1. Authenticate ── # GITHUB_TOKEN is provided by the orchestrator (ephemeral installation token) @@ -16,6 +15,8 @@ source "$PROMPT_DIR/issue_start_prompt.sh" # ── 2. Clone & branch ── source "$SCRIPT_DIR/git_workflow.sh" +trap 'agent_set_status "agent:status:failed" || true' ERR +FULL_PROMPT="$(build_action_prompt)" git_clone_and_branch # ── 3. Login & run Codex ── @@ -25,12 +26,10 @@ printenv OPENAI_API_KEY | codex login --with-api-key CODEX_MODEL="${CODEX_MODEL:-gpt-5.3-codex}" echo "Running Codex (model=$CODEX_MODEL) for issue #${ISSUE_NUMBER} ..." -BASE_PROMPT="$(issue_start_prompt)" -FULL_PROMPT="$(issue_append_issue_body "$BASE_PROMPT")" - codex exec --dangerously-bypass-approvals-and-sandbox --model "$CODEX_MODEL" "$FULL_PROMPT" # ── 4. Push & create PR ── git_push_and_pr "Automated PR created by Codex (OpenAI) for issue #${ISSUE_NUMBER}." +agent_set_status "agent:status:done" echo "Done" diff --git a/providers/source/__init__.py b/providers/source/__init__.py index 5b2c969..ad69709 100644 --- a/providers/source/__init__.py +++ b/providers/source/__init__.py @@ -1,8 +1,10 @@ from .base import SourceProvider, SourceProviderAPIError, SourceProviderConfigurationError, SourceProviderError from .factory import get_provider, register_provider -from .models import Comment, Issue, PullRequest, WebhookEvent, WebhookEventType +from .models import ActionName, ActionTrigger, Comment, Issue, PullRequest, WebhookEvent, WebhookEventType __all__ = [ + "ActionName", + "ActionTrigger", "Comment", "Issue", "PullRequest", diff --git a/providers/source/base.py b/providers/source/base.py index 522925a..077c939 100644 --- a/providers/source/base.py +++ b/providers/source/base.py @@ -2,7 +2,7 @@ from abc import ABC, abstractmethod -from .models import Comment, Issue, PullRequest, WebhookEvent +from .models import ActionTrigger, Comment, Issue, PullRequest, WebhookEvent class SourceProviderError(Exception): @@ -34,6 +34,14 @@ async def verify_webhook(self, headers: dict, body: bytes) -> bool: async def parse_event(self, headers: dict, body: bytes) -> WebhookEvent | None: """Parse provider-specific webhook payload into a generic event.""" + @abstractmethod + def get_action_trigger(self, event: WebhookEvent) -> ActionTrigger | None: + """Return an agent action requested by a source event, if any.""" + + @abstractmethod + async def can_trigger_action(self, event: WebhookEvent, trigger: ActionTrigger) -> bool: + """Return whether the event actor is allowed to trigger this action.""" + @abstractmethod async def get_issue(self, repo: str, issue_id: str) -> Issue: """Return one issue by provider-specific issue identifier.""" diff --git a/providers/source/github.py b/providers/source/github.py index 949729f..3aae0d8 100644 --- a/providers/source/github.py +++ b/providers/source/github.py @@ -12,7 +12,7 @@ import jwt from .base import SourceProvider, SourceProviderAPIError, SourceProviderConfigurationError -from .models import Comment, Issue, PullRequest, WebhookEvent +from .models import ActionTrigger, Comment, Issue, PullRequest, WebhookEvent logger = logging.getLogger("uvicorn.error") @@ -21,6 +21,9 @@ JWT_LEEWAY_SECONDS = 60 JWT_TTL_SECONDS = 600 DEFAULT_INSTALLATION_TOKEN_TTL_SECONDS = 3300 +AGENT_ACTIONS = {"spec", "plan", "implement", "review", "continue"} +ACTION_TRIGGER_COMMAND_MAX_CHARS = 64 +ACTION_TRIGGER_ALLOWED_PERMISSIONS = {"write", "maintain", "admin"} class GitHubProvider(SourceProvider): @@ -72,9 +75,11 @@ async def parse_event(self, headers: dict, body: bytes) -> WebhookEvent | None: return None repo = (payload.get("repository") or {}).get("full_name") + source_installation_id = None if repo: installation_id = (payload.get("installation") or {}).get("id") if installation_id: + source_installation_id = str(installation_id) self._installation_ids_by_repo[repo] = str(installation_id) sender = payload.get("sender") or {} @@ -86,13 +91,21 @@ async def parse_event(self, headers: dict, body: bytes) -> WebhookEvent | None: if not issue: return None if action == "opened": - return WebhookEvent(type="issue_opened", actor=actor, repo=issue.repo, issue=issue, raw=payload) + return WebhookEvent( + type="issue_opened", + actor=actor, + repo=issue.repo, + source_installation_id=source_installation_id, + issue=issue, + raw=payload, + ) if action == "labeled": label = _label_name(payload.get("label")) return WebhookEvent( type="issue_labeled", actor=actor, repo=issue.repo, + source_installation_id=source_installation_id, issue=issue, label=label, raw=payload, @@ -103,6 +116,7 @@ async def parse_event(self, headers: dict, body: bytes) -> WebhookEvent | None: type="issue_unlabeled", actor=actor, repo=issue.repo, + source_installation_id=source_installation_id, issue=issue, label=label, raw=payload, @@ -118,6 +132,7 @@ async def parse_event(self, headers: dict, body: bytes) -> WebhookEvent | None: type="issue_commented", actor=actor, repo=issue.repo, + source_installation_id=source_installation_id, issue=issue, comment=comment, raw=payload, @@ -128,14 +143,65 @@ async def parse_event(self, headers: dict, body: bytes) -> WebhookEvent | None: if not pr: return None if action == "opened": - return WebhookEvent(type="pr_opened", actor=actor, repo=pr.repo, pr=pr, raw=payload) + return WebhookEvent( + type="pr_opened", + actor=actor, + repo=pr.repo, + source_installation_id=source_installation_id, + pr=pr, + raw=payload, + ) if action == "closed": event_type = "pr_merged" if (payload.get("pull_request") or {}).get("merged") else "pr_closed" - return WebhookEvent(type=event_type, actor=actor, repo=pr.repo, pr=pr, raw=payload) + return WebhookEvent( + type=event_type, + actor=actor, + repo=pr.repo, + source_installation_id=source_installation_id, + pr=pr, + raw=payload, + ) return None return None + def get_action_trigger(self, event: WebhookEvent) -> ActionTrigger | None: + if event.type != "issue_commented" or not event.comment: + return None + + for raw_line in event.comment.body.splitlines(): + line = raw_line.strip() + if not line.startswith("/agent"): + continue + parts = line.split() + if len(parts) < 2 or parts[0] != "/agent": + continue + action = parts[1].lower() + if action not in AGENT_ACTIONS: + continue + raw_command = f"/agent {action}"[:ACTION_TRIGGER_COMMAND_MAX_CHARS] + return ActionTrigger(action=action, source="comment", raw_command=raw_command) + + return None + + async def can_trigger_action(self, event: WebhookEvent, trigger: ActionTrigger) -> bool: + if not event.actor: + return False + + token = await self._installation_token_for_repo(event.repo) + response = await self._request( + "GET", + f"/repos/{event.repo}/collaborators/{quote(event.actor, safe='')}/permission", + headers=self._github_headers(f"Bearer {token}"), + ) + if response.status_code == 404: + return False + if response.status_code >= 400: + raise SourceProviderAPIError(f"GitHub collaborator permission lookup failed ({response.status_code})") + + permission = response.json().get("permission") + return permission in ACTION_TRIGGER_ALLOWED_PERMISSIONS + async def get_issue(self, repo: str, issue_id: str) -> Issue: data = await self._request_json("GET", f"/repos/{repo}/issues/{issue_id}", repo=repo) return _issue_from_api(repo, data) diff --git a/providers/source/models.py b/providers/source/models.py index 7037f92..a225110 100644 --- a/providers/source/models.py +++ b/providers/source/models.py @@ -42,6 +42,15 @@ class PullRequest(BaseModel): issue_id: str | None = None +ActionName = Literal["spec", "plan", "implement", "review", "continue"] + + +class ActionTrigger(BaseModel): + action: ActionName + source: Literal["comment"] + raw_command: str + + WebhookEventType = Literal[ "issue_opened", "issue_labeled", @@ -57,6 +66,7 @@ class WebhookEvent(BaseModel): type: WebhookEventType actor: str repo: str + source_installation_id: str | None = None issue: Issue | None = None pr: PullRequest | None = None comment: Comment | None = None diff --git a/pyproject.toml b/pyproject.toml index 4cc905e..1f438c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ target-version = "py312" [tool.ruff.lint] select = ["E", "F", "I", "UP", "B"] -ignore = ["B008"] +ignore = ["B008", "UP017"] # Repo root contains packages `app/` and `providers/`; tests import `app.app`. [tool.pytest.ini_options] diff --git a/tests/app/test_webhook.py b/tests/app/test_webhook.py index 7e9ba8a..121587f 100644 --- a/tests/app/test_webhook.py +++ b/tests/app/test_webhook.py @@ -1,15 +1,16 @@ from __future__ import annotations -import copy import json +from datetime import datetime from pathlib import Path from types import SimpleNamespace import httpx import pytest -import app.app as orchestrator -from providers.source.models import WebhookEvent +import app.app as app_module +from app.agent_orchestrator import MAX_ISSUE_BODY_CHARS +from providers.source.models import ActionTrigger, Comment, Issue, WebhookEvent FIXTURES = Path(__file__).parents[1] / "providers" / "fixtures" @@ -18,6 +19,8 @@ class FakeSourceProvider: def __init__(self, *, verify: bool = True, event: WebhookEvent | None = None) -> None: self.verify = verify self.event = event + self.comments: list[tuple[str, str, str]] = [] + self.replaced_labels: list[tuple[str, str, list[str]]] = [] async def verify_webhook(self, headers: dict, body: bytes) -> bool: return self.verify @@ -25,6 +28,31 @@ async def verify_webhook(self, headers: dict, body: bytes) -> bool: async def parse_event(self, headers: dict, body: bytes) -> WebhookEvent | None: return self.event + def get_action_trigger(self, event: WebhookEvent) -> ActionTrigger | None: + if event.type != "issue_commented" or not event.comment: + return None + if event.comment.body.startswith("/agent implement"): + return ActionTrigger(action="implement", source="comment", raw_command="/agent implement") + if event.comment.body.startswith("/agent plan"): + return ActionTrigger(action="plan", source="comment", raw_command="/agent plan") + return None + + async def can_trigger_action(self, event: WebhookEvent, trigger: ActionTrigger) -> bool: + return True + + async def add_issue_comment(self, repo: str, issue_id: str, body: str): + self.comments.append((repo, issue_id, body)) + return Comment( + id="comment-response", + issue_id=issue_id, + body=body, + author="agent", + created_at=datetime.fromisoformat("2026-04-30T10:00:00+00:00"), + ) + + async def replace_issue_labels(self, repo: str, issue_id: str, labels: list[str]) -> None: + self.replaced_labels.append((repo, issue_id, labels)) + async def get_clone_credentials(self, repo: str) -> tuple[str, str]: return "x-access-token", "installation-token" @@ -49,46 +77,84 @@ def create_namespaced_secret(self, *, namespace: str, body) -> None: def patch_namespaced_secret(self, *, name: str, namespace: str, body) -> None: self.secret_patches.append((name, namespace, body)) + def patch_namespaced_secret_status(self, *args, **kwargs) -> None: + pass + def load_payload(name: str) -> dict: return json.loads((FIXTURES / name).read_text()) async def post_webhook(body: bytes, headers: dict): - transport = httpx.ASGITransport(app=orchestrator.app) + transport = httpx.ASGITransport(app=app_module.app) async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: return await client.post("/webhook/github", content=body, headers=headers) -def _plain_env_from_job(batch: FakeBatch) -> dict[str, str]: - _ns, job = batch.created_jobs[0] - container = job.spec.template.spec.containers[0] - return {e.name: e.value for e in container.env if getattr(e, "value", None) is not None} +def issue_from_payload(payload: dict, *, labels: list[str] | None = None, body=None) -> Issue: + raw_issue = payload["issue"] + return Issue( + id=str(raw_issue["id"]), + number=raw_issue["number"], + repo=payload["repository"]["full_name"], + title=raw_issue["title"], + body=raw_issue.get("body") if body is None else body, + labels=labels if labels is not None else [label["name"] for label in raw_issue["labels"]], + author=raw_issue["user"]["login"], + url=raw_issue["html_url"], + state=raw_issue["state"], + created_at=datetime.fromisoformat(raw_issue["created_at"].replace("Z", "+00:00")), + updated_at=datetime.fromisoformat(raw_issue["updated_at"].replace("Z", "+00:00")), + raw=raw_issue, + ) -async def _post_labeled_claude_webhook(monkeypatch, payload: dict): - """Simulate validated webhook: issues event, ai-pr-claude label, fake k8s.""" - event = WebhookEvent( - type="issue_labeled", +def comment(body: str) -> Comment: + return Comment( + id="2001", + issue_id="1001", + body=body, + author="bob", + created_at=datetime.fromisoformat("2026-04-30T10:00:00+00:00"), + ) + + +def issue_comment_event( + payload: dict, *, labels: list[str] | None = None, body: str = "/agent implement" +) -> WebhookEvent: + return WebhookEvent( + type="issue_commented", actor="bob", repo=payload["repository"]["full_name"], - label="ai-pr-claude", + source_installation_id=str(payload["installation"]["id"]), + issue=issue_from_payload(payload, labels=labels), + comment=comment(body), raw=payload, ) + + +async def post_event(monkeypatch, event: WebhookEvent, provider: FakeSourceProvider | None = None): + fake_provider = provider or FakeSourceProvider(event=event) batch = FakeBatch() core = FakeCore() - monkeypatch.setattr(orchestrator, "get_source_provider", lambda: FakeSourceProvider(event=event)) - monkeypatch.setattr(orchestrator, "load_k8s_client", lambda: (batch, core)) + monkeypatch.setattr(app_module, "get_source_provider", lambda: fake_provider) + monkeypatch.setattr(app_module, "load_k8s_client", lambda: (batch, core)) response = await post_webhook( - json.dumps(payload).encode("utf-8"), - {"X-GitHub-Event": "issues"}, + json.dumps(event.raw).encode("utf-8"), + {"X-GitHub-Event": "issue_comment"}, ) - return response, batch, core + return response, batch, core, fake_provider + + +def plain_env_from_job(batch: FakeBatch) -> dict[str, str]: + _ns, job = batch.created_jobs[0] + container = job.spec.template.spec.containers[0] + return {e.name: e.value for e in container.env if getattr(e, "value", None) is not None} @pytest.mark.asyncio async def test_github_webhook_rejects_invalid_signature(monkeypatch): - monkeypatch.setattr(orchestrator, "get_source_provider", lambda: FakeSourceProvider(verify=False)) + monkeypatch.setattr(app_module, "get_source_provider", lambda: FakeSourceProvider(verify=False)) response = await post_webhook(b"{}", {"X-GitHub-Event": "issues"}) @@ -96,19 +162,40 @@ async def test_github_webhook_rejects_invalid_signature(monkeypatch): @pytest.mark.asyncio -async def test_github_webhook_ignores_non_issue_event(monkeypatch): - monkeypatch.setattr(orchestrator, "get_source_provider", lambda: FakeSourceProvider()) +async def test_github_webhook_ignores_unparsed_source_event(monkeypatch): + monkeypatch.setattr(app_module, "get_source_provider", lambda: FakeSourceProvider(event=None)) response = await post_webhook(b"{}", {"X-GitHub-Event": "pull_request"}) assert response.status_code == 200 - assert response.json() == {"ok": True, "ignored": True, "reason": "event=pull_request"} + assert response.json() == {"ok": True, "ignored": True, "reason": "no source event"} @pytest.mark.asyncio -async def test_github_webhook_triggers_worker_job(monkeypatch): +async def test_issue_labeled_with_provider_sets_idle_but_does_not_trigger_job(monkeypatch): payload = load_payload("issue_labeled.json") - response, batch, core = await _post_labeled_claude_webhook(monkeypatch, payload) + event = WebhookEvent( + type="issue_labeled", + actor="bob", + repo=payload["repository"]["full_name"], + source_installation_id=str(payload["installation"]["id"]), + issue=issue_from_payload(payload), + label="ai-pr-claude", + raw=payload, + ) + + response, batch, _core, provider = await post_event(monkeypatch, event) + + assert response.status_code == 200 + assert response.json()["ignored"] is True + assert batch.created_jobs == [] + assert provider.replaced_labels == [("acme/widgets", "7", ["bug", "ai-pr-claude", "agent:status:idle"])] + + +@pytest.mark.asyncio +async def test_agent_implement_comment_triggers_worker_job(monkeypatch): + payload = load_payload("issue_labeled.json") + response, batch, core, provider = await post_event(monkeypatch, issue_comment_event(payload)) assert response.status_code == 200 data = response.json() @@ -116,50 +203,71 @@ async def test_github_webhook_triggers_worker_job(monkeypatch): assert data["created"] is True assert data["repo"] == "acme/widgets" assert data["issue_number"] == 7 + assert data["action"] == "implement" assert data["provider"] == "claude" assert len(core.secrets) == 1 assert core.secrets[0][1].string_data == {"GITHUB_TOKEN": "installation-token"} assert len(batch.created_jobs) == 1 - env_plain = _plain_env_from_job(batch) + assert provider.replaced_labels == [("acme/widgets", "7", ["bug", "ai-pr-claude", "agent:status:running"])] + + env_plain = plain_env_from_job(batch) assert env_plain["SOURCE_ISSUE_BODY"] == payload["issue"]["body"] + assert env_plain["SOURCE_ACTION"] == "implement" + assert env_plain["SOURCE_TRIGGER"] == "comment" + assert env_plain["SOURCE_TRIGGER_COMMAND"] == "/agent implement" @pytest.mark.asyncio -async def test_github_webhook_issue_body_truncated(monkeypatch): - payload = copy.deepcopy(load_payload("issue_labeled.json")) - cap = orchestrator._MAX_ISSUE_BODY_CHARS - payload["issue"]["body"] = "Z" * (cap + 500) +async def test_agent_implement_comment_without_provider_sets_awaiting_human(monkeypatch): + payload = load_payload("issue_labeled.json") + event = issue_comment_event(payload, labels=["bug"]) - response, batch, _core = await _post_labeled_claude_webhook(monkeypatch, payload) + response, batch, _core, provider = await post_event(monkeypatch, event) assert response.status_code == 200 - assert response.json()["triggered"] is True - env_plain = _plain_env_from_job(batch) - assert len(env_plain["SOURCE_ISSUE_BODY"]) == cap - assert env_plain["SOURCE_ISSUE_BODY"] == "Z" * cap + assert response.json()["ignored"] is True + assert batch.created_jobs == [] + assert provider.replaced_labels == [("acme/widgets", "7", ["bug", "agent:status:awaiting-human"])] + assert provider.comments[0][2] == "`/agent implement` needs a provider label such as `ai-pr-aider`." @pytest.mark.asyncio -async def test_github_webhook_issue_body_missing(monkeypatch): - payload = copy.deepcopy(load_payload("issue_labeled.json")) - del payload["issue"]["body"] +async def test_normal_issue_comment_does_not_trigger_job(monkeypatch): + payload = load_payload("issue_labeled.json") + event = issue_comment_event(payload, body="normal comment") - response, batch, _core = await _post_labeled_claude_webhook(monkeypatch, payload) + response, batch, _core, _provider = await post_event(monkeypatch, event) assert response.status_code == 200 - assert response.json()["triggered"] is True - env_plain = _plain_env_from_job(batch) - assert env_plain["SOURCE_ISSUE_BODY"] == "" + assert response.json()["ignored"] is True + assert batch.created_jobs == [] + + +@pytest.mark.asyncio +async def test_known_but_unimplemented_action_sets_awaiting_human(monkeypatch): + payload = load_payload("issue_labeled.json") + event = issue_comment_event(payload, body="/agent plan") + + response, batch, _core, provider = await post_event(monkeypatch, event) + + assert response.status_code == 200 + assert response.json()["reason"] == "action not implemented: plan" + assert batch.created_jobs == [] + assert provider.replaced_labels == [("acme/widgets", "7", ["bug", "ai-pr-claude", "agent:status:awaiting-human"])] + assert provider.comments[0][2] == "`/agent plan` is recognized, but `plan` is not implemented yet." @pytest.mark.asyncio -async def test_github_webhook_issue_body_non_string(monkeypatch): - payload = copy.deepcopy(load_payload("issue_labeled.json")) - payload["issue"]["body"] = ["unexpected", "list"] +async def test_github_webhook_issue_body_truncated(monkeypatch): + payload = load_payload("issue_labeled.json") + long_body = "Z" * (MAX_ISSUE_BODY_CHARS + 500) + event = issue_comment_event(payload) + event.issue.body = long_body - response, batch, _core = await _post_labeled_claude_webhook(monkeypatch, payload) + response, batch, _core, _provider = await post_event(monkeypatch, event) assert response.status_code == 200 assert response.json()["triggered"] is True - env_plain = _plain_env_from_job(batch) - assert env_plain["SOURCE_ISSUE_BODY"] == "" + env_plain = plain_env_from_job(batch) + assert len(env_plain["SOURCE_ISSUE_BODY"]) == MAX_ISSUE_BODY_CHARS + assert env_plain["SOURCE_ISSUE_BODY"] == "Z" * MAX_ISSUE_BODY_CHARS diff --git a/tests/providers/fixtures/issue_comment.json b/tests/providers/fixtures/issue_comment.json new file mode 100644 index 0000000..105b8cb --- /dev/null +++ b/tests/providers/fixtures/issue_comment.json @@ -0,0 +1,33 @@ +{ + "action": "created", + "issue": { + "id": 1001, + "number": 7, + "title": "Implement provider abstraction", + "body": "Move GitHub-specific code behind an interface.", + "labels": [ + {"name": "bug"}, + {"name": "ai-pr-claude"} + ], + "user": {"login": "alice"}, + "html_url": "https://github.com/acme/widgets/issues/7", + "state": "open", + "created_at": "2026-04-30T08:00:00Z", + "updated_at": "2026-04-30T09:00:00Z" + }, + "comment": { + "id": 2001, + "body": "/agent implement", + "user": {"login": "bob"}, + "created_at": "2026-04-30T10:00:00Z" + }, + "repository": { + "full_name": "acme/widgets" + }, + "installation": { + "id": 42 + }, + "sender": { + "login": "bob" + } +} diff --git a/tests/providers/test_github.py b/tests/providers/test_github.py index 06abf8d..5a40dcb 100644 --- a/tests/providers/test_github.py +++ b/tests/providers/test_github.py @@ -101,6 +101,103 @@ async def test_parse_event_issue_unlabeled(): assert event.issue.labels == ["bug"] +@pytest.mark.asyncio +async def test_parse_event_issue_commented(): + body = load_fixture("issue_comment.json") + event = await provider().parse_event({"X-GitHub-Event": "issue_comment"}, body) + + assert event is not None + assert event.type == "issue_commented" + assert event.actor == "bob" + assert event.repo == REPO + assert event.source_installation_id == "42" + assert event.issue is not None + assert event.issue.number == 7 + assert event.comment is not None + assert event.comment.body == "/agent implement" + + +@pytest.mark.asyncio +async def test_get_action_trigger_extracts_agent_implement(): + body = load_fixture("issue_comment.json") + gh = provider() + event = await gh.parse_event({"X-GitHub-Event": "issue_comment"}, body) + + trigger = gh.get_action_trigger(event) + + assert trigger is not None + assert trigger.action == "implement" + assert trigger.source == "comment" + assert trigger.raw_command == "/agent implement" + + +@pytest.mark.asyncio +async def test_get_action_trigger_ignores_normal_comment(): + body = load_fixture("issue_comment.json").replace(b"/agent implement", b"normal comment") + gh = provider() + event = await gh.parse_event({"X-GitHub-Event": "issue_comment"}, body) + + assert gh.get_action_trigger(event) is None + + +@pytest.mark.asyncio +async def test_get_action_trigger_ignores_unknown_action(): + body = load_fixture("issue_comment.json").replace(b"/agent implement", b"/agent foo") + gh = provider() + event = await gh.parse_event({"X-GitHub-Event": "issue_comment"}, body) + + assert gh.get_action_trigger(event) is None + + +@pytest.mark.asyncio +async def test_get_action_trigger_continues_after_invalid_agent_line(): + body = load_fixture("issue_comment.json").replace(b"/agent implement", b"/agent foo\\n/agent implement") + gh = provider() + event = await gh.parse_event({"X-GitHub-Event": "issue_comment"}, body) + + trigger = gh.get_action_trigger(event) + + assert trigger is not None + assert trigger.action == "implement" + assert trigger.raw_command == "/agent implement" + + +@pytest.mark.asyncio +@respx.mock +async def test_can_trigger_action_allows_write_permission(monkeypatch): + gh = provider() + gh._installation_ids_by_repo[REPO] = "42" + monkeypatch.setattr(gh, "_build_app_jwt", lambda: "jwt") + respx.post("https://api.github.com/app/installations/42/access_tokens").mock( + return_value=Response(201, json={"token": "installation-token"}) + ) + respx.get(f"https://api.github.com/repos/{REPO}/collaborators/bob/permission").mock( + return_value=Response(200, json={"permission": "write"}) + ) + event = await gh.parse_event({"X-GitHub-Event": "issue_comment"}, load_fixture("issue_comment.json")) + trigger = gh.get_action_trigger(event) + + assert await gh.can_trigger_action(event, trigger) is True + + +@pytest.mark.asyncio +@respx.mock +async def test_can_trigger_action_rejects_read_permission(monkeypatch): + gh = provider() + gh._installation_ids_by_repo[REPO] = "42" + monkeypatch.setattr(gh, "_build_app_jwt", lambda: "jwt") + respx.post("https://api.github.com/app/installations/42/access_tokens").mock( + return_value=Response(201, json={"token": "installation-token"}) + ) + respx.get(f"https://api.github.com/repos/{REPO}/collaborators/bob/permission").mock( + return_value=Response(200, json={"permission": "read"}) + ) + event = await gh.parse_event({"X-GitHub-Event": "issue_comment"}, load_fixture("issue_comment.json")) + trigger = gh.get_action_trigger(event) + + assert await gh.can_trigger_action(event, trigger) is False + + @pytest.mark.asyncio async def test_parse_event_pr_opened(): body = load_fixture("pr_opened.json")