From 2229e47f645a8316b3e112c091c49ae2b58b6fcc Mon Sep 17 00:00:00 2001 From: Akshay Jain Date: Thu, 30 Jul 2026 11:58:37 +0530 Subject: [PATCH] feat(webhook): forward CP API key, add label trigger, optional review caps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three improvements to the GitHub webhook handler, found running pr-af self-hosted with control-plane auth on a small shared host: 1. Forward AGENTFIELD_API_KEY as X-API-Key on the review-dispatch call. Without it, the webhook silently 401s whenever the control plane has auth enabled, so no webhook-triggered review ever runs. 2. Trigger a review when a configurable label (PR_AF_LABEL, default 'pr-af') is added to a PR — an alternative to the @mention comment. 3. Optional per-deployment review caps for webhook runs, applied only when set (PR_AF_MAX_CONCURRENT_REVIEWERS / PR_AF_MAX_REVIEW_DEPTH / PR_AF_MAX_COVERAGE_ITERATIONS / PR_AF_IGNORE_PATHS). Lets a small or shared host bound resource use without a code change; default behaviour is unchanged when unset. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 8 ++++++++ src/pr_af/app.py | 52 ++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index ea74b3a..52f757d 100644 --- a/.env.example +++ b/.env.example @@ -50,6 +50,14 @@ GITHUB_APP_ID= GITHUB_APP_PRIVATE_KEY= GITHUB_WEBHOOK_SECRET= PR_AF_BOT_MENTION=@pr-af +# Adding this label to a PR triggers a review via webhook (alt to @mention) +PR_AF_LABEL=pr-af +# Optional per-deployment caps for webhook-triggered reviews (unset = defaults). +# Useful to protect a small/shared host from resource spikes. +# PR_AF_MAX_CONCURRENT_REVIEWERS=1 +# PR_AF_MAX_REVIEW_DEPTH=0 +# PR_AF_MAX_COVERAGE_ITERATIONS=1 +# PR_AF_IGNORE_PATHS=**/*.min.js,**/dist/**,**/build/**,**/vendor/**,package-lock.json # --- Human-in-the-loop (optional) --- # Set HAX_API_KEY to enable HITL review gate diff --git a/src/pr_af/app.py b/src/pr_af/app.py index 66cfd6d..057e555 100644 --- a/src/pr_af/app.py +++ b/src/pr_af/app.py @@ -257,11 +257,35 @@ async def review( # --------------------------------------------------------------------------- -# GitHub Webhook — @mention-triggered PR review +# GitHub Webhook — @mention- or label-triggered PR review # --------------------------------------------------------------------------- _BOT_MENTION = os.getenv("PR_AF_BOT_MENTION", "@pr-af") +# Adding this label to a PR triggers a review (alternative to the @mention). +_LABEL_TRIGGER = os.getenv("PR_AF_LABEL", "pr-af") _WEBHOOK_SECRET = os.getenv("GITHUB_WEBHOOK_SECRET", "") _CP_URL = os.getenv("AGENTFIELD_SERVER", "http://localhost:8080") +# Control-plane API key, forwarded on the dispatch call when the control plane +# has auth enabled (otherwise the webhook's review dispatch 401s). +_CP_API_KEY = os.getenv("AGENTFIELD_API_KEY", "") + + +def _webhook_review_limits() -> dict[str, object]: + """Optional per-deployment review limits for webhook-triggered runs. + + Only applied when the corresponding env var is set, so default behaviour is + unchanged. Lets a small/shared host cap resource use (e.g. concurrency=1, + review_depth=0) without a code change. + """ + limits: dict[str, object] = {} + if (v := os.getenv("PR_AF_MAX_CONCURRENT_REVIEWERS")): + limits["max_concurrent_reviewers"] = int(v) + if (v := os.getenv("PR_AF_MAX_REVIEW_DEPTH")): + limits["max_review_depth"] = int(v) + if (v := os.getenv("PR_AF_MAX_COVERAGE_ITERATIONS")): + limits["max_coverage_iterations"] = int(v) + if (v := os.getenv("PR_AF_IGNORE_PATHS")): + limits["ignore_paths"] = [p.strip() for p in v.split(",") if p.strip()] + return limits def _verify_signature(payload: bytes, signature: str, secret: str) -> bool: @@ -281,16 +305,20 @@ async def _fire_review( "pr_url": pr_url, "depth": "standard", "dry_run": False, + **_webhook_review_limits(), } if hints: input_payload["hints"] = hints body = json.dumps({"input": input_payload}) + headers = {"Content-Type": "application/json"} + if _CP_API_KEY: + headers["X-API-Key"] = _CP_API_KEY try: async with httpx.AsyncClient(timeout=15.0) as client: resp = await client.post( f"{_CP_URL}/api/v1/execute/async/pr-af.review", content=body, - headers={"Content-Type": "application/json"}, + headers=headers, ) resp.raise_for_status() return resp.json().get("execution_id") @@ -340,6 +368,26 @@ async def webhook_github(request: Request) -> dict[str, object]: if event == "ping": return {"status": "pong"} + # Label trigger: adding the configured label to a PR fires a review. + if event == "pull_request": + payload = json.loads(body) + if payload.get("action") != "labeled": + return {"status": "ignored", "reason": f"pr action={payload.get('action')}"} + if (payload.get("label") or {}).get("name", "") != _LABEL_TRIGGER: + return {"status": "ignored", "reason": "label not a trigger"} + pr_url = (payload.get("pull_request") or {}).get("html_url") + if not pr_url: + return {"status": "ignored", "reason": "no pr_url"} + repo_name = payload.get("repository", {}).get("full_name", "") + number = (payload.get("pull_request") or {}).get("number") + print( + f"[PR-AF] Webhook: '{_LABEL_TRIGGER}' label on {repo_name}#{number}" + " — firing review", + flush=True, + ) + exec_id = await _fire_review(pr_url) + return {"status": "review_dispatched", "pr_url": pr_url, "execution_id": exec_id} + if event != "issue_comment": return {"status": "ignored", "reason": f"event={event}"}