diff --git a/README.md b/README.md index f8922d3..617927b 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,19 @@ An extensible, forge-agnostic PR review framework. Not a product — a toolkit that teams extend with their own review dimensions. -**Status:** design approved; framework build deferred. A **pilot** is live — a -minimal AI review bot running as a Gitea Actions step on `glm-5.2:cloud`. See -[`pilot/README.md`](pilot/README.md) to onboard a repo. The framework design -remains at [`docs/plans/2026-08-04-pragent-design.md`](docs/plans/2026-08-04-pragent-design.md); +**Status:** design approved; framework build deferred. A **pilot** is live on +`glm-5.2:cloud` with two delivery paths: + +- **Central webhook service** (preferred, least per-repo setup): a Gitea + user-level webhook posts PR events to an always-on in-cluster service that + gates on the `AI-REVIEW` label. Onboarding a repo = add `pragent-bot` + collaborator + create the label + label a PR. See + [`pilot/README-webhook.md`](pilot/README-webhook.md). +- **CI-step** (legacy): a per-repo Gitea Action fetches the reviewer script at + runtime. See [`pilot/README.md`](pilot/README.md). + +The framework design remains at +[`docs/plans/2026-08-04-pragent-design.md`](docs/plans/2026-08-04-pragent-design.md); the pilot is its bootstrap and will be superseded by `pragent review` when the framework build resumes. diff --git a/docs/plans/2026-08-04-pragent-design.md b/docs/plans/2026-08-04-pragent-design.md index f56758e..f4853b0 100644 --- a/docs/plans/2026-08-04-pragent-design.md +++ b/docs/plans/2026-08-04-pragent-design.md @@ -318,3 +318,26 @@ The pilot validates the delivery model (CI-step + bot user, not a central webhook) and the on-network provider path. When the framework build resumes, `pilot/ai_review.py` is replaced by `pragent review`; the per-repo workflow stays, just calling the CLI instead of curling the script. See `pilot/README.md`. + +### Central webhook service (2026-08-17) — partially reverses the non-goal + +User decision 2026-08-17: ship a central webhook service so "add the bot + +label a PR" is the only per-repo action (no workflow file, no secret, no runner). +This walks back the "central webhook service = non-goal" line above, for the +pilot only — the framework's CLI-step delivery model is unchanged. + +Implementation notes (see `pilot/README-webhook.md`): + +- **Gitea 1.26.1 system webhooks are broken**: `POST /admin/hooks` returns `201` + but the hook never persists (`GET /admin/hooks` lists 0, no delivery). The + one-webhook-per-instance ideal is not achievable on this version. Fallback: + **user-level webhooks** — one webhook per repo-owner, fires for every repo + that user owns. Few owners on this instance, so near-equivalent. +- **Gitea SSRF allow-list** blocks webhook delivery to in-cluster hosts by + default; required a scoped `[webhook] ALLOWED_HOST_LIST` addition to + `app.ini` (via the helm inline-config secret) + a gitea pod restart. +- The webhook receiver (`pilot/webhook_server.py`, stdlib only) HMAC-verifies + the delivery, gates on `AI-REVIEW` label + PR action, and calls the same + `ai_review.review_pr()` core the CI-step uses — one review path, two triggers. +- The bot stays a **normal user** (not site admin); it must be a Write + collaborator on each reviewed repo. diff --git a/pilot/README-webhook.md b/pilot/README-webhook.md new file mode 100644 index 0000000..0acea40 --- /dev/null +++ b/pilot/README-webhook.md @@ -0,0 +1,153 @@ +# pragent pilot — central webhook service + +The CI-step pilot (`pilot/README.md`) needs a workflow file + secret + label per +repo. The **central webhook service** removes the workflow file, the secret, and +the runner dependency: a Gitea webhook posts PR events to an always-on in-cluster +service, which gates on the `AI-REVIEW` label and runs the same review core. + +## Architecture + +``` +PR opened/pushed/labeled "AI-REVIEW" (any repo under a covered owner) + │ Gitea user-level webhook (events: pull_request) + ▼ +Service pragent-webhook.pragent.svc.cluster.local (ClusterIP, ns pragent) + │ HMAC-verify (X-Gitea-Signature) → gate: action in {opened, + │ reopened, synchronize/synchronized, labeled/label_updated} + │ AND pull_request.labels ∋ AI-REVIEW + ▼ +ai_review.review_pr() (same core the CI-step uses) + 1. fetch PR diff → GET gitea-http.gitea.svc:3000/api/v1/repos/{o}/{r}/pulls/{i}.diff + 2. review prompt → POST http://100.74.17.70:8789/v1/messages (glm-5.2:cloud) + 3. post review → POST .../pulls/{i}/reviews (event: COMMENT) as pragent-bot +``` + +Fail-open, comment-only, re-posts on every qualifying trigger (no prior-comment +synthesis yet — framework §6.1). Reviews are tagged with the head SHA. + +## What "onboarding a repo" means now + +1. Add `pragent-bot` as collaborator with **Write** (so it can read the diff and + post the review). The bot stays a normal user — it is **not** a site admin. +2. Create the `AI-REVIEW` label on the repo (one-time; `pragent-bot`'s + `write:issue` scope can do it once it's a collaborator). +3. Label a PR `AI-REVIEW`. + +No workflow file, no repo secret, no act-runner needed. (The owner must already +be covered by a user-level webhook — see below. If not, do the one-time +per-owner setup first.) + +## One-time per-owner setup: register a user-level webhook + +Gitea **system webhooks** (one webhook for the whole instance — the ideal) are +**broken in Gitea 1.26.1**: `POST /admin/hooks` returns `201` but the hook never +persists (`GET /admin/hooks` lists 0, no delivery). So we use **user-level +webhooks** instead — one webhook per repo-owner, which fires for every repo that +user owns. For a small instance with few owners this is nearly as good. + +To onboard a new owner (e.g. `masi`): + +```bash +# 1. generate a one-time token for that user (admin CLI, inside the gitea pod) +K="microk8s kubectl" +GPOD=$($K -n gitea get pod -l app=gitea --field-selector=status.phase=Running \ + -o jsonpath='{.items[?(@.status.containerStatuses[0].ready==true)].metadata.name}') +$K -n gitea exec "$GPOD" -c gitea -- \ + gitea admin user generate-access-token --username masi \ + --scopes write:user,read:user --token-name pragent-userhook-masi + +# 2. register the user-level webhook (events: pull_request) +# WEBHOOK_SECRET = the shared HMAC secret in the pragent-webhook K8s Secret +python3 - "$MASI_TOKEN" <<'PY' +import sys, json, urllib.request +tok = sys.argv[1] +GAPI = "http://100.74.17.70:30000/api/v1" +WS = open("/dev/stdin") and __import__("os").environ["WEBHOOK_SECRET"] # or paste +req = urllib.request.Request( + f"{GAPI}/user/hooks", + data=json.dumps({"type":"gitea", + "config":{"url":"http://pragent-webhook.pragent.svc.cluster.local/webhook", + "content_type":"json","secret":WS}, + "events":["pull_request"],"active":True}).encode(), + method="POST", headers={"Authorization":f"token {tok}","Content-Type":"application/json"}) +print(urllib.request.urlopen(req).status, urllib.request.urlopen(req).read()[:80]) +PY +# 3. revoke the one-time token (Gitea admin UI → Users → masi → Access Tokens). +``` + +Owners already covered: `gitea_admin` (webhook id=4), `masi` (webhook id=5). + +## Gitea SSRF allow-list (required, one-time) + +Gitea refuses to POST webhooks to in-cluster addresses by default: + +``` +webhook can only call allowed HTTP servers (check your webhook.ALLOWED_HOST_LIST setting), +deny 'pragent-webhook.pragent.svc.cluster.local(10.152.183.170:80)' +``` + +Fix: add a scoped `[webhook]` section to Gitea's `app.ini` via the helm chart's +inline-config secret (`gitea-inline-config`, key = section name `webhook`): + +```ini +ALLOWED_HOST_LIST = external,loopback,*.svc.cluster.local,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,100.64.0.0/10 +``` + +Then restart the gitea pod. Scoped to in-cluster + tailnet ranges only — not a +blanket "allow all private". A future `helm upgrade` may overwrite the inline +secret; bake it into `gitea.config.webhook.ALLOWED_HOST_LIST` in the helm values +for permanence. + +## K8s deployment + +Manifest: `~/k8s/pragent-webhook.yaml` (Namespace `pragent`, Deployment pinned to +`kubernets`, ClusterIP Service). The two scripts are a ConfigMap +(`pragent-scripts`) and the webhook secret + bot token are a Secret +(`pragent-webhook`). Verified: a regular pod on kubernets reaches both +`100.74.17.70:8789` (headroom/glm) and `gitea-http.gitea.svc.cluster.local:3000`. + +Update the scripts after editing `pilot/ai_review.py` or `pilot/webhook_server.py`: + +```bash +K="microk8s kubectl"; cd ~/Projects/pragent +$K -n pragent create configmap pragent-scripts \ + --from-file=webhook_server.py=pilot/webhook_server.py \ + --from-file=ai_review.py=pilot/ai_review.py \ + --dry-run=client -o yaml | $K apply -f - +$K -n pragent rollout restart deploy/pragent-webhook +``` + +Env on the Deployment: `GITEA_API`, `OLLAMA_URL`, `OLLAMA_MODEL`, `OLLAMA_MAX_TOKENS`, +`DIFF_MAX_CHARS` are literals; `WEBHOOK_SECRET` + `PRAGENT_BOT_TOKEN` come from the +Secret. + +## Relationship to the CI-step pilot + +Both paths coexist. The webhook service is strictly less per-repo setup. For +repos still on the CI-step workflow (`.gitea/workflows/ai-review.yml`), retire +that file once the owner has a user-level webhook — otherwise a labeled PR gets +reviewed twice. `gitea_admin/pragent`'s own self-CI workflow was retired when the +webhook service went live. + +## Known limitations (pilot, same as CI-step) + +- Re-posts on every qualifying trigger; no prior-comment synthesis (framework §6.1). +- No inline line comments, no status checks, no fail-close. +- `glm-5.2:cloud` only; no tiering, no analyzer fan-out. +- Gitea 1.26.1: system webhooks broken (see above) → user-level webhooks instead; + hook delivery-history API (`.../hooks/{id}/tasks`) returns 404, so delivery is + observed via the pragent-webhook pod logs (`kubectl -n pragent logs -f deploy/pragent-webhook`). + +## Cleanup of one-time setup tokens (manual) + +Token revocation via API/CLI is broken in Gitea 1.26.1 (`GET /users/{u}/tokens` +401, no CLI `delete-access-token`). Revoke these one-time setup tokens in the +Gitea admin UI (Site Administration → Users → *user* → Manage access tokens), +name prefix `pragent-`: +- `gitea_admin`: `pragent-syswh-reg-2026`, `pragent-syswh-list-2026`, + `pragent-syswh-test-2026`, `pragent-syswh-retry-2026`, + `pragent-userhook-test-2026`, `pragent-payload-look-2026`, + `pragent-cleanup-2026`, `pragent-cleanup2-2026`, `pragent-cleanup3-2026`. +- `masi`: `pragent-userhook-masi-2026`. + +(Keep `pragent-bot`'s `pragent-ci` token — that's the live reviewer credential.) \ No newline at end of file diff --git a/pilot/ai_review.py b/pilot/ai_review.py index 90fe8fa..7784693 100644 --- a/pilot/ai_review.py +++ b/pilot/ai_review.py @@ -187,33 +187,57 @@ def _need(name: str) -> str: return v -def run() -> int: - api = _need("GITEA_API") - repo = _need("GITEA_REPOSITORY") - index = _need("PR_INDEX") - token = _need("PRAGENT_BOT_TOKEN") - ollama_url = _need("OLLAMA_URL") - model = _need("OLLAMA_MODEL") - title = os.environ.get("PR_TITLE", "") - body = os.environ.get("PR_BODY", "") - sha = os.environ.get("PRAGENT_SHA", "") - max_tokens = int(os.environ.get("OLLAMA_MAX_TOKENS", "6000")) - max_chars = int(os.environ.get("DIFF_MAX_CHARS", "150000")) +def review_pr( + api: str, + repo: str, + index: str, + title: str, + body: str, + sha: str, + token: str, + ollama_url: str, + model: str, + max_tokens: int = 6000, + max_chars: int = 150000, +) -> bool: + """Run one review and post it as a PR comment. + Returns True on success, False on failure (failure note is posted when + possible). Never raises — fail-open by design. Both the CI `run()` entry + point and the central webhook server call this. + """ try: - diff, truncated, _orig = fetch_pr_diff(api, repo, index, token, max_chars) + diff, _truncated, _orig = fetch_pr_diff(api, repo, index, token, max_chars) if not diff.strip(): post_review(api, repo, index, token, format_review_body("No diff content to review.", model, sha)) - return 0 + return True user_prompt = build_user_prompt(title, body, diff) findings = call_model(ollama_url, model, SYSTEM_PROMPT, user_prompt, max_tokens) post_review(api, repo, index, token, format_review_body(findings, model, sha)) + return True except Exception as e: # fail-open try: post_review(api, repo, index, token, format_review_body(f"⚠️ AI review failed: {e}", model, sha)) except Exception as e2: print(f"pragent: could not post failure note: {e2}", file=sys.stderr) print(f"pragent: review failed: {e}", file=sys.stderr) + return False + + +def run() -> int: + review_pr( + api=_need("GITEA_API"), + repo=_need("GITEA_REPOSITORY"), + index=_need("PR_INDEX"), + title=os.environ.get("PR_TITLE", ""), + body=os.environ.get("PR_BODY", ""), + sha=os.environ.get("PRAGENT_SHA", ""), + token=_need("PRAGENT_BOT_TOKEN"), + ollama_url=_need("OLLAMA_URL"), + model=_need("OLLAMA_MODEL"), + max_tokens=int(os.environ.get("OLLAMA_MAX_TOKENS", "6000")), + max_chars=int(os.environ.get("DIFF_MAX_CHARS", "150000")), + ) return 0 diff --git a/pilot/webhook_server.py b/pilot/webhook_server.py new file mode 100644 index 0000000..e622af4 --- /dev/null +++ b/pilot/webhook_server.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +"""pragent pilot — central webhook receiver. + +A stdlib-only HTTP server that Gitea posts system-webhook events to. It gates on +the `AI-REVIEW` PR label, then runs the same review core (`ai_review.review_pr`) +the CI-step pilot uses, posting findings back as `pragent-bot`. + +Zero per-repo setup: one Gitea **system webhook** fires for every repo on the +instance; this service filters to labeled PRs. Onboarding a repo = label a PR. + +Stdlib only — no pip install, runs on python:3-slim with the scripts mounted. + +Endpoints: + POST /webhook Gitea webhook delivery (HMAC-verified) + GET /health liveness probe + +Env: + WEBHOOK_SECRET shared secret used to register the Gitea webhook (HMAC) + GITEA_API in-cluster Gitea base URL + PRAGENT_BOT_TOKEN pragent-bot access token (admin so it can read any repo) + OLLAMA_URL headroom proxy URL, e.g. http://100.74.17.70:8789 + OLLAMA_MODEL model id, e.g. glm-5.2:cloud + OLLAMA_MAX_TOKENS (optional) output cap, default 6000 + DIFF_MAX_CHARS (optional) diff truncation cap, default 150000 + WEBHOOK_PORT (optional) listen port, default 8080 +""" + +import hashlib +import hmac +import json +import os +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +from ai_review import review_pr + +# Pull-request webhook `action` values worth reviewing on. Gitea emits +# GitHub-style payload `action` names (`labeled`, `synchronize`) even though the +# `X-Gitea-Event-Type` header uses `label_updated` / `synchronized` — accept both +# so the gate is robust to either. The label gate below means a non-AI-REVIEW +# label update is a no-op. +REVIEW_ACTIONS = {"opened", "reopened", "synchronize", "synchronized", "labeled", "label_updated"} +AI_REVIEW_LABEL = "AI-REVIEW" + +GITEA_API = os.environ.get("GITEA_API", "http://gitea-http.gitea.svc.cluster.local:3000") +BOT_TOKEN = os.environ.get("PRAGENT_BOT_TOKEN", "") +OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://100.74.17.70:8789") +OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "glm-5.2:cloud") +OLLAMA_MAX_TOKENS = int(os.environ.get("OLLAMA_MAX_TOKENS", "6000")) +DIFF_MAX_CHARS = int(os.environ.get("DIFF_MAX_CHARS", "150000")) +WEBHOOK_SECRET = os.environ.get("WEBHOOK_SECRET", "").encode() +PORT = int(os.environ.get("WEBHOOK_PORT", "8080")) + + +def _labels_have_ai_review(labels) -> bool: + if not isinstance(labels, list): + return False + for lab in labels: + if isinstance(lab, dict) and lab.get("name") == AI_REVIEW_LABEL: + return True + if isinstance(lab, str) and lab == AI_REVIEW_LABEL: + return True + return False + + +def _verify_signature(raw_body: bytes, headers) -> bool: + if not WEBHOOK_SECRET: + return False # refuse to run without a configured secret + sig_header = headers.get("X-Gitea-Signature") or headers.get("X-Forgejo-Signature") + if not sig_header: + return False + mac = hmac.new(WEBHOOK_SECRET, raw_body, hashlib.sha256).hexdigest() + return hmac.compare_digest(mac, sig_header) + + +def _handle_pull_request(payload: dict) -> tuple[int, str]: + """Decide whether to review; if so, kick it off in a background thread. + + Returns (status, message) to Gitea immediately — the review itself runs + async so Gitea's delivery timeout never fires and causes a retry. + """ + action = payload.get("action", "") + pr = payload.get("pull_request") or {} + repo_obj = payload.get("repository") or {} + repo = repo_obj.get("full_name") or "" + + if action not in REVIEW_ACTIONS: + return 200, f"ignore action={action}" + if not repo: + return 400, "no repository.full_name" + + if not _labels_have_ai_review(pr.get("labels")): + return 200, f"ignore (no {AI_REVIEW_LABEL} label) action={action}" + + index = pr.get("number") + if index is None: + return 400, "no pull_request.number" + title = pr.get("title", "") or "" + body = pr.get("body", "") or "" + head = pr.get("head") or {} + sha = head.get("sha", "") or "" + + if not BOT_TOKEN: + return 500, "PRAGENT_BOT_TOKEN not set" + + threading.Thread( + target=_run_review, + args=(repo, str(index), title, body, sha), + daemon=True, + ).start() + return 202, f"reviewing {repo}#{index} action={action} sha={sha[:8]}" + + +def _run_review(repo: str, index: str, title: str, body: str, sha: str) -> None: + try: + ok = review_pr( + api=GITEA_API, + repo=repo, + index=index, + title=title, + body=body, + sha=sha, + token=BOT_TOKEN, + ollama_url=OLLAMA_URL, + model=OLLAMA_MODEL, + max_tokens=OLLAMA_MAX_TOKENS, + max_chars=DIFF_MAX_CHARS, + ) + print(f"pragent-webhook: reviewed {repo}#{index} sha={sha[:8]} ok={ok}", flush=True) + except Exception as e: # review_pr is fail-open, but guard the thread anyway + print(f"pragent-webhook: thread crashed for {repo}#{index}: {e}", flush=True) + + +class Handler(BaseHTTPRequestHandler): + def _send(self, status: int, body: str) -> None: + data = body.encode() + self.send_response(status) + self.send_header("Content-Type", "text/plain") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def do_GET(self): + if self.path == "/health": + self._send(200, "ok") + else: + self._send(404, "not found") + + def do_POST(self): + if self.path != "/webhook": + self._send(404, "not found") + return + length = int(self.headers.get("Content-Length", "0") or "0") + raw = self.rfile.read(length) if length else b"" + + if not _verify_signature(raw, self.headers): + self._send(401, "invalid signature") + return + + try: + payload = json.loads(raw) + except json.JSONDecodeError: + self._send(400, "invalid json") + return + + event = self.headers.get("X-Gitea-Event") or payload.get("action") or "" + if event != "pull_request": + self._send(200, f"ignore event={event}") + return + + pr0 = payload.get("pull_request") or {} + print( + f"pragent-webhook: pull_request action={payload.get('action')} " + f"repo={(payload.get('repository') or {}).get('full_name')} " + f"ai_review={_labels_have_ai_review(pr0.get('labels'))}", + flush=True, + ) + status, msg = _handle_pull_request(payload) + self._send(status, msg) + + def log_message(self, fmt, *args): + # Keep k8s logs to our own lines (see _run_review / _send paths). + print(f"pragent-webhook: {self.address_string()} {fmt % args}", flush=True) + + +def main() -> int: + if not WEBHOOK_SECRET: + print("pragent-webhook: FATAL: WEBHOOK_SECRET not set", flush=True) + return 1 + if not BOT_TOKEN: + print("pragent-webhook: FATAL: PRAGENT_BOT_TOKEN not set", flush=True) + return 1 + server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler) + print(f"pragent-webhook: listening on :{PORT} (model={OLLAMA_MODEL})", flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file