# 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 existing reviews → dedupe: skip if a review already carries (no duplicate on label-toggle / re-fire) 2. fetch PR diff → GET .../pulls/{i}.diff 3. fetch .pr-review.json @ head ref (optional repo-local focus/config) 4. prior review bodies → fed as "already said" context (light §6.1) 5. review prompt → POST http://100.74.17.70:8789/v1/messages (glm-5.2:cloud) model emits JSON: {findings:[{severity,path,line,problem,fix,suggestion}]} 6. parse diff hunks → valid (path, new_line) anchors (RIGHT side) 7. post review → POST .../pulls/{i}/reviews (event: COMMENT) as pragent-bot - anchored findings → inline line comments, body wraps `suggestion` in a ```suggestion fence (Gitea renders an apply-button) - unanchored findings → summary-body bullets - summary body carries the marker for dedupe ``` Fail-open. No duplicate per commit (dedupe). Inline comments + apply-able suggestions where the line anchors cleanly. Repo-local focus via `.pr-review.json`. Prior reviews fed as context so re-pushes synthesize instead of repeating (light version of framework §6.1). ## 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.) ## Repo-local focus: `.pr-review.json` (optional) Drop a `.pr-review.json` at the repo root (committed on the PR's branch, or on the default branch) to steer the review for that repo. All fields optional; absent file = defaults. JSON (stdlib, no YAML dependency). ```json { "focus": ["security", "supply-chain", "sql-injection"], "exclude_paths": ["vendor/**", "**/*.generated.ts"], "languages": ["typescript", "go"], "instructions": "We use Result for error handling. Flag any bare throw. Flag eval()/exec() on user input as critical." } ``` - `focus` — weight these review areas higher (does not blind the reviewer to critical issues outside them). - `exclude_paths` — tell the model to ignore these paths. - `languages` — hint the primary languages. - `instructions` — free-form house conventions / compliance language. Fetched at review time from the PR head ref (`GET /repos/{o}/{r}/contents/.pr-review.json?ref=`). Bad/missing file fails open to defaults. The bot's `read:repository` scope reads it. ## 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) - One model (`glm-5.2:cloud`); no tiering, no analyzer fan-out, no shared-prefix caching. Those are framework features. - No status checks, no fail-close (review never blocks a PR). - Dedupe is per-commit: a re-push (new SHA) always re-reviews (by design — the diff changed). Prior-review context is fed to the model so it doesn't repeat, but the bot does not delete or resolve its own old reviews. - Inline comments only anchor to post-change lines present in the diff (context + added). A finding whose `line` the model places on a removed line or outside the diff is folded into the summary as a bullet instead of misplaced. - 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.)