Files
pragent/pilot/README-webhook.md
T
2026-09-01 02:54:23 +00:00

30 KiB
Raw Blame History

pragent pilot — central webhook service

The CI-step pilot (pilot/README.md) needs a workflow file + secret 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 .pr-review.json:enabled = true and runs the same review core.

Architecture

PR opened/pushed/edited/…  (any repo under a covered owner)
        │  Gitea user-level webhook (events: pull_request)
        ▼
Service pragent-webhook.pragent.svc.cluster.local  (ClusterIP, ns pragent)
        │  body-size cap → HMAC-verify (X-Gitea-Signature)
        │  → gate: action ≠ closed AND .pr-review.json:enabled = true on base
        │  → claim (repo, index, sha) in-flight (closes the dedupe race)
        │  → bounded worker (PRAGENT_MAX_CONCURRENT_REVIEWS, default 2)
        ▼
ai_review.review_pr()  (same core the CI-step uses)
   1. opt-in     .pr-review.json:enabled = true on base? if not, skip.
   2. fetch existing reviews → dedupe: skip if a review already carries
      <!-- pragent:sha=<this sha> -->  (no duplicate on title/body-edit re-fire)
   3. fetch PR diff     → GET .../pulls/{i}.diff
   4. fetch .pr-review.json @ base ref (the opt-in flag + repo-local focus/config)
   5. prior review bodies → fed as "already said" context (light §6.1)
   6. PRAGENT_ENGINE=opencode (default):
      a. fetch repo archive @ head sha → /tmp/pragent-work/<repo>-<sha>
         (symlink-escape + traversal rejected on untar)
      a2. sanitize the workdir: delete author-controlled agent-instruction
         files (AGENTS.md at any depth, CLAUDE.md, .cursorrules, a repo
         opencode.json/.opencode, .github/copilot-instructions.md)
      b. write .pragent/brief.md (title/body/diff/config/prior/sha/anchor-hint),
         author-controlled parts fenced in --- UNTRUSTED --- markers
      c. drop the factory (opencode.json + .opencode/) into the workdir
      d. opencode run --pure --agent pragent --dir <workdir> --model headroom/glm-5.2:cloud
         → the pragent agent reads the brief, inspects the repo, runs the
         repo's own linters via bash, loads review-methodology + findings-schema
         skills, delegates to security/tests/perf subagents only on big/risky
         diffs, and emits: {"summary":..., "findings":[{severity,path,line,
         problem,fix,suggestion,reference}]}
      (=ollama: legacy single POST to http://<model-proxy-host>:8789/v1/messages)
   7. parse diff hunks → valid (path, new_line) anchors (RIGHT side)
   8. post review       → POST .../pulls/{i}/reviews  (event: COMMENT) as pragent-bot
      - prose summary → review body intro
      - anchored findings → inline line comments, body wraps `suggestion` in a
        language-tagged fenced code block (Gitea syntax-highlights it; Gitea
        1.26.x has no apply-suggestion button); reference → 📎 ref link
      - unanchored findings → summary-body bullets
      - summary body carries the <!-- pragent:sha=... --> marker for dedupe

Fail-open. No duplicate per commit (dedupe). Inline comments + syntax-highlighted suggested-fix blocks 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. Commit .pr-review.json: {"enabled": true} to the repo's default branch (so every PR on the repo is auto-reviewed).
  3. Open a PR.

No workflow file, no repo secret, no act-runner, no label needed. (The owner must already be covered by a user-level webhook — see below. If not, do the one-time per-owner setup first.)

Token-usage reporting (always on)

Every opencode review now appends a token-usage report — no label, no env var needed:

  • a ## 🔋 AI usage section on the review summary body with the measured review total — input / output / reasoning / cache read+write / total tokens, agent step count, wall-clock duration, estimated cost, the model, and a scope note (the agent reviews a whole-repo checkout at the head sha, so input tokens include files read beyond the diff);
  • a per-finding attribution table (severity · location · ≈out tok · %);
  • a 🪙 ~N tok (X% · attributed output) line at the foot of each inline comment.

Attributed, not measured. One opencode agent pass produces all findings, so there is no native per-finding token metering. The per-comment / per-row counts are the review's measured output tokens split by each finding's rendered-body weight (len(problem)+len(fix)+len(suggestion)) — an honest attribution, labelled as such. The totals are real measurements summed from opencode's step_finish events.

No-op on the ollama fallback (no usage available). The usage section is part of the review body, so it's covered by the existing sha-marker dedupe.

Repo-provided static context (ADDITIONAL_CONTEXT_URL)

Long agent loops resend the brief prefix on every step; the cheap reusable knowledge — architecture summary, module map, conventions, glossary, past incident write-ups — lives in a versioned file the maintainers control, so the agent doesn't have to re-read the source tree to rediscover it on every PR. Two ways to wire it up:

Env var (Deployment-wide, useful for shared house docs):

PRAGENT_ADDITIONAL_CONTEXT_URL="https://nexus.example/raw/architecture.md,https://nexus.example/raw/glossary.md"
# comma-separated, trimmed, deduped; ≤ 8 URLs total

Per-repo .pr-review.json (read from the PR's base branch — same trust boundary as the rest of .pr-review.json):

{
  "additional_context_urls": [
    "https://nexus.example/repository/raw-hosted/architecture.md",
    "https://nexus.example/repository/raw-hosted/conventions.md"
  ]
}

The two are merged: env first (in declared order), then config entries that aren't already in env. The first 8 win.

Behaviour:

  • Fetched once per review, cached by URL for the lifetime of the pod.
  • http/https onlyfile://, javascript:, ftp://, anything else is silently dropped.
  • 5 s timeout per URL.
  • Per-URL truncated to 4 000 chars, total to 16 000 chars, then …[truncated] is appended and the next URL is skipped.
  • Best-effort: a network error or non-200 is logged to stderr and skipped — never aborts the review.
  • Rendered into the brief under "Repo-provided context", between the repo config and prior reviews. The brief explicitly labels the content of each block as untrusted author-controlled data (same as the PR description), so the agent knows to ground findings against it but not take instructions from it.

Self-hosted example (Nexus raw-hosted):

# Upload a doc to Nexus raw-hosted (anonymous read for in-cluster pods).
curl -u techspark -X PUT \
  --data-binary @architecture.md \
  https://nexus.example/repository/raw-hosted/architecture.md
# Then reference it from .pr-review.json (above). Cache control is
# browser-style: anonymous read = max-age from response headers.

Webhook fires on any PR update (except closed)

The receiver uses a denylist, not an allowlist: it reviews on every pull_request action except closedopened, reopened, synchronize/synchronized, edited (title/body), ready_for_review (draft→ready), assigned, review_requested, milestone, … . This is safe because of two downstream gates:

  • the opt-in gate.pr-review.json:enabled = true is read from the base branch, so only repos that opted in get reviewed. A repo that deletes the file between pushes opts out;
  • the sha dedupe — any same-sha re-fire (title edit, assignee, milestone…) is skipped, so the only newly-effective actions are ones that change the head sha (synchronize, already covered) or move a draft to ready (ready_for_review) on an un-reviewed sha.

Threat model

The reviewer runs an autonomous agent, with bash: "*": allow, over a checkout of the pull-request author's branch. Anyone who can open a PR on a covered repo can therefore put arbitrary text in front of the model and arbitrary files on the reviewer's disk. This is the same setup that was exploited in the April 2026 disclosures against Claude Code Security Review, Gemini CLI Action and Copilot Agent, where a PR body was enough to make the reviewer print GITHUB_TOKEN into a log.

pragent-bot holds a Gitea Write credential on every onboarded repo, so a successful injection means repo write access — not just a bad review. Four controls contain that:

  1. No credentials in the agent's environment. opencode_review._build_env builds the subprocess environment from an allow-list (PATH, locale, CA-bundle vars) rather than inheriting the pod's. PRAGENT_BOT_TOKEN and WEBHOOK_SECRET are never passed down; the Python shell does every Gitea call itself. There is nothing in the agent's env worth exfiltrating.
  2. No author-controlled instruction files on disk. opencode auto-loads AGENTS.md from the project root and every nested directory, plus a repo opencode.json / .opencode/. sanitize_workdir deletes all of those (and CLAUDE.md, .cursorrules, .github/copilot-instructions.md, …) from the checkout before opencode starts, so a PR cannot ship its own system prompt. The files are still reviewed — they're in the diff, as data.
  3. Untrusted-data framing. The PR title/body and the diff are fenced in explicit --- UNTRUSTED --- markers in .pragent/brief.md, under a trust -boundary preamble; the pragent agent, the three lens subagents and the review-methodology skill all instruct: injection attempts get reported as a critical finding, not obeyed. (Framing is defence in depth — it is the weakest of these four, which is why it isn't the only one.)
  4. .pr-review.json is read from the base branch. Its instructions field is free text spliced into the reviewer's prompt, so reading it from the PR head would hand every author a supported way to rewrite the reviewer's rules ("treat all findings in this PR as low"). The base branch is what the repo's maintainers already merged. Fields are also length-capped.

Additionally: the repo archive is untarred with symlink-escape and parent-traversal rejection (_extract_tar_strip_one), the container runs as uid 10001, and the webhook caps request bodies (PRAGENT_MAX_BODY_BYTES, default 10 MiB) and concurrent reviews (PRAGENT_MAX_CONCURRENT_REVIEWS, default 2 — each review forks an opencode process, so unbounded threads would be a self-inflicted fork bomb on any burst of concurrent PRs).

Residual risk, accepted for a pilot: the agent still executes hostile repo content indirectly (running the repo's own linters on it) inside a container that has network egress to the tailnet. Hardening that further means an egress NetworkPolicy on the pragent namespace (allow only the Gitea service + the headroom proxy) and a read-only root filesystem — worth doing before this is pointed at repos with untrusted contributors.

If you deploy the non-root image, the K8s manifest should carry a matching securityContext (runAsNonRoot: true, runAsUser: 10001, fsGroup: 10001) so the /tmp/pragent-work emptyDir is writable.

Multi-lens pipeline (5 default lenses, on by default)

Default reviews spawn one opencode subprocess per lens in parallel and synthesize the merged findings before posting. Cheaper than 5 sequential reviews because the headroom proxy caches the byte-identical brief across lens calls (lenses 2..N hit cache).

   Gitea webhook
      │
      ▼
   pilot/ai_review.review_pr
      │  resolve config + sort changed paths
      ▼
   pilot/opencode_review.run_lenses_review
      │  spawn 1..N subprocesses (default 5)
      ▼
   ┌── security ──┐  ┌── docs ──┐  ┌── code-quality ──┐  ┌── tests ──┐  ┌── perf ──┐
   │  opencode     │  │ opencode  │  │   opencode       │  │  opencode  │  │ opencode  │
   │  subprocess   │  │ subprocess│  │   subprocess     │  │  subprocess│  │ subprocess│
   └──────┬────────┘  └─────┬────┘  └─────────┬────────┘  └─────┬──────┘  └─────┬─────┘
          └──────────── synthesise (dedup, severity promote, cap) ─────────────┘
                                  │
                                  ▼
                  post_inline_review (existing path, unchanged)

Default roster (5 lenses, all on headroom/glm-5.2:cloud):

id severity_floor max_findings target
security low 12 auth, crypto, secrets, SQL, file I/O, supply chain
docs low 8 README, CHANGELOG, docstrings, code-fence breakage
code-quality low 8 dead code, hidden complexity, suppressed errors
tests low 8 coverage gaps for changed logic, missing assertions
perf medium 6 hot-path globs, O(n²) loops, N+1 queries

Set .pr-review.json: "reviewers": [] to opt out (single-primary fallback).

Per-lens config (drop-in):

{
  "reviewers": [
    { "id": "security", "severity_floor": "high", "max_findings": 10 },
    { "id": "docs",     "activation": "off" },
    { "id": "perf",     "skip_if_all_changed_paths": "docs/**" },
    { "id": "my-lens",  "agent_file": ".opencode/agents/my-lens.md", "model": "headroom/glm-5.2:cloud" }
  ],
  "triage": { "enabled": true, "max_lenses": 4 },
  "max_findings": 7
}

Triage (off by default, but enabled: true recommended) runs a tiny primary agent that picks a subset of lenses based on the diff's changed files. Fail-open: if triage errors, all lenses run.

Env vars:

var default effect
PRAGENT_MAX_PARALLEL_LENSES 4 cap concurrency
PRAGENT_LENS_TIMEOUT 540 per-lens subprocess timeout (s)
PRAGENT_REVIEWERS unset force multi-lens fan-out even without reviewers[]

Cross-lens dedup: synthesiser drops duplicates by sha256[:16](path|line|severity|problem[:80]) (matches the feedback DB's posthash), then promotes multi-lens agreement by one severity step (never past critical). A [multi-lens] tag is added so the summary section can flag it.

Adding a new lens: drop .opencode/agents/<id>.md (use an existing one as a template), then add one entry to reviewers[]. That's it — no Python change, no image rebuild.

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).

{
  "focus": ["security", "supply-chain", "sql-injection"],
  "exclude_paths": ["vendor/**", "**/*.generated.ts"],
  "languages": ["typescript", "go"],
  "instructions": "We use Result<T,E> 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's base branch (GET /repos/{o}/{r}/contents/.pr-review.json?ref=<base ref>; no ref → the repo's default branch). Deliberately not the PR head — see "Threat model" above: instructions goes straight into the reviewer's prompt, so it must come from what maintainers merged, not from the branch under review. A PR that adds .pr-review.json therefore only takes effect once merged.

Bad/missing file fails open to defaults. Fields are capped (32 list items × 200 chars; instructions 4000 chars). The bot's read:repository scope reads it.

Feedback loop (reactions → daily report)

The bot learns from how humans react to its reviews. The loop has three parts:

  1. Harvest (every PR webhook). pilot/feedback_harvest.py walks back over the PR's bot-authored reviews + inline comments + their reactions + their reply threads + their resolved/unresolved state, and writes everything into /data/feedback.db (SQLite, on the pragent-feedback-data PVC). It runs inside the webhook pod, before the new review is scheduled — piggy-backs on the webhook so there is no second cron just for harvesting. ~50 ms per PR.
  2. Analyze (pilot/feedback_analyze.py). Aggregates findings by posthash (a sha256 of path:line:severity:problem) and computes per-finding scores:
    • false-positive score = -1 reactions + unresolved status + negation- phrase replies ("false positive", "intentional", "not a bug"…) upvotes resolved.
    • accepted-pattern score = upvotes + resolved downvotes unresolved negation replies.
    • restraint = fraction of reviewed PRs the bot left a finding on. The DoorDash rule (2026-07-06, ZenML recap): excessive noise on clean code is its own failure mode. Above ~25% the report flags ⚠️. Renders markdown: top-N false-positive candidates, top-N accepted patterns, a case-review queue (every disagreement with full context), and a "where to action this" footer.
  3. Deliver (pilot/feedback_post.py). Posts the markdown as a comment on a single long-lived issue pragent feedback roll-up in gitea_admin/pragent. Comments are append-only history — one per run, timestamped.

The daily CronJob (k8s/pragent-feedback-cronjob.yaml, schedule 7 3 * * *) runs feedback_post.py. The webhook pod has PRAGENT_FEEDBACK_DB=/data/feedback.db; an empty / unset value disables harvesting (CI-step pod never gets the PVC).

Human reactions are not ground truth — authors accept/reject for workflow reasons as often as for technical ones (DoorDash lesson). Treat the top-N lists as a case-review queue, not a directive. Re-read the PR before adding anything to .pr-review.json:instructions or the cross-repo architecture.md.

Acting on the report

  • Per-repo: add a patterns.deny glob to .pr-review.json, raise the severity_threshold, or amend instructions — all read live at the next review.
  • Cross-repo: append accepted patterns to the shared PRAGENT_ADDITIONAL_CONTEXT_URL document on Nexus raw-hosted (e.g. canalhandia/architecture.md). The next review picks it up via the prompt-cached prefix → ~0 marginal cost on step 2+.
  • Benchmark gate (DoorDash pattern): before changing the model / prompt / context window, replay the labeled posthash corpus against a candidate change. If a candidate flips ≥ 1 currently-accepted finding into false-positive, drop it.

Manual ops

# ad-hoc report (no post)
python3 pilot/feedback_analyze.py --db /data/feedback.db --out /tmp/report.md

# ad-hoc report for a window
python3 pilot/feedback_analyze.py --db /data/feedback.db --since 1755000000

# force-run the cron now
kubectl -n pragent create job --from cronjob/pragent-feedback pragent-fb-now
kubectl -n pragent logs -l app=pragent-feedback --tail=30

# pause the cron
kubectl -n pragent patch cronjob pragent-feedback -p '{"spec":{"suspend":true}}'

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. alice):

# 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 alice \
  --scopes write:user,read:user --token-name pragent-userhook-alice

# 2. register the user-level webhook (events: pull_request)
#    WEBHOOK_SECRET = the shared HMAC secret in the pragent-webhook K8s Secret
python3 - "$OWNER_TOKEN" <<'PY'
import sys, json, urllib.request
tok = sys.argv[1]
GAPI = "http://<gitea-host>:3000/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 → <owner> → Access Tokens).

Owners are onboarded one at a time with the recipe above; true orgs need org-level webhooks (POST /orgs/{org}/hooks, requires a token with write:organization).

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(<cluster-ip>: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):

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.

The opencode review engine

The review "brain" runs on opencode (the AI coding-agent CLI), not a single cramped model call. pilot/review/opencode.py is the compatibility seam:

  1. opencode_workspace.fetch_archiveGET .../archive/{sha}.tar.gz, untar into a temp workdir (stripping the top dir) so the agent has the real files, not just the diff.
  2. opencode_workspace.write_brief — renders .pragent/brief.md (title, body, diff, repo .pr-review.json, prior reviews, sha, anchor hint).
  3. opencode_workspace.drop_factory — copies opencode.json + .opencode/ (agents/skills/commands) into the workdir as the project config.
  4. opencode.run_opencodeopencode run --pure --format json --agent pragent --dir <workdir> --model headroom/glm-5.2:cloud headlessly. --format json emits NDJSON events: parse_opencode_events reconstructs the assistant text from text events and sums tokens/cost/steps from every step_finish event. Returns (text, usage).

It does no Gitea I/O and no parsingreview_pr parses the stdout into (summary, findings), validates findings against diff anchors, and posts. So all v2 logic (dedupe marker, anchor validation, language-tagged suggestion fencing, posting, token-usage attribution) is reused and never depends on the model remembering it.

The factory lives in the pragent repo root: opencode.json (provider/model/ permission) + .opencode/ (agents, skills, commands). It is both the in-cluster deploy factory and the local interactive factory (run opencode in the repo, or /review via .opencode/commands/review.md). .opencode/README.md is the factory guide: pipeline diagram, how to add a subagent (drop a .md + one allow-list line), how to add a skill, how the engine flag works, how to switch the model. Lean by default: the pragent primary does summary + findings in one pass and runs the repo's own tsc/ruff/eslint/go vet via bash; security/tests/perf subagents are dormant lenses the primary delegates to only on large/security-sensitive diffs, so small PRs never fan out.

Engine flag + model ref

PRAGENT_ENGINE=opencode (default) selects it; =ollama keeps the legacy direct POST .../v1/messages path as a fallback. opencode wants a provider-prefixed model ref, so review_pr maps the bare OLLAMA_MODEL (glm-5.2:cloud) to headroom/glm-5.2:cloud (override with OPENCODE_MODEL). The headroom provider is defined in opencode.json with options.baseURL=http://<model-proxy-host>:8789/v1 (the headroom Anthropic proxy).

Local one-shot (no posting)

cd ~/Projects/pragent
python3 /tmp/pragent-e2e.py <owner>/<repo> <pr_number>   # driver script
# or, with opencode installed locally:
opencode run --pure --agent pragent --dir <checkout> --model headroom/glm-5.2:cloud \
  "$(python3 -c 'import sys;sys.path.insert(0,"pilot");import opencode_review as o;print(o._PROMPT)')"

Gotchas baked into the opencode review modules

  • stdin=DEVNULL — opencode blocks on stdin (permission prompt) when run headlessly via subprocess; closing stdin is required or it hangs to timeout.
  • Strip ANTHROPIC_* — the host shell exports ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN / ANTHROPIC_DEFAULT_*_MODEL (for Claude Code / headroom). Leaked into opencode, ANTHROPIC_DEFAULT_SONNET_MODEL=glm-5.2:cloud makes opencode look for provider glm-5.2:cloudProviderModelNotFoundError. The headroom provider's config is self-contained, so all ANTHROPIC_* are dropped from the subprocess env.
  • Shared warmed HOME — opencode bun-installs its @opencode-ai runtime into $HOME/.config/opencode/node_modules on first run (cold-start, ~30-60s, once per pod lifetime). A shared, marker-warmed HOME makes every review a warm run.

K8s deployment

Manifest: ~/k8s/pragent-webhook.yaml (Namespace pragent, Deployment pinned to kubernets, ClusterIP Service). The container image pragent-webhook:opencode (pilot/Dockerfile: python:3.12-slim + node 20 + opencode-ai@1.3.10 + pyright / typescript-language-server / eslint / ruff) is built locally and imported into microk8s containerd — it is not pulled from a registry (imagePullPolicy: Never). The webhook secret + bot token are a Secret (pragent-webhook). An emptyDir at /tmp/pragent-work holds the per-review checkout + the warmed opencode runtime. The PVC pragent-feedback-data (1 Gi, microk8s-hostpath, ReadWriteOnce) is mounted at /data and holds the SQLite file the feedback loop reads + writes — both the webhook pod and the daily CronJob pod share it. Verified: a regular pod on kubernets reaches both <model-proxy-host>:8789 (headroom/glm) and gitea-http.gitea.svc.cluster.local:3000.

The feedback CronJob lives in ~/k8s/pragent-feedback-cronjob.yaml — same image, same PVC, schedule 7 3 * * * (nudge off the round-hour). It runs feedback_post.py, which posts the daily report to the pragent feedback roll-up issue in gitea_admin/pragent.

Build + deploy after editing the pilot scripts or the factory:

K="microk8s kubectl"; cd ~/Projects/pragent
# 1. build the image (docker is in the microk8s group, no sudo)
docker build -t pragent-webhook:opencode -f pilot/Dockerfile .
# 2. import into microk8s containerd — sudoless. the `microk8s ctr` wrapper
#    sudo-wraps even in the microk8s group, so use the raw binary against the
#    group-readable containerd socket directly:
docker save pragent-webhook:opencode | \
  /snap/microk8s/current/bin/ctr --address /var/snap/microk8s/common/run/containerd.sock \
    --namespace k8s.io image import -
# 3. apply + roll
$K apply -f ~/k8s/pragent-webhook.yaml
$K -n pragent rollout restart deploy/pragent-webhook
$K -n pragent logs -f deploy/pragent-webhook

Env on the Deployment: PRAGENT_ENGINE, OPENCODE_MODEL, OPENCODE_EXPERIMENTAL_LSP_TOOL, PRAGENT_FACTORY_DIR, PRAGENT_OPENCODE_BIN, PRAGENT_WORK_ROOT, PRAGENT_REVIEW_TIMEOUT, GITEA_API, OLLAMA_URL, OLLAMA_MODEL, OLLAMA_MAX_TOKENS, DIFF_MAX_CHARS, PRAGENT_ADDITIONAL_CONTEXT_URL (optional, see "Repo-provided static context" above), PRAGENT_FEEDBACK_DB (defaults to /data/feedback.db on the webhook; empty / unset disables harvesting — the CI-step path doesn't get the PVC), PRAGENT_MAX_CONCURRENT_REVIEWS, PRAGENT_MAX_BODY_BYTES are literals; WEBHOOK_SECRET + PRAGENT_BOT_TOKEN come from the Secret. The image now runs as uid 10001 — add securityContext: {runAsNonRoot: true, runAsUser: 10001, fsGroup: 10001} to the pod spec so the /tmp/pragent-work emptyDir is writable.

GET /health reports ok inflight=<n> max_concurrent=<n>.

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).