fix(review): language-tagged suggestion fence for Gitea syntax highlighting

Gitea 1.26.x has no GitHub-style 'Apply suggestion' button — a ```suggestion
fence is just an unknown-language code block, so chroma does not highlight it
and there is no apply control. Switch inline_comment_body to wrap the suggested
fix in a fence tagged with the file's language (new _lang_for_path helper,
.java→java, .ts→typescript, .py→python, ...), so Gitea syntax-highlights the
code. No capability lost (there was never an apply button on this Gitea
version). Correct the docstrings/skills/README that wrongly claimed an
apply-button was rendered.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Marcos
2026-08-18 01:26:37 +00:00
parent 4129f217fa
commit 76b6752f48
7 changed files with 72 additions and 23 deletions
+2 -2
View File
@@ -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 launched there. The `pragent` primary agent reviews the diff with real tools
(subagents, LSP/linters via bash, webfetch references) and emits a structured (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 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). deterministic in Python).
## Layout ## Layout
@@ -40,7 +40,7 @@ flowchart TD
PR --> JSON["final message: summary + ```json findings```"] PR --> JSON["final message: summary + ```json findings```"]
JSON --> PARSE["ai_review.parse_review_output<br/>{summary, findings}"] JSON --> PARSE["ai_review.parse_review_output<br/>{summary, findings}"]
PARSE --> ANCHOR["parse_diff_anchors → split_findings"] PARSE --> ANCHOR["parse_diff_anchors → split_findings"]
ANCHOR --> POST["post_inline_review<br/>summary + inline ```suggestion + ref links + sha marker"] ANCHOR --> POST["post_inline_review<br/>summary + inline lang-tagged fix block + ref links + sha marker"]
``` ```
## Lean by default ## Lean by default
+2 -2
View File
@@ -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 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 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 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 a bullet instead of an inline comment, so anchoring correctly is what gets a
suggestion apply-able in Gitea. finding shown inline with its suggested-fix code block (language-highlighted).
## Output — REQUIRED exact shape ## Output — REQUIRED exact shape
+4 -2
View File
@@ -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, - `suggestion` — the literal new code replacing the flagged line(s). Minimal,
just the changed lines, indented as they appear in the file. **Empty string** just the changed lines, indented as they appear in the file. **Empty string**
when no safe textual replacement exists (missing test, architectural note, a when no safe textual replacement exists (missing test, architectural note, a
fix that needs context beyond one hunk). This is wrapped in a ```suggestion fix that needs context beyond one hunk). The pilot wraps this in a fenced code
fence → Gitea renders an **apply button**. 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 `""`. - `reference` — a URL (CVE, library docs, spec) backing the finding, or `""`.
Only link authoritative sources; don't fabricate URLs. Only link authoritative sources; don't fabricate URLs.
+2 -1
View File
@@ -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. 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 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 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 ## Honoring repo config
+4 -3
View File
@@ -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 7. post review → POST .../pulls/{i}/reviews (event: COMMENT) as pragent-bot
- prose summary → review body intro - prose summary → review body intro
- anchored findings → inline line comments, body wraps `suggestion` in a - 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 - unanchored findings → summary-body bullets
- summary body carries the <!-- pragent:sha=... --> marker for dedupe - summary body carries the <!-- pragent:sha=... --> marker for dedupe
``` ```
Fail-open. No duplicate per commit (dedupe). Inline comments + apply-able Fail-open. No duplicate per commit (dedupe). Inline comments + syntax-highlighted
suggestions where the line anchors cleanly. Repo-local focus via 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 `.pr-review.json`. Prior reviews fed as context so re-pushes synthesize instead
of repeating (light version of framework §6.1). of repeating (light version of framework §6.1).
+47 -11
View File
@@ -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 (`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 headroom proxy, Anthropic /v1/messages format) to review it, and posts the
findings back as `pragent-bot` — as a **review summary** plus **inline line findings back as `pragent-bot` — as a **review summary** plus **inline line
comments** with apply-able ```suggestion blocks where the model could produce comments** with a fenced suggested-fix block (tagged with the file's language so
them and the line anchors cleanly to the post-change file. Gitea syntax-highlights it) where the model could produce one and the line
anchors cleanly to the post-change file.
Features (pilot v2): Features (pilot v2):
- **Dedupe / persistence:** Gitea itself is the source of truth. Before - **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 findings with `path`/`line`. We parse the diff hunks to learn which
`(path, new_line)` pairs are valid post-change anchors and post each `(path, new_line)` pairs are valid post-change anchors and post each
anchored finding as a positional review comment; the `suggestion` field, if anchored finding as a positional review comment; the `suggestion` field, if
non-empty, is wrapped in a ```suggestion fence so Gitea renders an non-empty, is wrapped in a fenced code block tagged with the file's language
apply-button. Findings that don't anchor (bad line, unchanged file, etc.) (via `_lang_for_path`) so Gitea syntax-highlights it. Gitea 1.26.x has no
are folded into the summary body as plain bullets. 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, Fail-open by design: any error becomes a short "review failed" review comment,
and review_pr never raises. Stdlib only — no pip install. 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 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: def inline_comment_body(f: dict) -> str:
"""Render one finding as a positional review-comment body. """Render one finding as a positional review-comment body.
Includes a ```suggestion fence only if the model produced non-empty Includes a fenced suggested-fix block only if the model produced non-empty
replacement code. Gitea renders that as an apply-able suggestion. Appends a replacement code. The fence is tagged with the file's language (via
`📎 ref:` link when the finding carries a `reference` URL. `_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() sev = f["severity"].upper()
body = f"**[{sev}]** {f['problem']}" body = f"**[{sev}]** {f['problem']}"
if f["fix"]: if f["fix"]:
body += f"\n\nFix: {f['fix']}" body += f"\n\nFix: {f['fix']}"
if f["suggestion"]: 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", "") ref = f.get("reference", "")
if ref: if ref:
body += f"\n\n📎 ref: {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 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 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 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 `split_findings`, so it maps directly to `new_position`. The body carries a
```suggestion fence when the model produced replacement code. language-tagged fenced code block when the model produced replacement code.
""" """
comments = [ comments = [
{ {
+11 -2
View File
@@ -268,14 +268,23 @@ def test_inline_comment_body_with_suggestion():
body = inline_comment_body(f) body = inline_comment_body(f)
assert "**[HIGH]**" in body assert "**[HIGH]**" in body
assert "bad" 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 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(): def test_inline_comment_body_no_suggestion():
f = {"severity": "low", "path": "a", "line": 1, "problem": "p", "fix": "f", "suggestion": ""} f = {"severity": "low", "path": "a", "line": 1, "problem": "p", "fix": "f", "suggestion": ""}
body = inline_comment_body(f) body = inline_comment_body(f)
assert "```suggestion" not in body assert "```" not in body
assert "Fix: f" in body assert "Fix: f" in body