pilot: dedupe + repo config + inline comments w/ suggestions

- Dedupe: Gitea-as-state. Scan existing reviews for a hidden
  <!-- pragent:sha=... --> marker matching the head sha; skip if present
  (kills duplicate reviews on label-toggle / re-fire). Prior review bodies
  fed back as 'already said' context (light framework §6.1).
- Repo-local focus: optional .pr-review.json at repo root
  ({focus,exclude_paths,languages,instructions}), fetched at head ref.
- Inline comments + apply-able suggestions: model emits JSON findings
  {severity,path,line,problem,fix,suggestion}; diff hunks parsed into valid
  (path,new_line) RIGHT-side anchors; anchored findings become positional
  review comments with a ```suggestion fence (Gitea apply-button);
  unanchored findings fold into the summary body.
- Tests: parse_diff_anchors, parse_findings (tolerant JSON), split_findings,
  inline_comment_body, summary_bullets, parse_repo_config, reviewed_shas,
  prior_review_bodies, sha-marker. 35 pass.
- Bump OLLAMA_MAX_TOKENS default 6000 -> 8000 (suggestions add length).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Marcos
2026-08-17 19:59:36 +00:00
parent 789fb38bae
commit 90cea84f6f
4 changed files with 734 additions and 45 deletions
+52 -9
View File
@@ -17,13 +17,25 @@ Service pragent-webhook.pragent.svc.cluster.local (ClusterIP, ns pragent)
│ AND pull_request.labels ∋ AI-REVIEW │ AND pull_request.labels ∋ AI-REVIEW
ai_review.review_pr() (same core the CI-step uses) 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 1. fetch existing reviews → dedupe: skip if a review already carries
2. review prompt → POST http://100.74.17.70:8789/v1/messages (glm-5.2:cloud) <!-- pragent:sha=<this sha> --> (no duplicate on label-toggle / re-fire)
3. post reviewPOST .../pulls/{i}/reviews (event: COMMENT) as pragent-bot 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 <!-- pragent:sha=... --> marker for dedupe
``` ```
Fail-open, comment-only, re-posts on every qualifying trigger (no prior-comment Fail-open. No duplicate per commit (dedupe). Inline comments + apply-able
synthesis yet — framework §6.1). Reviews are tagged with the head SHA. 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 ## What "onboarding a repo" means now
@@ -37,6 +49,31 @@ 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 be covered by a user-level webhook — see below. If not, do the one-time
per-owner setup first.) 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<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 head ref
(`GET /repos/{o}/{r}/contents/.pr-review.json?ref=<head sha>`). 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 ## One-time per-owner setup: register a user-level webhook
Gitea **system webhooks** (one webhook for the whole instance — the ideal) are Gitea **system webhooks** (one webhook for the whole instance — the ideal) are
@@ -129,11 +166,17 @@ that file once the owner has a user-level webhook — otherwise a labeled PR get
reviewed twice. `gitea_admin/pragent`'s own self-CI workflow was retired when the reviewed twice. `gitea_admin/pragent`'s own self-CI workflow was retired when the
webhook service went live. webhook service went live.
## Known limitations (pilot, same as CI-step) ## Known limitations (pilot)
- Re-posts on every qualifying trigger; no prior-comment synthesis (framework §6.1). - One model (`glm-5.2:cloud`); no tiering, no analyzer fan-out, no shared-prefix
- No inline line comments, no status checks, no fail-close. caching. Those are framework features.
- `glm-5.2:cloud` only; no tiering, no analyzer fan-out. - 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; - 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 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`). observed via the pragent-webhook pod logs (`kubectl -n pragent logs -f deploy/pragent-webhook`).
+463 -30
View File
@@ -1,51 +1,102 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""pragent pilot — minimal AI PR reviewer. """pragent pilot — minimal AI PR reviewer.
Runs as a Gitea Actions step. Fetches a PR diff, asks glm-5.2:cloud (via the Runs as a Gitea Actions step OR is called by the central webhook server
on-network headroom proxy, Anthropic /v1/messages format) to review it, and (`webhook_server.py`). Fetches a PR diff, asks glm-5.2:cloud (via the on-network
posts the findings back as a PR review authored by pragent-bot. headroom proxy, Anthropic /v1/messages format) to review it, and posts the
findings back as `pragent-bot` — as a **review summary** plus **inline line
comments** with apply-able ```suggestion blocks where the model could produce
them and the line anchors cleanly to the post-change file.
Features (pilot v2):
- **Dedupe / persistence:** Gitea itself is the source of truth. Before
reviewing, fetch the PR's existing reviews and look for a hidden
`<!-- pragent:sha=... -->` marker matching this commit. If present, skip
(no duplicate review on label-toggle / re-fire). Prior review bodies are
fed back to the model as "already said" context so a re-push synthesizes
instead of repeating (light version of design §6.1).
- **Repo-local focus:** if the repo has a `.pr-review.json` at the PR's head
ref, its `focus` / `exclude_paths` / `instructions` / `languages` steer the
review. Optional — defaults apply when absent.
- **Inline comments + suggestions:** the model emits structured JSON
findings with `path`/`line`. We parse the diff hunks to learn which
`(path, new_line)` pairs are valid post-change anchors and post each
anchored finding as a positional review comment; the `suggestion` field, if
non-empty, is wrapped in a ```suggestion fence so Gitea renders an
apply-button. Findings that don't anchor (bad line, unchanged file, etc.)
are folded into the summary body as plain bullets.
Fail-open by design: any error becomes a short "review failed" review comment, Fail-open by design: any error becomes a short "review failed" review comment,
and the process always exits 0 so it can never block CI. and review_pr never raises. Stdlib only — no pip install.
Stdlib only — no pip install, fast cold start in CI. Env (CI run() path):
GITEA_API base URL of the in-cluster Gitea
Env:
GITEA_API base URL of the in-cluster Gitea, e.g. http://gitea-http.gitea.svc.cluster.local:3000
GITEA_REPOSITORY "owner/repo" of the PR (github.repository) GITEA_REPOSITORY "owner/repo" of the PR (github.repository)
PR_INDEX PR number (github.event.pull_request.number) PR_INDEX PR number (github.event.pull_request.number)
PR_TITLE PR title PR_TITLE PR title
PR_BODY PR body (optional) PR_BODY PR body (optional)
PRAGENT_BOT_TOKEN bot access token (repo secret) PRAGENT_BOT_TOKEN bot access token (repo secret)
PRAGENT_SHA head SHA to tag the review (github.event.pull_request.head.sha) PRAGENT_SHA head SHA to tag the review
OLLAMA_URL headroom proxy URL, e.g. http://100.74.17.70:8789 OLLAMA_URL headroom proxy URL, e.g. http://100.74.17.70:8789
OLLAMA_MODEL model id, e.g. glm-5.2:cloud OLLAMA_MODEL model id, e.g. glm-5.2:cloud
OLLAMA_MAX_TOKENS (optional) output cap, default 6000 OLLAMA_MAX_TOKENS (optional) output cap, default 8000
DIFF_MAX_CHARS (optional) diff truncation cap, default 150000 DIFF_MAX_CHARS (optional) diff truncation cap, default 150000
""" """
import base64
import json import json
import os import os
import re
import sys import sys
import urllib.error import urllib.error
import urllib.request import urllib.request
REVIEW_HEADER = "🤖 **AI Review** · pragent pilot · {model} · `{sha}`" REVIEW_HEADER = "🤖 **AI Review** · pragent pilot · {model} · `{sha}`"
# Hidden marker the dedupe pass scans for. Full sha so a re-push (new sha) is
# never mistaken for an already-reviewed commit, and a label-toggle (same sha)
# is correctly skipped.
SHA_MARKER = "<!-- pragent:sha={sha} -->"
_SHA_MARKER_RE = re.compile(r"<!-- pragent:sha=([0-9a-f]{7,40}) -->")
AI_REVIEW_LABEL = "AI-REVIEW"
SEVERITIES = ("critical", "high", "medium", "low")
REPO_CONFIG_FILE = ".pr-review.json"
SYSTEM_PROMPT = """You are a senior, pragmatic code reviewer. Review the pull request diff below. SYSTEM_PROMPT = """You are a senior, pragmatic code reviewer. Review the pull request diff below.
Report ONLY real, actionable issues: correctness bugs, security problems, risky Report ONLY real, actionable issues: correctness bugs, security problems, risky
changes, missing tests for changed behaviour, and breaking API/contract changes. changes, missing tests for changed behaviour, and breaking API/contract changes.
Honour any repo-specific focus / instructions given in the prompt; if focus is
given, weight those areas higher, but do not ignore a critical issue outside them.
For each issue, output exactly one line in this format: Output STRICT JSON only — no prose, no markdown fences. Shape:
- [SEVERITY] path:line — concise problem. suggested fix. {
where SEVERITY is one of: critical, high, medium, low. "findings": [
{
"severity": "critical|high|medium|low",
"path": "file path exactly as it appears in the diff (`+++ b/` side)",
"line": <int, the NEW-file line number the issue is on, within the diff>,
"problem": "one line: what is wrong",
"fix": "one line: how to fix it",
"suggestion": "<exact replacement lines for that location, or empty string if you cannot produce safe replacement code>"
}
]
}
Rules: Rules:
- Skip nitpicks, pure formatting, and praise. - `line` MUST be a line number that exists in the post-change version of `path`
- If the diff is clean, output exactly: No issues found. (i.e. a context line or an added `+` line shown in the diff). Never a removed
- Be concise. At most ~15 findings, highest severity first. line. If you are unsure of the exact line, set `line` to the closest context
- Do not restate the diff. Do not include a summary header. Just the findings lines.""" line you CAN see in the diff.
- `suggestion` is the literal new code that should replace the flagged line(s).
Keep it minimal — just the changed lines, indented as they would appear in the
file. Leave it empty ("") if a safe textual replacement is not possible (e.g.
a missing test, an architectural note).
- Skip nitpicks, pure formatting, and praise. At most ~15 findings, highest
severity first.
- If the diff is clean, output: {"findings": []}
- Do NOT repeat anything already covered in "PREVIOUS REVIEWS" — only surface
new or still-unresolved issues."""
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Pure helpers (unit-tested, no network) # Pure helpers (unit-tested, no network)
@@ -80,16 +131,53 @@ def parse_text_blocks(content: list) -> str:
def format_review_body(findings: str, model: str, sha: str) -> str: def format_review_body(findings: str, model: str, sha: str) -> str:
"""Format the posted review body. Findings empty -> "No issues found.".""" """Format the posted review summary body.
`findings` is the bullet text for findings that could NOT be anchored inline
(or, on the legacy/no-inline path, the whole review). Empty -> "No issues
found.". The hidden sha marker is always appended for the dedupe pass.
"""
header = REVIEW_HEADER.format(model=model, sha=sha[:8] if sha else "unknown") header = REVIEW_HEADER.format(model=model, sha=sha[:8] if sha else "unknown")
findings = (findings or "").strip() findings = (findings or "").strip()
if not findings: if not findings:
findings = "No issues found." findings = "No issues found."
return f"{header}\n\n{findings}" marker = SHA_MARKER.format(sha=sha) if sha else ""
body = f"{header}\n\n{findings}"
if marker:
body += f"\n{marker}"
return body
def build_user_prompt(title: str, body: str, diff: str) -> str: def build_user_prompt(
parts = [f"## PR\nTitle: {title or '(none)'}"] title: str,
body: str,
diff: str,
config: dict | None = None,
prior_reviews: list[str] | None = None,
) -> str:
"""Assemble the user prompt: repo config + prior reviews + PR meta + diff."""
parts: list[str] = []
if config:
cfg_lines = []
if config.get("focus"):
cfg_lines.append("Focus areas: " + ", ".join(config["focus"]))
if config.get("exclude_paths"):
cfg_lines.append("Ignore paths: " + ", ".join(config["exclude_paths"]))
if config.get("languages"):
cfg_lines.append("Languages: " + ", ".join(config["languages"]))
if config.get("instructions"):
cfg_lines.append("Instructions:\n" + str(config["instructions"]).strip())
if cfg_lines:
parts.append("## Repo review config (.pr-review.json)\n" + "\n".join(cfg_lines))
if prior_reviews:
joined = "\n\n---\n\n".join(prior_reviews)
if len(joined) > 8000:
joined = joined[:8000] + "\n…[prior reviews truncated]"
parts.append("## PREVIOUS REVIEWS (already posted — do NOT repeat these points)\n" + joined)
parts.append(f"## PR\nTitle: {title or '(none)'}")
if body and body.strip(): if body and body.strip():
b = body.strip() b = body.strip()
if len(b) > 4000: if len(b) > 4000:
@@ -99,6 +187,256 @@ def build_user_prompt(title: str, body: str, diff: str) -> str:
return "\n\n".join(parts) return "\n\n".join(parts)
# ---------------------------------------------------------------------------
# Diff parsing — find valid post-change (RIGHT-side) line anchors per file
# ---------------------------------------------------------------------------
def parse_diff_anchors(diff: str) -> dict[str, set[int]]:
"""Parse a unified diff into {path: {new_line, ...}} for lines that exist in
the post-change version (context + added lines). Removed lines are NOT
anchors (they have no RIGHT-side line). Used to validate inline comments.
Robust to:
- `diff --git a/x b/x` and `+++ b/x` path headers (uses the `b/` side)
- hunk headers `@@ -a,b +c,d @@` (new line counter starts at c)
- No-newline-at-eof markers, binary files, missing hunks.
"""
anchors: dict[str, set[int]] = {}
current_path: str | None = None
new_line = 0
for raw in (diff or "").splitlines():
# File path: prefer the `+++ b/` line (handles renames); fall back to
# `diff --git a/x b/x`'s second path.
if raw.startswith("+++ "):
p = raw[4:].strip()
if p == "/dev/null":
current_path = None
else:
current_path = _strip_path_prefix(p)
anchors.setdefault(current_path, set())
continue
if raw.startswith("diff --git "):
# `diff --git a/foo b/foo` — take the second path as a fallback in
# case the `+++` line is missing (binary). Split on " b/".
m = re.search(r" b/(.+)$", raw)
if m:
current_path = m.group(1).strip()
anchors.setdefault(current_path, set())
continue
if raw.startswith("@@"):
m = re.search(r"\+(\d+)(?:,\d+)?\s@@", raw)
new_line = int(m.group(1)) if m else 0
continue
if current_path is None:
continue
if raw.startswith("\\ No newline"):
continue
if raw.startswith("-"):
# removed line — no RIGHT-side anchor
continue
if raw.startswith("+"):
anchors[current_path].add(new_line)
new_line += 1
continue
# context line (" " or anything else within a hunk)
if raw.startswith(" "):
anchors[current_path].add(new_line)
new_line += 1
return anchors
def _strip_path_prefix(p: str) -> str:
"""`b/foo` or `foo` -> `foo`."""
if p.startswith("b/"):
return p[2:]
return p
# ---------------------------------------------------------------------------
# Model output parsing — tolerant JSON findings extraction
# ---------------------------------------------------------------------------
def parse_findings(text: str) -> list[dict]:
"""Parse the model's JSON response into a list of finding dicts.
Tolerant: strips ```json fences, and if the model wrapped JSON in prose,
scans for the first balanced `{...}` and extracts its `findings` array.
Drops findings missing path/line or with an unknown severity (normalised).
Never raises — returns [] on any parse failure.
"""
if not text:
return []
s = text.strip()
# Strip a single wrapping code fence if present.
if s.startswith("```"):
s = re.sub(r"^```[a-zA-Z]*\n?", "", s)
s = re.sub(r"\n?```$", "", s).strip()
data = None
try:
data = json.loads(s)
except json.JSONDecodeError:
obj = _extract_first_json_object(s)
if obj is not None:
try:
data = json.loads(obj)
except json.JSONDecodeError:
data = None
if not isinstance(data, dict):
return []
findings = data.get("findings")
if not isinstance(findings, list):
return []
out = []
for f in findings:
if not isinstance(f, dict):
continue
path = f.get("path")
line = f.get("line")
if not isinstance(path, str) or not path.strip():
continue
if not isinstance(line, int) or line < 1:
continue
sev = str(f.get("severity", "medium")).strip().lower()
if sev not in SEVERITIES:
sev = "medium"
out.append({
"severity": sev,
"path": path.strip(),
"line": line,
"problem": str(f.get("problem", "")).strip(),
"fix": str(f.get("fix", "")).strip(),
"suggestion": str(f.get("suggestion", "") or "").strip(),
})
return out
def _extract_first_json_object(s: str) -> str | None:
"""Return the substring of the first balanced top-level `{ ... }` in s."""
start = s.find("{")
if start < 0:
return None
depth = 0
in_str = False
esc = False
for i in range(start, len(s)):
c = s[i]
if in_str:
if esc:
esc = False
elif c == "\\":
esc = True
elif c == '"':
in_str = False
continue
if c == '"':
in_str = True
elif c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
return s[start:i + 1]
return None
def split_findings(findings: list[dict], anchors: dict[str, set[int]]) -> tuple[list[dict], list[dict]]:
"""Split findings into (anchored, unanchored).
A finding is anchored if its path is known AND its line is a valid post-change
line for that path. Lines just outside the diff (model off-by-one) are NOT
anchored — safer to keep them as summary bullets than to drop or misplace.
"""
anchored, unanchored = [], []
for f in findings:
valid = anchors.get(f["path"])
if valid and f["line"] in valid:
anchored.append(f)
else:
unanchored.append(f)
return anchored, unanchored
def inline_comment_body(f: dict) -> str:
"""Render one finding as a positional review-comment body.
Includes a ```suggestion fence only if the model produced non-empty
replacement code. Gitea renders that as an apply-able suggestion.
"""
sev = f["severity"].upper()
body = f"**[{sev}]** {f['problem']}"
if f["fix"]:
body += f"\n\nFix: {f['fix']}"
if f["suggestion"]:
body += f"\n\n```suggestion\n{f['suggestion']}\n```"
return body
def summary_bullets(findings: list[dict]) -> str:
"""Render unanchored findings as summary-body bullets (no line anchor)."""
lines = []
for f in findings:
loc = f"{f['path']}:{f['line']}" if f["line"] else f["path"]
fix = f" — fix: {f['fix']}" if f["fix"] else ""
lines.append(f"- **[{f['severity'].upper()}]** `{loc}` — {f['problem']}{fix}")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Repo config + existing-review helpers
# ---------------------------------------------------------------------------
def parse_repo_config(raw: str) -> dict:
"""Parse a .pr-review.json blob tolerantly. Returns {} on any failure."""
if not raw:
return {}
try:
data = json.loads(raw)
except json.JSONDecodeError:
return {}
if not isinstance(data, dict):
return {}
out = {}
for k in ("focus", "exclude_paths", "languages"):
v = data.get(k)
if isinstance(v, list) and all(isinstance(x, str) for x in v):
out[k] = v
instr = data.get("instructions")
if isinstance(instr, str) and instr.strip():
out["instructions"] = instr.strip()
return out
def reviewed_shas(reviews: list[dict]) -> set[str]:
"""Pull every `<!-- pragent:sha=... -->` marker out of a PR's reviews."""
shas: set[str] = set()
for r in reviews or []:
body = r.get("body") or ""
for m in _SHA_MARKER_RE.finditer(body):
shas.add(m.group(1))
return shas
def prior_review_bodies(reviews: list[dict], current_sha: str, limit: int = 6) -> list[str]:
"""Bodies of prior bot reviews (older shas), newest-first, bounded."""
out = []
for r in reviews or []:
body = (r.get("body") or "").strip()
if not body:
continue
shas = _SHA_MARKER_RE.findall(body)
# Skip the current sha (that would be a self-reference) and non-bot
# noise; keep reviews that carry our marker.
if not shas:
continue
if current_sha and current_sha in shas:
continue
out.append(body)
return out[:limit]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Network helpers # Network helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -147,6 +485,35 @@ def fetch_pr_diff(api: str, repo: str, index: str, token: str, max_chars: int) -
return truncate_diff("\n".join(joined), max_chars) return truncate_diff("\n".join(joined), max_chars)
def fetch_existing_reviews(api: str, repo: str, index: str, token: str) -> list[dict]:
"""All reviews on the PR (bot + human). Empty list on failure (fail-open)."""
status, raw = gitea_get(api, repo, f"pulls/{index}/reviews", token)
if status != 200:
return []
try:
data = json.loads(raw)
except json.JSONDecodeError:
return []
return data if isinstance(data, list) else []
def fetch_repo_config(api: str, repo: str, sha: str, token: str) -> dict:
"""Fetch .pr-review.json from the PR's head ref. {} if absent/unreadable."""
if not sha:
return {}
status, raw = gitea_get(api, repo, f"contents/{REPO_CONFIG_FILE}?ref={sha}", token)
if status != 200:
return {}
try:
data = json.loads(raw)
content_b64 = data.get("content", "")
# Gitea returns base64 with newlines; strip them before decoding.
decoded = base64.b64decode(content_b64.replace("\n", "")).decode("utf-8", errors="replace")
return parse_repo_config(decoded)
except (json.JSONDecodeError, ValueError):
return {}
def call_model(ollama_url: str, model: str, system: str, user: str, max_tokens: int) -> str: def call_model(ollama_url: str, model: str, system: str, user: str, max_tokens: int) -> str:
payload = { payload = {
"model": model, "model": model,
@@ -167,6 +534,7 @@ def call_model(ollama_url: str, model: str, system: str, user: str, max_tokens:
def post_review(api: str, repo: str, index: str, token: str, body: str) -> None: def post_review(api: str, repo: str, index: str, token: str, body: str) -> None:
"""Post a body-only review (summary / failure note). No inline comments."""
status, raw = gitea_post(api, repo, f"pulls/{index}/reviews", token, {"event": "COMMENT", "body": body}) status, raw = gitea_post(api, repo, f"pulls/{index}/reviews", token, {"event": "COMMENT", "body": body})
if status not in (200, 201): if status not in (200, 201):
# Fallback to a plain issue comment if reviews endpoint refuses. # Fallback to a plain issue comment if reviews endpoint refuses.
@@ -175,6 +543,33 @@ def post_review(api: str, repo: str, index: str, token: str, body: str) -> None:
raise RuntimeError(f"post review failed: reviews={status}, comments={status2}") raise RuntimeError(f"post review failed: reviews={status}, comments={status2}")
def post_inline_review(
api: str, repo: str, index: str, token: str, summary: str, anchored: list[dict]
) -> None:
"""Post a review with a summary body AND positional inline comments.
Each anchored finding becomes one entry in `comments`:
{path, side:"RIGHT", line, body}. The body carries the ```suggestion
fence when the model produced replacement code.
"""
comments = [
{
"path": f["path"],
"side": "RIGHT",
"line": f["line"],
"body": inline_comment_body(f),
}
for f in anchored
]
payload = {"event": "COMMENT", "body": summary, "comments": comments}
status, raw = gitea_post(api, repo, f"pulls/{index}/reviews", token, payload)
if status in (200, 201):
return
# If the inline post failed (e.g. a bad line slipped through), retry as a
# body-only review so the findings still land somewhere.
post_review(api, repo, index, token, summary)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Main # Main
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -197,23 +592,61 @@ def review_pr(
token: str, token: str,
ollama_url: str, ollama_url: str,
model: str, model: str,
max_tokens: int = 6000, max_tokens: int = 8000,
max_chars: int = 150000, max_chars: int = 150000,
) -> bool: ) -> bool:
"""Run one review and post it as a PR comment. """Run one review and post it as `pragent-bot`.
Returns True on success, False on failure (failure note is posted when Dedupe: if a prior review already carries this commit's sha marker, skip
possible). Never raises — fail-open by design. Both the CI `run()` entry (no duplicate). Otherwise: fetch repo config + prior-review context, call
point and the central webhook server call this. the model, parse JSON findings, anchor what we can to diff lines, post a
review with inline comments + suggestions (unanchored findings → summary
bullets).
Returns True on success (including a deliberate skip), False on failure
(failure note posted when possible). Never raises — fail-open by design.
Both the CI `run()` entry point and the central webhook server call this.
""" """
try: try:
reviews = fetch_existing_reviews(api, repo, index, token)
# Dedupe: already reviewed this exact commit -> nothing to do.
if sha and sha in reviewed_shas(reviews):
print(f"pragent: {repo}#{index} sha={sha[:8]} already reviewed, skipping", flush=True)
return True
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(): if not diff.strip():
post_review(api, repo, index, token, format_review_body("No diff content to review.", model, sha)) post_review(api, repo, index, token, format_review_body("No diff content to review.", model, sha))
return True return True
user_prompt = build_user_prompt(title, body, diff)
findings = call_model(ollama_url, model, SYSTEM_PROMPT, user_prompt, max_tokens) config = fetch_repo_config(api, repo, sha, token)
post_review(api, repo, index, token, format_review_body(findings, model, sha)) prior = prior_review_bodies(reviews, sha)
user_prompt = build_user_prompt(title, body, diff, config, prior)
raw_findings = call_model(ollama_url, model, SYSTEM_PROMPT, user_prompt, max_tokens)
findings = parse_findings(raw_findings)
anchors = parse_diff_anchors(diff)
anchored, unanchored = split_findings(findings, anchors)
# Summary body: the unanchored bullets (or "No issues found."), plus a
# one-line note when inline comments were posted so the summary isn't
# empty-looking.
bullets = summary_bullets(unanchored)
summary_parts = []
if anchored:
summary_parts.append(f"_{len(anchored)} inline comment(s) posted below._")
if bullets:
summary_parts.append(bullets)
if not summary_parts:
summary_parts.append("No issues found.")
summary_body = format_review_body("\n\n".join(summary_parts), model, sha)
post_inline_review(api, repo, index, token, summary_body, anchored)
print(
f"pragent: reviewed {repo}#{index} sha={sha[:8]} "
f"findings={len(findings)} inline={len(anchored)}",
flush=True,
)
return True return True
except Exception as e: # fail-open except Exception as e: # fail-open
try: try:
@@ -235,7 +668,7 @@ def run() -> int:
token=_need("PRAGENT_BOT_TOKEN"), token=_need("PRAGENT_BOT_TOKEN"),
ollama_url=_need("OLLAMA_URL"), ollama_url=_need("OLLAMA_URL"),
model=_need("OLLAMA_MODEL"), model=_need("OLLAMA_MODEL"),
max_tokens=int(os.environ.get("OLLAMA_MAX_TOKENS", "6000")), max_tokens=int(os.environ.get("OLLAMA_MAX_TOKENS", "8000")),
max_chars=int(os.environ.get("DIFF_MAX_CHARS", "150000")), max_chars=int(os.environ.get("DIFF_MAX_CHARS", "150000")),
) )
return 0 return 0
+8 -5
View File
@@ -1,12 +1,14 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""pragent pilot — central webhook receiver. """pragent pilot — central webhook receiver.
A stdlib-only HTTP server that Gitea posts system-webhook events to. It gates on A stdlib-only HTTP server that Gitea posts user-webhook events to. It gates on
the `AI-REVIEW` PR label, then runs the same review core (`ai_review.review_pr`) 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`. 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 Per-owner setup: one Gitea **user-level webhook** per repo-owner fires for every
instance; this service filters to labeled PRs. Onboarding a repo = label a PR. repo that owner has; this service filters to labeled PRs. (Gitea 1.26.1 system
webhooks are broken — see pilot/README-webhook.md.) Onboarding a repo = add the
bot as a Write collaborator + create the label + label a PR.
Stdlib only — no pip install, runs on python:3-slim with the scripts mounted. Stdlib only — no pip install, runs on python:3-slim with the scripts mounted.
@@ -17,7 +19,8 @@ Endpoints:
Env: Env:
WEBHOOK_SECRET shared secret used to register the Gitea webhook (HMAC) WEBHOOK_SECRET shared secret used to register the Gitea webhook (HMAC)
GITEA_API in-cluster Gitea base URL GITEA_API in-cluster Gitea base URL
PRAGENT_BOT_TOKEN pragent-bot access token (admin so it can read any repo) PRAGENT_BOT_TOKEN pragent-bot access token (non-admin; must be a Write
collaborator on each reviewed repo)
OLLAMA_URL headroom proxy URL, e.g. http://100.74.17.70:8789 OLLAMA_URL headroom proxy URL, e.g. http://100.74.17.70:8789
OLLAMA_MODEL model id, e.g. glm-5.2:cloud OLLAMA_MODEL model id, e.g. glm-5.2:cloud
OLLAMA_MAX_TOKENS (optional) output cap, default 6000 OLLAMA_MAX_TOKENS (optional) output cap, default 6000
@@ -46,7 +49,7 @@ GITEA_API = os.environ.get("GITEA_API", "http://gitea-http.gitea.svc.cluster.loc
BOT_TOKEN = os.environ.get("PRAGENT_BOT_TOKEN", "") BOT_TOKEN = os.environ.get("PRAGENT_BOT_TOKEN", "")
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://100.74.17.70:8789") 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_MODEL = os.environ.get("OLLAMA_MODEL", "glm-5.2:cloud")
OLLAMA_MAX_TOKENS = int(os.environ.get("OLLAMA_MAX_TOKENS", "6000")) OLLAMA_MAX_TOKENS = int(os.environ.get("OLLAMA_MAX_TOKENS", "8000"))
DIFF_MAX_CHARS = int(os.environ.get("DIFF_MAX_CHARS", "150000")) DIFF_MAX_CHARS = int(os.environ.get("DIFF_MAX_CHARS", "150000"))
WEBHOOK_SECRET = os.environ.get("WEBHOOK_SECRET", "").encode() WEBHOOK_SECRET = os.environ.get("WEBHOOK_SECRET", "").encode()
PORT = int(os.environ.get("WEBHOOK_PORT", "8080")) PORT = int(os.environ.get("WEBHOOK_PORT", "8080"))
+211 -1
View File
@@ -10,7 +10,15 @@ sys.path.insert(0, os.path.join(ROOT, "pilot"))
from ai_review import ( # noqa: E402 from ai_review import ( # noqa: E402
build_user_prompt, build_user_prompt,
format_review_body, format_review_body,
inline_comment_body,
parse_diff_anchors,
parse_findings,
parse_repo_config,
parse_text_blocks, parse_text_blocks,
prior_review_bodies,
reviewed_shas,
split_findings,
summary_bullets,
truncate_diff, truncate_diff,
) )
@@ -130,4 +138,206 @@ def test_build_user_prompt_truncates_long_body():
def test_build_user_prompt_no_body(): def test_build_user_prompt_no_body():
p = build_user_prompt("t", "", "d") p = build_user_prompt("t", "", "d")
assert "Description:" not in p assert "Description:" not in p
def test_build_user_prompt_with_config_and_prior():
cfg = {"focus": ["security"], "instructions": "Use Result<T,E>."}
prior = ["🤖 **AI Review** …\n- [high] x:1 — old."]
p = build_user_prompt("t", "b", "diff --git a/x b/x", config=cfg, prior_reviews=prior)
assert "## Repo review config" in p
assert "security" in p
assert "Result<T,E>" in p
assert "## PREVIOUS REVIEWS" in p
assert "old." in p
# ---------------------------------------------------------------------------
# parse_diff_anchors
# ---------------------------------------------------------------------------
_DIFF = """\
diff --git a/src/a.py b/src/a.py
index 1..2 100644
--- a/src/a.py
+++ b/src/a.py
@@ -1,4 +1,5 @@
context
-removed
+added
context2
@@ -10,3 +10,4 @@
keep
+new
last
diff --git a/binary.bin b/binary.bin
new file mode 100644
index 0..1
Binary files differ
"""
def test_parse_diff_anchors_context_and_added():
a = parse_diff_anchors(_DIFF)
# context(1), +added(2), context2(3) | keep(10), +new(11), last(12)
assert a["src/a.py"] == {1, 2, 3, 10, 11, 12}
# removed line (-removed, old line 2) has no new-line anchor
assert 2 in a["src/a.py"] # 2 here is the +added line, not the removed one
def test_parse_diff_anchors_binary_file_present_no_lines():
a = parse_diff_anchors(_DIFF)
assert "binary.bin" in a
assert a["binary.bin"] == set()
def test_parse_diff_anchors_empty():
assert parse_diff_anchors("") == {}
assert parse_diff_anchors(None) == {} # type: ignore[arg-type]
def test_parse_diff_anchors_new_file():
diff = "diff --git a/new.ts b/new.ts\nnew file mode 100644\n--- /dev/null\n+++ b/new.ts\n@@ -0,0 +1,3 @@\n+a\n+b\n+c\n"
a = parse_diff_anchors(diff)
assert a["new.ts"] == {1, 2, 3}
# ---------------------------------------------------------------------------
# parse_findings
# ---------------------------------------------------------------------------
def test_parse_findings_clean_json():
txt = '{"findings":[{"severity":"high","path":"a.py","line":3,"problem":"x","fix":"y","suggestion":"z"}]}'
fs = parse_findings(txt)
assert len(fs) == 1
assert fs[0]["severity"] == "high"
assert fs[0]["path"] == "a.py"
assert fs[0]["line"] == 3
def test_parse_findings_fenced_json():
txt = '```json\n{"findings":[{"severity":"low","path":"b.go","line":1,"problem":"p","fix":"","suggestion":""}]}\n```'
fs = parse_findings(txt)
assert len(fs) == 1
assert fs[0]["path"] == "b.go"
def test_parse_findings_json_in_prose():
txt = 'Here is my review: {"findings":[{"severity":"critical","path":"c","line":9,"problem":"q"}]} thanks!'
fs = parse_findings(txt)
assert len(fs) == 1
assert fs[0]["severity"] == "critical"
def test_parse_findings_empty():
assert parse_findings('{"findings":[]}') == []
assert parse_findings("") == []
assert parse_findings("not json at all") == []
def test_parse_findings_drops_bad_entries():
# missing path, bad line, unknown severity (normalised)
txt = '{"findings":[{"line":1},{"path":"x","line":-1},{"path":"x","line":2,"severity":"bogus","problem":"p"}]}'
fs = parse_findings(txt)
assert len(fs) == 1
assert fs[0]["severity"] == "medium"
# ---------------------------------------------------------------------------
# split_findings + inline_comment_body + summary_bullets
# ---------------------------------------------------------------------------
def test_split_findings_by_anchor():
anchors = {"a.py": {1, 3, 4}}
fs = [
{"severity": "high", "path": "a.py", "line": 3, "problem": "p", "fix": "f", "suggestion": ""},
{"severity": "low", "path": "a.py", "line": 99, "problem": "off", "fix": "", "suggestion": ""},
{"severity": "medium", "path": "other.go", "line": 1, "problem": "x", "fix": "", "suggestion": ""},
]
anchored, unanchored = split_findings(fs, anchors)
assert [f["line"] for f in anchored] == [3]
assert len(unanchored) == 2
def test_inline_comment_body_with_suggestion():
f = {"severity": "high", "path": "a", "line": 1, "problem": "bad", "fix": "swap", "suggestion": "good()"}
body = inline_comment_body(f)
assert "**[HIGH]**" in body
assert "bad" in body
assert "```suggestion\n" in body
assert "good()" in body
def test_inline_comment_body_no_suggestion():
f = {"severity": "low", "path": "a", "line": 1, "problem": "p", "fix": "f", "suggestion": ""}
body = inline_comment_body(f)
assert "```suggestion" not in body
assert "Fix: f" in body
def test_summary_bullets_format():
fs = [{"severity": "high", "path": "a.py", "line": 7, "problem": "p", "fix": "f", "suggestion": ""}]
b = summary_bullets(fs)
assert "- **[HIGH]**" in b
assert "`a.py:7`" in b
# ---------------------------------------------------------------------------
# repo config parsing
# ---------------------------------------------------------------------------
def test_parse_repo_config_full():
raw = '{"focus":["security","perf"],"exclude_paths":["vendor/**"],"languages":["go"],"instructions":"be strict"}'
c = parse_repo_config(raw)
assert c["focus"] == ["security", "perf"]
assert c["exclude_paths"] == ["vendor/**"]
assert c["instructions"] == "be strict"
def test_parse_repo_config_partial_and_bad():
assert parse_repo_config('{"focus":"not-a-list"}') == {}
assert parse_repo_config('{"focus":["ok"]}') == {"focus": ["ok"]}
assert parse_repo_config("") == {}
assert parse_repo_config("not json") == {}
assert parse_repo_config('{"instructions":" "}') == {}
# ---------------------------------------------------------------------------
# dedupe / prior-context parsing
# ---------------------------------------------------------------------------
def test_reviewed_shas_extracts_marker():
reviews = [
{"body": "🤖 AI Review · glm · `abcdef12`\n\n<!-- pragent:sha=abcdef1234567890 -->"},
{"body": "human comment, no marker"},
{"body": "<!-- pragent:sha=0987654 -->"},
]
shas = reviewed_shas(reviews)
assert "abcdef1234567890" in shas
assert "0987654" in shas
def test_reviewed_shas_empty():
assert reviewed_shas([]) == set()
assert reviewed_shas([{"body": "no marker"}]) == set()
def test_prior_review_bodies_skips_current_sha():
reviews = [
{"body": "r1\n<!-- pragent:sha=1111111 -->"},
{"body": "r2\n<!-- pragent:sha=2222222 -->"},
{"body": "no marker here"},
]
prior = prior_review_bodies(reviews, current_sha="2222222")
assert len(prior) == 1
assert "r1" in prior[0]
def test_format_review_body_has_sha_marker():
body = format_review_body("- [high] x:1 — b", "glm-5.2:cloud", "abcdef1234567890")
assert "<!-- pragent:sha=abcdef1234567890 -->" in body