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