diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..29a380f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.github +__pycache__ +*.pyc +tests +*.jsonl +*.token +.env +.pragent/cache/ +node_modules/ +dist/ +build/ \ No newline at end of file diff --git a/.opencode/README.md b/.opencode/README.md index cfa11c8..97f8cb8 100644 --- a/.opencode/README.md +++ b/.opencode/README.md @@ -38,7 +38,7 @@ flowchart TD DROP --> OC["opencode run --pure --agent pragent --dir
--model headroom/glm-5.2:cloud"] OC --> PR["pragent primary
load skills · run linters · read code · delegate lenses"] PR --> JSON["final message: summary + ```json findings```"] - JSON --> PARSE["ai_review.extract_findings
{summary, findings}"] + JSON --> PARSE["ai_review.parse_review_output
{summary, findings}"] PARSE --> ANCHOR["parse_diff_anchors → split_findings"] ANCHOR --> POST["post_inline_review
summary + inline ```suggestion + ref links + sha marker"] ``` @@ -48,7 +48,7 @@ flowchart TD The `pragent` primary does the whole review in one pass for small/medium diffs (no subagent calls). It delegates to `@security` / `@tests` / `@perf` subagents ONLY on large (>~400 lines) or security-sensitive diffs. Token cost scales with -PR size. `subagent_depth: 2` caps recursion. +PR size. Subagent recursion is capped by the primary's `steps` budget. `--pure` is passed at runtime so the reviewer doesn't load the host user's heavy global opencode plugins (supermemory/dcp/morph/pty) which hang cold-start. In the diff --git a/pilot/Dockerfile b/pilot/Dockerfile new file mode 100644 index 0000000..53a86d1 --- /dev/null +++ b/pilot/Dockerfile @@ -0,0 +1,51 @@ +# pragent pilot — combined webhook + opencode review-engine image. +# +# One container runs the Gitea webhook server (python) and subprocess-calls the +# opencode CLI headlessly to analyze each PR. Includes node + the LSPs / linters +# the pragent agent's bash tool can invoke on the checked-out repo. The factory +# (opencode.json + .opencode/ agents/skills) lives at /app and is discovered via +# PRAGENT_FACTORY_DIR=/app. +# +# Build: +# docker build -t pragent-webhook:opencode -f pilot/Dockerfile . +# Import into microk8s (needs sudo for containerd): +# docker save pragent-webhook:opencode | sudo microk8s ctr images import - +# (or: sudo microk8s ctr images import pragent-webhook-opencode.tar) +# +FROM python:3.12-slim + +# System deps: git (archive/repo reads by the agent), ripgrep (opencode dep), +# curl + ca-certs (archive fetch), xz-utils (node install). +RUN apt-get update && apt-get install -y --no-install-recommends \ + git ripgrep curl ca-certificates xz-utils \ + && rm -rf /var/lib/apt/lists/* + +# Node 20 — opencode runtime + the npm-installed LSP servers below. +RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && rm -rf /var/lib/apt/lists/* + +# opencode CLI, pinned to the verified version. opencode bun-installs its +# @opencode-ai runtime into $HOME/.config/opencode/node_modules on first run +# (cold-start ~30-60s, once per pod lifetime; the webhook returns 202 async so +# no Gitea delivery timeout is risked). HOME is an emptyDir at runtime. +RUN npm install -g opencode-ai@1.3.10 + +# LSPs + linters the pragent agent's bash tool can invoke on reviewed repos. +# (opencode's own LSP tool is opportunistic; the real signal is the agent +# running the repo's own tsc/ruff/eslint/go vet — these make that available.) +RUN npm install -g pyright typescript-language-server typescript eslint \ + && pip install --no-cache-dir ruff + +# pragent repo: factory (opencode.json + .opencode/) + pilot scripts. +WORKDIR /app +COPY . /app + +ENV PRAGENT_FACTORY_DIR=/app \ + PRAGENT_OPENCODE_BIN=/usr/local/bin/opencode \ + PRAGENT_ENGINE=opencode \ + OPENCODE_MODEL=headroom/glm-5.2:cloud \ + OPENCODE_EXPERIMENTAL_LSP_TOOL=true \ + PRAGENT_RTK_DIR="" + +CMD ["python3", "/app/pilot/webhook_server.py"] \ No newline at end of file diff --git a/pilot/README-webhook.md b/pilot/README-webhook.md index 619eded..76209db 100644 --- a/pilot/README-webhook.md +++ b/pilot/README-webhook.md @@ -22,12 +22,22 @@ ai_review.review_pr() (same core the CI-step uses) 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}]} + 5. PRAGENT_ENGINE=opencode (default): + a. fetch repo archive @ head sha → /tmp/pragent-work/- + b. write .pragent/brief.md (title/body/diff/config/prior/sha/anchor-hint) + c. drop the factory (opencode.json + .opencode/) into the workdir + d. opencode run --pure --agent pragent --dir --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://100.74.17.70:8789/v1/messages) 6. parse diff hunks → valid (path, new_line) anchors (RIGHT side) 7. 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 - ```suggestion fence (Gitea renders an apply-button) + ```suggestion fence (Gitea renders an apply-button); reference → 📎 ref link - unanchored findings → summary-body bullets - summary body carries the marker for dedupe ``` @@ -138,28 +148,101 @@ 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/opencode_review.py` is the glue: + +1. `fetch_archive` — `GET .../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. `write_brief` — renders `.pragent/brief.md` (title, body, diff, repo + `.pr-review.json`, prior reviews, sha, anchor hint). +3. `drop_factory` — copies `opencode.json` + `.opencode/` (agents/skills/commands) + into the workdir as the project config. +4. `run_opencode` — `opencode run --pure --agent pragent --dir + --model headroom/glm-5.2:cloud` headlessly; returns the agent's stdout. + +It does **no Gitea I/O and no parsing** — `review_pr` parses the stdout into +`(summary, findings)`, validates findings against diff anchors, and posts. So +all v2 logic (dedupe marker, anchor validation, ```suggestion fencing, posting) +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://100.74.17.70:8789/v1` (the headroom Anthropic proxy). + +### Local one-shot (no posting) + +```bash +cd ~/Projects/pragent +python3 /tmp/pragent-e2e.py / # driver script +# or, with opencode installed locally: +opencode run --pure --agent pragent --dir --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 `opencode_review.py` + +- **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:cloud` → `ProviderModelNotFoundError`. + 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 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 +`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. 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`: +Build + deploy after editing the pilot scripts or the factory: ```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 - +# 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 (needs sudo — one command) +docker save pragent-webhook:opencode | sudo microk8s ctr images 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: `GITEA_API`, `OLLAMA_URL`, `OLLAMA_MODEL`, `OLLAMA_MAX_TOKENS`, -`DIFF_MAX_CHARS` are literals; `WEBHOOK_SECRET` + `PRAGENT_BOT_TOKEN` come from the -Secret. +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` are literals; +`WEBHOOK_SECRET` + `PRAGENT_BOT_TOKEN` come from the Secret. ## Relationship to the CI-step pilot