diff --git a/.opencode/README.md b/.opencode/README.md index 97f8cb8..d8e2315 100644 --- a/.opencode/README.md +++ b/.opencode/README.md @@ -6,7 +6,7 @@ checked-out copy of the target repo at the PR head sha, then `opencode run` is launched there. The `pragent` primary agent reviews the diff with real tools (subagents, LSP/linters via bash, webfetch references) and emits a structured findings JSON. A thin Python shell posts that JSON back to Gitea as inline -comments + ```suggestion blocks + a summary (dedupe + anchor validation stay +comments + language-highlighted suggested-fix blocks + a summary (dedupe + anchor validation stay deterministic in Python). ## Layout @@ -40,7 +40,7 @@ flowchart TD PR --> JSON["final message: summary + ```json 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"] + ANCHOR --> POST["post_inline_review
summary + inline lang-tagged fix block + ref links + sha marker"] ``` ## Lean by default diff --git a/.opencode/agents/pragent.md b/.opencode/agents/pragent.md index 26098fc..ee1ec1d 100644 --- a/.opencode/agents/pragent.md +++ b/.opencode/agents/pragent.md @@ -87,8 +87,8 @@ read the full file around a flagged line, not just the diff hunk. the POST-CHANGE version of `path` — a context line or an added `+` line shown in the diff. Never a removed line. If unsure, use the closest context line you can see in the diff. A finding with a bad line gets folded into the summary as - a bullet instead of an inline comment, so anchoring correctly is what makes a - suggestion apply-able in Gitea. + a bullet instead of an inline comment, so anchoring correctly is what gets a + finding shown inline with its suggested-fix code block (language-highlighted). ## Output — REQUIRED exact shape diff --git a/.opencode/skills/findings-schema/SKILL.md b/.opencode/skills/findings-schema/SKILL.md index eebeed0..1579c02 100644 --- a/.opencode/skills/findings-schema/SKILL.md +++ b/.opencode/skills/findings-schema/SKILL.md @@ -42,8 +42,10 @@ message — so the JSON must be the last thing, and it must be valid. - `suggestion` — the literal new code replacing the flagged line(s). Minimal, just the changed lines, indented as they appear in the file. **Empty string** when no safe textual replacement exists (missing test, architectural note, a - fix that needs context beyond one hunk). This is wrapped in a ```suggestion - fence → Gitea renders an **apply button**. + fix that needs context beyond one hunk). The pilot wraps this in a fenced code + block tagged with the file's language so Gitea syntax-highlights it. (Gitea + 1.26.x has no GitHub-style "Apply suggestion" button, so a ```suggestion fence + is not used — a language-tagged fence gives highlighting and loses nothing.) - `reference` — a URL (CVE, library docs, spec) backing the finding, or `""`. Only link authoritative sources; don't fabricate URLs. diff --git a/.opencode/skills/review-methodology/SKILL.md b/.opencode/skills/review-methodology/SKILL.md index 4f6eca0..655a34e 100644 --- a/.opencode/skills/review-methodology/SKILL.md +++ b/.opencode/skills/review-methodology/SKILL.md @@ -38,7 +38,8 @@ Each finding's `line` MUST be a line that exists in the POST-CHANGE version of Never anchor on a **removed** (`-`) line — it has no post-change line number. If you're unsure of the exact line, use the closest context line you CAN see in the diff. A misanchored finding becomes a summary bullet instead of an inline -comment, so correct anchoring is what makes a ```suggestion apply-able in Gitea. +comment, so correct anchoring is what gets a finding shown inline with its +suggested-fix code block (language-highlighted) rather than demoted to a bullet. ## Honoring repo config diff --git a/pilot/README-webhook.md b/pilot/README-webhook.md index a5a11d7..b900a83 100644 --- a/pilot/README-webhook.md +++ b/pilot/README-webhook.md @@ -37,13 +37,14 @@ ai_review.review_pr() (same core the CI-step uses) 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); reference → 📎 ref link + 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 marker for dedupe ``` -Fail-open. No duplicate per commit (dedupe). Inline comments + apply-able -suggestions where the line anchors cleanly. Repo-local focus via +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). diff --git a/pilot/ai_review.py b/pilot/ai_review.py index 0f07535..81d2de1 100644 --- a/pilot/ai_review.py +++ b/pilot/ai_review.py @@ -5,8 +5,9 @@ Runs as a Gitea Actions step OR is called by the central webhook server (`webhook_server.py`). Fetches a PR diff, asks glm-5.2:cloud (via the on-network 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. +comments** with a fenced suggested-fix block (tagged with the file's language so +Gitea syntax-highlights it) where the model could produce one and the line +anchors cleanly to the post-change file. Features (pilot v2): - **Dedupe / persistence:** Gitea itself is the source of truth. Before @@ -22,9 +23,12 @@ Features (pilot v2): 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. + non-empty, is wrapped in a fenced code block tagged with the file's language + (via `_lang_for_path`) so Gitea syntax-highlights it. Gitea 1.26.x has no + GitHub-style "Apply suggestion" button, so a language-tagged block is used + for highlighting instead of a ```suggestion fence. 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, and review_pr never raises. Stdlib only — no pip install. @@ -431,19 +435,51 @@ def split_findings(findings: list[dict], anchors: dict[str, set[int]]) -> tuple[ return anchored, unanchored +def _lang_for_path(path: str) -> str: + """Map a file extension to a chroma language tag for fenced code blocks. + + Used so the suggested-fix block is syntax-highlighted in Gitea. Gitea 1.26.x + has no GitHub-style "Apply suggestion" button (the ```suggestion fence is + just an unknown-language code block → plain monospace, no apply), so we tag + the block with the file's real language for highlighting instead. + """ + ext = path.rsplit(".", 1)[-1].lower() if "." in path else "" + return { + "java": "java", "kt": "kotlin", "scala": "scala", "groovy": "groovy", + "ts": "typescript", "tsx": "tsx", "js": "javascript", "jsx": "jsx", + "mjs": "javascript", "cjs": "javascript", + "py": "python", "pyi": "python", + "go": "go", "rs": "rust", "rb": "ruby", "php": "php", + "c": "c", "h": "c", "cpp": "cpp", "cc": "cpp", "hpp": "cpp", + "cs": "csharp", "swift": "swift", "m": "objc", + "sh": "bash", "bash": "bash", "zsh": "bash", + "yml": "yaml", "yaml": "yaml", "json": "json", "jsonc": "json", + "toml": "toml", "ini": "ini", "cfg": "ini", + "html": "html", "htm": "html", "css": "css", "scss": "scss", + "xml": "xml", "svg": "xml", "sql": "sql", + "md": "markdown", "dockerfile": "dockerfile", + }.get(ext, "") + + 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. Appends a - `📎 ref:` link when the finding carries a `reference` URL. + Includes a fenced suggested-fix block only if the model produced non-empty + replacement code. The fence is tagged with the file's language (via + `_lang_for_path`) so Gitea syntax-highlights it — Gitea 1.26.x has no + GitHub-style "Apply suggestion" button (```suggestion is just an + unknown-language block there → plain monospace), so a language-tagged block + is strictly more readable and loses nothing. Appends a `📎 ref:` link when + the finding carries a `reference` URL. """ 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```" + lang = _lang_for_path(f.get("path", "")) + fence = f"```{lang}" if lang else "```" + body += f"\n\n{fence}\n{f['suggestion']}\n```" ref = f.get("reference", "") if ref: body += f"\n\n📎 ref: {ref}" @@ -632,8 +668,8 @@ def post_inline_review( honored here and silently leave the comment unpositioned (Gitea then renders a file-level comment on EVERY diff line of the file, which is the flood we hit). `f["line"]` is already a validated post-change (RIGHT-side) line from - `split_findings`, so it maps directly to `new_position`. The body carries the - ```suggestion fence when the model produced replacement code. + `split_findings`, so it maps directly to `new_position`. The body carries a + language-tagged fenced code block when the model produced replacement code. """ comments = [ { diff --git a/tests/pilot/test_ai_review.py b/tests/pilot/test_ai_review.py index f2479e5..47304c1 100644 --- a/tests/pilot/test_ai_review.py +++ b/tests/pilot/test_ai_review.py @@ -268,14 +268,23 @@ def test_inline_comment_body_with_suggestion(): body = inline_comment_body(f) assert "**[HIGH]**" in body assert "bad" in body - assert "```suggestion\n" in body + # no extension → bare fence (Gitea 1.26.x has no apply-suggestion; we tag + # with the file language for highlighting instead of ```suggestion) + assert "```\ngood()\n```" in body assert "good()" in body +def test_inline_comment_body_suggestion_lang_tagged(): + f = {"severity": "high", "path": "src/Foo.java", "line": 1, + "problem": "bad", "fix": "swap", "suggestion": "good();"} + body = inline_comment_body(f) + assert "```java\ngood();\n```" 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 "```" not in body assert "Fix: f" in body