ocr-post

Post one Open Code Review run as a single advisory PR review (MIP-0060 §5.2 "Post").

Advisory means: exactly one COMMENT review, never REQUEST_CHANGES, and exit 0 whatever the JSON looks like — a broken review tool must not turn a PR red.

Input schema read from alibaba/open-code-review at tag v1.12.7: internal/model/review.go (LlmComment: path, content, suggestion_code, existing_code, start_line, end_line, category, severity), cmd/opencodereview/output.go (jsonOutput: status, llm{provider,model}, message, summary{files_reviewed,comments,elapsed,…}, comments, warnings), and upstream's own consumer scripts/github-actions/post-review-comments.js, which is where the posting rules come from: a finding is inline-able when start_line or end_line ≥ 1, multi-line uses start_line + line on side RIGHT, and a ```suggestion block is emitted only when suggestion_code and existing_code are both present.

ocr-post.py --json ocr.json --repo owner/name --pr 42 --head-sha "$SHA"
ocr-post.py --json ocr.json --repo owner/name --pr 42 --head-sha x --diff d.patch --dry-run
ocr-post.py --self-test
  1#!/usr/bin/env python3
  2"""Post one Open Code Review run as a single advisory PR review (MIP-0060 §5.2 "Post").
  3
  4Advisory means: exactly one `COMMENT` review, never `REQUEST_CHANGES`, and exit 0 whatever the
  5JSON looks like — a broken review tool must not turn a PR red.
  6
  7Input schema read from alibaba/open-code-review at tag **v1.12.7**:
  8`internal/model/review.go` (`LlmComment`: path, content, suggestion_code, existing_code,
  9start_line, end_line, category, severity), `cmd/opencodereview/output.go` (`jsonOutput`:
 10status, llm{provider,model}, message, summary{files_reviewed,comments,elapsed,…}, comments,
 11warnings), and upstream's own consumer `scripts/github-actions/post-review-comments.js`, which
 12is where the posting rules come from: a finding is inline-able when start_line or end_line ≥ 1,
 13multi-line uses start_line + line on side RIGHT, and a ```suggestion block is emitted only when
 14suggestion_code *and* existing_code are both present.
 15
 16    ocr-post.py --json ocr.json --repo owner/name --pr 42 --head-sha "$SHA"
 17    ocr-post.py --json ocr.json --repo owner/name --pr 42 --head-sha x --diff d.patch --dry-run
 18    ocr-post.py --self-test
 19"""
 20
 21from __future__ import annotations
 22
 23import argparse
 24import json
 25import re
 26import subprocess
 27import sys
 28from pathlib import Path
 29
 30MARKER = "<!-- marola-ocr -->"
 31# MIP-0060 §8: low recall is by design, so silence must never read as a pass.
 32QUIET_NOT_APPROVAL = "A quiet run is **not** an approval — this reviewer's recall is low by design."
 33SEVERITY_RANK = {"critical": 4, "high": 3, "medium": 2, "low": 1}
 34MAX_BODY = 1200
 35MAX_CODE = 800
 36HUNK = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
 37MENTION = re.compile(r"(?<![\w/`])@([A-Za-z0-9][-A-Za-z0-9]{0,38})")
 38FIXTURES = Path(__file__).resolve().parent / "fixtures" / "ocr"
 39
 40
 41class GhError(RuntimeError):
 42    def __init__(self, status: int, message: str):
 43        super().__init__(message)
 44        self.status = status
 45
 46
 47def gh_api(method: str, path: str, payload=None, accept: str | None = None):
 48    """Every GitHub read and write goes through here, so --self-test can swap in a fake."""
 49    cmd = ["gh", "api", "-X", method, path]
 50    if accept:
 51        cmd += ["-H", f"Accept: {accept}"]
 52    if payload is not None:
 53        cmd += ["--input", "-"]
 54    p = subprocess.run(
 55        cmd,
 56        input=json.dumps(payload) if payload is not None else None,
 57        capture_output=True,
 58        text=True,
 59        check=False,
 60    )
 61    if p.returncode != 0:
 62        m = re.search(r"HTTP (\d{3})", p.stderr)
 63        raise GhError(int(m.group(1)) if m else 0, (p.stderr or "gh api failed").strip())
 64    if accept and "json" not in accept:
 65        return p.stdout
 66    return json.loads(p.stdout) if p.stdout.strip() else None
 67
 68
 69# --- the OCR JSON -----------------------------------------------------------------
 70
 71
 72def _int(v) -> int:
 73    return v if isinstance(v, int) and not isinstance(v, bool) else 0
 74
 75
 76def normalise(c: dict) -> dict:
 77    return {
 78        "path": str(c.get("path") or "").strip().lstrip("./"),
 79        "content": str(c.get("content") or ""),
 80        "start_line": _int(c.get("start_line")),
 81        "end_line": _int(c.get("end_line")),
 82        "severity": str(c.get("severity") or "").strip().lower(),
 83        "category": str(c.get("category") or "").strip().lower(),
 84        "suggestion_code": str(c.get("suggestion_code") or ""),
 85        "existing_code": str(c.get("existing_code") or ""),
 86    }
 87
 88
 89def load_findings(path: str) -> tuple[dict, list[dict], str]:
 90    """(document, findings, reason). A non-empty reason means: degrade to summary-only."""
 91    try:
 92        raw = Path(path).read_text(encoding="utf-8")
 93    except OSError as e:
 94        return {}, [], f"cannot read {path} ({e.strerror or e})"
 95    if not raw.strip():
 96        return {}, [], f"{Path(path).name} is empty — ocr wrote no output"
 97    try:
 98        doc = json.loads(raw)
 99    except json.JSONDecodeError as e:
100        return {}, [], f"{Path(path).name} is not valid JSON ({e.msg}, line {e.lineno})"
101    if not isinstance(doc, dict):
102        return {}, [], "top-level JSON is not an object — unknown output shape"
103    if doc.get("comments") is None and doc.get("status") == "failed":
104        # A failed run carries `comments: null`, not `[]` (seen in a real v1.12.7 run, fixture
105        # failed-run.json); the per-file reasons are in manifest.coverage.failed.
106        failed = ((doc.get("manifest") or {}).get("coverage") or {}).get("failed") or []
107        why = next(
108            (str(f.get("reason")) for f in failed if isinstance(f, dict) and f.get("reason")), ""
109        )
110        return doc, [], f"ocr failed on {len(failed)} file(s)" + (f" — {why}" if why else "")
111    if not isinstance(doc.get("comments"), list):
112        return doc, [], "no `comments` array in the output — unknown output shape"
113    findings = [normalise(c) for c in doc["comments"] if isinstance(c, dict)]
114    return doc, [f for f in findings if f["path"]], ""
115
116
117# --- the PR diff ------------------------------------------------------------------
118
119
120def right_lines(diff_text: str) -> dict[str, set[int]]:
121    """Line numbers addressable on the RIGHT side of a unified diff, per path."""
122    out: dict[str, set[int]] = {}
123    path, new_no, new_left, old_left = None, 0, 0, 0
124    for line in diff_text.splitlines():
125        if line.startswith("+++ "):
126            p = line[4:].split("\t")[0].strip()
127            path = None if p == "/dev/null" else (p[2:] if p.startswith("b/") else p)
128            new_left = old_left = 0
129            continue
130        m = HUNK.match(line)
131        if m:
132            new_no = int(m.group(3))
133            new_left = int(m.group(4) or 1)
134            old_left = int(m.group(2) or 1)
135            continue
136        if path is None or (new_left <= 0 and old_left <= 0):
137            continue
138        if line.startswith("\\"):
139            continue
140        if line.startswith("+"):
141            out.setdefault(path, set()).add(new_no)
142            new_no += 1
143            new_left -= 1
144        elif line.startswith("-"):
145            old_left -= 1
146        elif line.startswith(" ") or line == "":
147            out.setdefault(path, set()).add(new_no)
148            new_no += 1
149            new_left -= 1
150            old_left -= 1
151        else:
152            new_left = old_left = 0
153    return out
154
155
156def inline_position(f: dict, lines: dict[str, set[int]]) -> dict | None:
157    known = lines.get(f["path"])
158    if not known:
159        return None
160    s, e = f["start_line"], f["end_line"]
161    if 1 <= s < e and s in known and e in known:
162        return {"line": e, "start_line": s, "side": "RIGHT", "start_side": "RIGHT"}
163    one = e if e >= 1 else s
164    return {"line": one, "side": "RIGHT"} if one >= 1 and one in known else None
165
166
167# --- text the model wrote: never trusted -------------------------------------------
168
169
170def sanitise(text: str, limit: int = MAX_BODY) -> str:
171    """Strip HTML comments (a finding must not forge the sticky marker) and defuse @mentions."""
172    t = re.sub(r"<!--.*?-->", "", text, flags=re.S)
173    t = t.replace("<!--", "&lt;!--").replace("-->", "--&gt;")
174    t = MENTION.sub(r"`@\1`", t)
175    if len(t) > limit:
176        t = t[: limit - 16].rstrip() + "\n\n…(truncated)"
177    return t.strip()
178
179
180def fence_for(code: str) -> str:
181    longest = max((len(r) for r in re.findall(r"`+", code)), default=0)
182    return "`" * max(3, longest + 1)
183
184
185def label(f: dict) -> str:
186    tag = "/".join(x for x in (f["category"], f["severity"]) if x)
187    return f"[{tag}]" if tag else ""
188
189
190def comment_body(f: dict) -> str:
191    body = sanitise(f["content"]) or "_(the model wrote no text)_"
192    if label(f):
193        body = f"**{label(f)}** {body}"
194    # Upstream's own condition for treating the field as a code suggestion.
195    if f["suggestion_code"] and f["existing_code"]:
196        code = re.sub(r"<!--.*?-->", "", f["suggestion_code"], flags=re.S)[:MAX_CODE]
197        fence = fence_for(code)
198        body += f"\n\n{fence}suggestion\n{code.rstrip()}\n{fence}"
199    return body
200
201
202def one_line(f: dict, n: int = 140) -> str:
203    text = sanitise(f["content"], n + 40).replace("\n", " ").strip()
204    return (text[: n - 1] + "…") if len(text) > n else text
205
206
207# --- the two things we post --------------------------------------------------------
208
209
210def clean_field(v: str, n: int = 60) -> str:
211    return re.sub(r"[`|\r\n]", "", str(v)).strip()[:n] or "unknown"
212
213
214def header(doc: dict, args, n_findings: int) -> str:
215    s = doc.get("summary") if isinstance(doc.get("summary"), dict) else {}
216    llm = doc.get("llm") if isinstance(doc.get("llm"), dict) else {}
217    model = clean_field(args.model or llm.get("model") or "unknown")
218    provider = clean_field(args.provider or llm.get("provider") or "local", 24)
219    files = _int(s.get("files_reviewed")) if isinstance(s.get("files_reviewed"), int) else "?"
220    elapsed = clean_field(args.elapsed or s.get("elapsed") or "?", 24)
221    return (
222        f"`marola-ocr` · advisory · {model} ({provider}) · ocr "
223        f"{clean_field(args.ocr_version or 'unknown', 24)} · "
224        f"{files} files, {n_findings} findings, {elapsed}"
225    )
226
227
228def summary_body(head: str, posted: int, dropped: list[tuple[dict, str]], notes: list[str]) -> str:
229    out = [MARKER, head, ""]
230    out.append(f"{posted} finding(s) posted inline on this PR's diff.")
231    if dropped:
232        out += ["", f"Not posted inline ({len(dropped)}):"]
233        for f, why in dropped:
234            where = f"{f['path']}:{f['end_line'] or f['start_line']}"
235            out.append(f"- `{where}` {label(f)} — {why} — {one_line(f)}".replace(" —  — ", " — "))
236    for n in notes:
237        out += ["", n]
238    out += ["", QUIET_NOT_APPROVAL, ""]
239    return "\n".join(out)
240
241
242def post_review(api, repo: str, pr: int, payload: dict) -> int:
243    """Returns how many inline comments landed; -1 if no review could be posted at all."""
244    try:
245        api("POST", f"repos/{repo}/pulls/{pr}/reviews", payload)
246        return len(payload.get("comments", []))
247    except GhError as e:
248        if e.status != 422:
249            print(f"ocr-post: review not posted ({e})", file=sys.stderr)
250            return -1
251        retry = {k: v for k, v in payload.items() if k != "comments"}
252        try:
253            api("POST", f"repos/{repo}/pulls/{pr}/reviews", retry)
254        except GhError as e2:
255            print(f"ocr-post: summary-only retry also failed ({e2})", file=sys.stderr)
256            return -1
257        return 0
258
259
260def upsert_sticky(api, repo: str, pr: int, body: str) -> str:
261    for page in range(1, 6):
262        items = api("GET", f"repos/{repo}/issues/{pr}/comments?per_page=100&page={page}") or []
263        for c in items:
264            if MARKER in (c.get("body") or ""):
265                api("PATCH", f"repos/{repo}/issues/comments/{c['id']}", {"body": body})
266                return "updated"
267        if len(items) < 100:
268            break
269    api("POST", f"repos/{repo}/issues/{pr}/comments", {"body": body})
270    return "created"
271
272
273# --- wiring ------------------------------------------------------------------------
274
275
276def run(args, api) -> int:
277    doc, findings, reason = load_findings(args.json)
278    if reason:
279        head = header(doc, args, 0)
280        body = f"{MARKER}\n{head}\n\nno review this run: {reason}\n\n{QUIET_NOT_APPROVAL}\n"
281        if args.dry_run:
282            print("(no review posted)")
283            print(body)
284        else:
285            upsert_sticky(api, args.repo, args.pr, body)
286        print(f"ocr-post: no review this run: {reason}", file=sys.stderr)
287        return 0
288
289    if args.diff:
290        diff = Path(args.diff).read_text(encoding="utf-8")
291    else:
292        diff = api(
293            "GET",
294            f"repos/{args.repo}/pulls/{args.pr}",
295            accept="application/vnd.github.v3.diff",
296        )
297    lines = right_lines(diff or "")
298
299    ordered = sorted(findings, key=lambda f: -SEVERITY_RANK.get(f["severity"], 0))
300    placed, dropped = [], []
301    for f in ordered:
302        pos = inline_position(f, lines)
303        if pos:
304            placed.append((f, pos))
305        else:
306            dropped.append((f, "outside this PR's diff"))
307    over = placed[args.max_inline :]
308    placed = placed[: args.max_inline]
309    dropped += [(f, f"over the inline cap of {args.max_inline}") for f, _ in over]
310
311    head = header(doc, args, len(findings))
312    review = {
313        "commit_id": args.head_sha,
314        "event": "COMMENT",
315        "body": f"{head}\n\nAdvisory only. {QUIET_NOT_APPROVAL}",
316        "comments": [{"path": f["path"], "body": comment_body(f), **pos} for f, pos in placed],
317    }
318
319    notes = []
320    if args.dry_run:
321        print(json.dumps(review, indent=2, ensure_ascii=False))
322        posted = len(placed)
323    else:
324        posted = post_review(api, args.repo, args.pr, review)
325        if posted < len(placed):
326            notes.append("The review carried no inline comments; every finding is listed here.")
327            dropped += [(f, "position rejected by GitHub") for f, _ in placed]
328            posted = max(posted, 0)
329
330    body = summary_body(head, posted, dropped, notes)
331    if args.dry_run:
332        print(body)
333    else:
334        print(f"ocr-post: {upsert_sticky(api, args.repo, args.pr, body)} the sticky summary")
335    return 0
336
337
338def build_parser() -> argparse.ArgumentParser:
339    ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
340    ap.add_argument("--json", help="ocr review --format json --output <this>")
341    ap.add_argument("--repo", help="OWNER/NAME")
342    ap.add_argument("--pr", type=int)
343    ap.add_argument("--head-sha")
344    ap.add_argument("--diff", help="unified diff file; default: fetch the PR's diff via gh")
345    ap.add_argument("--model", default="")
346    ap.add_argument("--provider", default="")
347    ap.add_argument("--ocr-version", default="")
348    ap.add_argument("--elapsed", default="")
349    ap.add_argument("--max-inline", type=int, default=15)
350    ap.add_argument("--dry-run", action="store_true")
351    ap.add_argument("--self-test", action="store_true")
352    return ap
353
354
355def main(argv=None) -> int:
356    args = build_parser().parse_args(argv)
357    if args.self_test:
358        return self_test()
359    missing = [n for n in ("json", "repo", "pr", "head_sha") if not getattr(args, n)]
360    if missing:
361        print(
362            f"ocr-post: missing --{', --'.join(m.replace('_', '-') for m in missing)}",
363            file=sys.stderr,
364        )
365        return 2
366    return run(args, gh_api)
367
368
369# --- self-test ---------------------------------------------------------------------
370
371
372class FakeGh:
373    """The whole GitHub side: a diff to serve, an issue-comment store, reviews received."""
374
375    def __init__(self, diff: str, reject_positions: bool = False):
376        self.diff, self.reject = diff, reject_positions
377        self.comments: list[dict] = []
378        self.reviews: list[dict] = []
379        self.patches = 0
380        self.next_id = 100
381
382    def __call__(self, method, path, payload=None, accept=None):
383        if accept and "diff" in accept:
384            return self.diff
385        if method == "POST" and path.endswith("/reviews"):
386            if self.reject and payload.get("comments"):
387                raise GhError(422, "HTTP 422: line must be part of the diff")
388            self.reviews.append(payload)
389            return {"id": 1}
390        if method == "GET" and "/issues/" in path:
391            return self.comments if "page=1" in path else []
392        if method == "PATCH" and "/issues/comments/" in path:
393            self.patches += 1
394            for c in self.comments:
395                if c["id"] == int(path.rsplit("/", 1)[1]):
396                    c["body"] = payload["body"]
397            return {"id": 1}
398        if method == "POST" and "/issues/" in path:
399            c = {"id": self.next_id, "body": payload["body"]}
400            self.next_id += 1
401            self.comments.append(c)
402            return c
403        raise AssertionError(f"unexpected {method} {path}")
404
405
406def self_test() -> int:  # noqa: C901 - a flat list of assertions reads better than helpers
407    fails = 0
408
409    def ok(cond, what):
410        nonlocal fails
411        if not cond:
412            fails += 1
413            print(f"  FAIL: {what}", file=sys.stderr)
414
415    diff = (FIXTURES / "diff.patch").read_text(encoding="utf-8")
416    lines = right_lines(diff)
417    ok(88 in lines["scripts/oods_ingest.py"], "an added line is addressable on the RIGHT side")
418    ok(200 not in lines["scripts/oods_ingest.py"], "a line outside every hunk is not")
419    ok(41 in lines["oods/src/main/scala/Planner.scala"], "the second file's hunk is parsed too")
420
421    def go(fixture, **kw):
422        argv = [
423            "--json", str(FIXTURES / fixture),
424            "--repo", "h0ffmann/marola", "--pr", "42", "--head-sha", "deadbee",
425            "--diff", str(FIXTURES / "diff.patch"), "--ocr-version", "1.12.7",
426        ]  # fmt: skip
427        for k, v in kw.items():
428            argv += [f"--{k}", str(v)]
429        api = FakeGh(diff)
430        return run(build_parser().parse_args(argv), api), api
431
432    rc, api = go("normal.json")
433    ok(rc == 0, "a normal run exits 0")
434    ok(len(api.reviews) == 1 and api.reviews[0]["event"] == "COMMENT", "one COMMENT review")
435    bodies = "\n".join(c["body"] for c in api.reviews[0]["comments"])
436    ok("`@hoffmann`" in bodies and "\n@hoffmann" not in bodies, "an @mention cannot ping anyone")
437    ok(MARKER not in bodies, "a finding cannot forge the sticky marker")
438    ok(len(api.comments) == 1 and MARKER in api.comments[0]["body"], "one sticky summary")
439    ok(QUIET_NOT_APPROVAL in api.comments[0]["body"], "the summary says a quiet run is no approval")
440
441    rc, api = go("out-of-diff.json")
442    ok(len(api.reviews[0]["comments"]) == 1, "the out-of-diff finding is not posted inline")
443    sticky = api.comments[0]["body"]
444    ok("Untouched.scala" in sticky and "outside this PR's diff" in sticky, "…it is counted instead")
445
446    rc, api = go("many.json")
447    posted = api.reviews[0]["comments"]
448    ok(len(posted) == 15, "inline comments are capped at 15 by default")
449    ranks = [
450        SEVERITY_RANK.get(b["body"].split("[")[1].split("/")[1].split("]")[0], 0) for b in posted
451    ]
452    ok(ranks == sorted(ranks, reverse=True), "the cap keeps the most severe findings, in order")
453    ok("over the inline cap of 15" in api.comments[0]["body"], "the rest are listed in the summary")
454    rc, api = go("many.json", **{"max-inline": 3})
455    ok(len(api.reviews[0]["comments"]) == 3, "--max-inline moves the cap")
456
457    api = FakeGh(diff)
458    argv = [
459        "--json", str(FIXTURES / "normal.json"), "--repo", "h0ffmann/marola", "--pr", "42",
460        "--head-sha", "deadbee", "--diff", str(FIXTURES / "diff.patch"),
461    ]  # fmt: skip
462    run(build_parser().parse_args(argv), api)
463    run(build_parser().parse_args(argv), api)
464    ok(len(api.comments) == 1 and api.patches == 1, "a second run PATCHes, never posts a twin")
465
466    for fixture, why in (
467        ("malformed.json", "not valid JSON"),
468        ("empty.json", "is empty"),
469        ("failed-run.json", "ocr failed on 4 file(s)"),
470    ):
471        rc, api = go(fixture)
472        ok(rc == 0, f"{fixture} still exits 0 — the check is advisory")
473        ok(not api.reviews, f"{fixture} posts no review")
474        ok(why in api.comments[0]["body"], f"{fixture} explains itself in the summary")
475        ok(
476            QUIET_NOT_APPROVAL in api.comments[0]["body"],
477            f"{fixture} still refuses to imply a pass",
478        )
479
480    api = FakeGh(diff, reject_positions=True)
481    run(build_parser().parse_args(argv), api)
482    ok(len(api.reviews) == 1 and "comments" not in api.reviews[0], "a 422 retries as summary-only")
483    ok("position rejected" in api.comments[0]["body"], "…and the summary keeps every finding")
484
485    print(f"ocr-post self-test: {'ok' if fails == 0 else f'{fails} FAILED'}")
486    return 0 if fails == 0 else 1
487
488
489if __name__ == "__main__":
490    sys.exit(main())
MARKER = '<!-- marola-ocr -->'
QUIET_NOT_APPROVAL = "A quiet run is **not** an approval — this reviewer's recall is low by design."
SEVERITY_RANK = {'critical': 4, 'high': 3, 'medium': 2, 'low': 1}
MAX_BODY = 1200
MAX_CODE = 800
HUNK = re.compile('^@@ -(\\d+)(?:,(\\d+))? \\+(\\d+)(?:,(\\d+))? @@')
MENTION = re.compile('(?<![\\w/`])@([A-Za-z0-9][-A-Za-z0-9]{0,38})')
FIXTURES = PosixPath('/home/runner/work/marola/marola/scripts/fixtures/ocr')
class GhError(builtins.RuntimeError):
42class GhError(RuntimeError):
43    def __init__(self, status: int, message: str):
44        super().__init__(message)
45        self.status = status

Unspecified run-time error.

GhError(status: int, message: str)
43    def __init__(self, status: int, message: str):
44        super().__init__(message)
45        self.status = status
status
def gh_api(method: str, path: str, payload=None, accept: str | None = None):
48def gh_api(method: str, path: str, payload=None, accept: str | None = None):
49    """Every GitHub read and write goes through here, so --self-test can swap in a fake."""
50    cmd = ["gh", "api", "-X", method, path]
51    if accept:
52        cmd += ["-H", f"Accept: {accept}"]
53    if payload is not None:
54        cmd += ["--input", "-"]
55    p = subprocess.run(
56        cmd,
57        input=json.dumps(payload) if payload is not None else None,
58        capture_output=True,
59        text=True,
60        check=False,
61    )
62    if p.returncode != 0:
63        m = re.search(r"HTTP (\d{3})", p.stderr)
64        raise GhError(int(m.group(1)) if m else 0, (p.stderr or "gh api failed").strip())
65    if accept and "json" not in accept:
66        return p.stdout
67    return json.loads(p.stdout) if p.stdout.strip() else None

Every GitHub read and write goes through here, so --self-test can swap in a fake.

def normalise(c: dict) -> dict:
77def normalise(c: dict) -> dict:
78    return {
79        "path": str(c.get("path") or "").strip().lstrip("./"),
80        "content": str(c.get("content") or ""),
81        "start_line": _int(c.get("start_line")),
82        "end_line": _int(c.get("end_line")),
83        "severity": str(c.get("severity") or "").strip().lower(),
84        "category": str(c.get("category") or "").strip().lower(),
85        "suggestion_code": str(c.get("suggestion_code") or ""),
86        "existing_code": str(c.get("existing_code") or ""),
87    }
def load_findings(path: str) -> tuple[dict, list[dict], str]:
 90def load_findings(path: str) -> tuple[dict, list[dict], str]:
 91    """(document, findings, reason). A non-empty reason means: degrade to summary-only."""
 92    try:
 93        raw = Path(path).read_text(encoding="utf-8")
 94    except OSError as e:
 95        return {}, [], f"cannot read {path} ({e.strerror or e})"
 96    if not raw.strip():
 97        return {}, [], f"{Path(path).name} is empty — ocr wrote no output"
 98    try:
 99        doc = json.loads(raw)
100    except json.JSONDecodeError as e:
101        return {}, [], f"{Path(path).name} is not valid JSON ({e.msg}, line {e.lineno})"
102    if not isinstance(doc, dict):
103        return {}, [], "top-level JSON is not an object — unknown output shape"
104    if doc.get("comments") is None and doc.get("status") == "failed":
105        # A failed run carries `comments: null`, not `[]` (seen in a real v1.12.7 run, fixture
106        # failed-run.json); the per-file reasons are in manifest.coverage.failed.
107        failed = ((doc.get("manifest") or {}).get("coverage") or {}).get("failed") or []
108        why = next(
109            (str(f.get("reason")) for f in failed if isinstance(f, dict) and f.get("reason")), ""
110        )
111        return doc, [], f"ocr failed on {len(failed)} file(s)" + (f" — {why}" if why else "")
112    if not isinstance(doc.get("comments"), list):
113        return doc, [], "no `comments` array in the output — unknown output shape"
114    findings = [normalise(c) for c in doc["comments"] if isinstance(c, dict)]
115    return doc, [f for f in findings if f["path"]], ""

(document, findings, reason). A non-empty reason means: degrade to summary-only.

def right_lines(diff_text: str) -> dict[str, set[int]]:
121def right_lines(diff_text: str) -> dict[str, set[int]]:
122    """Line numbers addressable on the RIGHT side of a unified diff, per path."""
123    out: dict[str, set[int]] = {}
124    path, new_no, new_left, old_left = None, 0, 0, 0
125    for line in diff_text.splitlines():
126        if line.startswith("+++ "):
127            p = line[4:].split("\t")[0].strip()
128            path = None if p == "/dev/null" else (p[2:] if p.startswith("b/") else p)
129            new_left = old_left = 0
130            continue
131        m = HUNK.match(line)
132        if m:
133            new_no = int(m.group(3))
134            new_left = int(m.group(4) or 1)
135            old_left = int(m.group(2) or 1)
136            continue
137        if path is None or (new_left <= 0 and old_left <= 0):
138            continue
139        if line.startswith("\\"):
140            continue
141        if line.startswith("+"):
142            out.setdefault(path, set()).add(new_no)
143            new_no += 1
144            new_left -= 1
145        elif line.startswith("-"):
146            old_left -= 1
147        elif line.startswith(" ") or line == "":
148            out.setdefault(path, set()).add(new_no)
149            new_no += 1
150            new_left -= 1
151            old_left -= 1
152        else:
153            new_left = old_left = 0
154    return out

Line numbers addressable on the RIGHT side of a unified diff, per path.

def inline_position(f: dict, lines: dict[str, set[int]]) -> dict | None:
157def inline_position(f: dict, lines: dict[str, set[int]]) -> dict | None:
158    known = lines.get(f["path"])
159    if not known:
160        return None
161    s, e = f["start_line"], f["end_line"]
162    if 1 <= s < e and s in known and e in known:
163        return {"line": e, "start_line": s, "side": "RIGHT", "start_side": "RIGHT"}
164    one = e if e >= 1 else s
165    return {"line": one, "side": "RIGHT"} if one >= 1 and one in known else None
def sanitise(text: str, limit: int = 1200) -> str:
171def sanitise(text: str, limit: int = MAX_BODY) -> str:
172    """Strip HTML comments (a finding must not forge the sticky marker) and defuse @mentions."""
173    t = re.sub(r"<!--.*?-->", "", text, flags=re.S)
174    t = t.replace("<!--", "&lt;!--").replace("-->", "--&gt;")
175    t = MENTION.sub(r"`@\1`", t)
176    if len(t) > limit:
177        t = t[: limit - 16].rstrip() + "\n\n…(truncated)"
178    return t.strip()

Strip HTML comments (a finding must not forge the sticky marker) and defuse @mentions.

def fence_for(code: str) -> str:
181def fence_for(code: str) -> str:
182    longest = max((len(r) for r in re.findall(r"`+", code)), default=0)
183    return "`" * max(3, longest + 1)
def label(f: dict) -> str:
186def label(f: dict) -> str:
187    tag = "/".join(x for x in (f["category"], f["severity"]) if x)
188    return f"[{tag}]" if tag else ""
def comment_body(f: dict) -> str:
191def comment_body(f: dict) -> str:
192    body = sanitise(f["content"]) or "_(the model wrote no text)_"
193    if label(f):
194        body = f"**{label(f)}** {body}"
195    # Upstream's own condition for treating the field as a code suggestion.
196    if f["suggestion_code"] and f["existing_code"]:
197        code = re.sub(r"<!--.*?-->", "", f["suggestion_code"], flags=re.S)[:MAX_CODE]
198        fence = fence_for(code)
199        body += f"\n\n{fence}suggestion\n{code.rstrip()}\n{fence}"
200    return body
def one_line(f: dict, n: int = 140) -> str:
203def one_line(f: dict, n: int = 140) -> str:
204    text = sanitise(f["content"], n + 40).replace("\n", " ").strip()
205    return (text[: n - 1] + "…") if len(text) > n else text
def clean_field(v: str, n: int = 60) -> str:
211def clean_field(v: str, n: int = 60) -> str:
212    return re.sub(r"[`|\r\n]", "", str(v)).strip()[:n] or "unknown"
def summary_body( head: str, posted: int, dropped: list[tuple[dict, str]], notes: list[str]) -> str:
229def summary_body(head: str, posted: int, dropped: list[tuple[dict, str]], notes: list[str]) -> str:
230    out = [MARKER, head, ""]
231    out.append(f"{posted} finding(s) posted inline on this PR's diff.")
232    if dropped:
233        out += ["", f"Not posted inline ({len(dropped)}):"]
234        for f, why in dropped:
235            where = f"{f['path']}:{f['end_line'] or f['start_line']}"
236            out.append(f"- `{where}` {label(f)} — {why} — {one_line(f)}".replace(" —  — ", " — "))
237    for n in notes:
238        out += ["", n]
239    out += ["", QUIET_NOT_APPROVAL, ""]
240    return "\n".join(out)
def post_review(api, repo: str, pr: int, payload: dict) -> int:
243def post_review(api, repo: str, pr: int, payload: dict) -> int:
244    """Returns how many inline comments landed; -1 if no review could be posted at all."""
245    try:
246        api("POST", f"repos/{repo}/pulls/{pr}/reviews", payload)
247        return len(payload.get("comments", []))
248    except GhError as e:
249        if e.status != 422:
250            print(f"ocr-post: review not posted ({e})", file=sys.stderr)
251            return -1
252        retry = {k: v for k, v in payload.items() if k != "comments"}
253        try:
254            api("POST", f"repos/{repo}/pulls/{pr}/reviews", retry)
255        except GhError as e2:
256            print(f"ocr-post: summary-only retry also failed ({e2})", file=sys.stderr)
257            return -1
258        return 0

Returns how many inline comments landed; -1 if no review could be posted at all.

def upsert_sticky(api, repo: str, pr: int, body: str) -> str:
261def upsert_sticky(api, repo: str, pr: int, body: str) -> str:
262    for page in range(1, 6):
263        items = api("GET", f"repos/{repo}/issues/{pr}/comments?per_page=100&page={page}") or []
264        for c in items:
265            if MARKER in (c.get("body") or ""):
266                api("PATCH", f"repos/{repo}/issues/comments/{c['id']}", {"body": body})
267                return "updated"
268        if len(items) < 100:
269            break
270    api("POST", f"repos/{repo}/issues/{pr}/comments", {"body": body})
271    return "created"
def run(args, api) -> int:
277def run(args, api) -> int:
278    doc, findings, reason = load_findings(args.json)
279    if reason:
280        head = header(doc, args, 0)
281        body = f"{MARKER}\n{head}\n\nno review this run: {reason}\n\n{QUIET_NOT_APPROVAL}\n"
282        if args.dry_run:
283            print("(no review posted)")
284            print(body)
285        else:
286            upsert_sticky(api, args.repo, args.pr, body)
287        print(f"ocr-post: no review this run: {reason}", file=sys.stderr)
288        return 0
289
290    if args.diff:
291        diff = Path(args.diff).read_text(encoding="utf-8")
292    else:
293        diff = api(
294            "GET",
295            f"repos/{args.repo}/pulls/{args.pr}",
296            accept="application/vnd.github.v3.diff",
297        )
298    lines = right_lines(diff or "")
299
300    ordered = sorted(findings, key=lambda f: -SEVERITY_RANK.get(f["severity"], 0))
301    placed, dropped = [], []
302    for f in ordered:
303        pos = inline_position(f, lines)
304        if pos:
305            placed.append((f, pos))
306        else:
307            dropped.append((f, "outside this PR's diff"))
308    over = placed[args.max_inline :]
309    placed = placed[: args.max_inline]
310    dropped += [(f, f"over the inline cap of {args.max_inline}") for f, _ in over]
311
312    head = header(doc, args, len(findings))
313    review = {
314        "commit_id": args.head_sha,
315        "event": "COMMENT",
316        "body": f"{head}\n\nAdvisory only. {QUIET_NOT_APPROVAL}",
317        "comments": [{"path": f["path"], "body": comment_body(f), **pos} for f, pos in placed],
318    }
319
320    notes = []
321    if args.dry_run:
322        print(json.dumps(review, indent=2, ensure_ascii=False))
323        posted = len(placed)
324    else:
325        posted = post_review(api, args.repo, args.pr, review)
326        if posted < len(placed):
327            notes.append("The review carried no inline comments; every finding is listed here.")
328            dropped += [(f, "position rejected by GitHub") for f, _ in placed]
329            posted = max(posted, 0)
330
331    body = summary_body(head, posted, dropped, notes)
332    if args.dry_run:
333        print(body)
334    else:
335        print(f"ocr-post: {upsert_sticky(api, args.repo, args.pr, body)} the sticky summary")
336    return 0
def build_parser() -> argparse.ArgumentParser:
339def build_parser() -> argparse.ArgumentParser:
340    ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
341    ap.add_argument("--json", help="ocr review --format json --output <this>")
342    ap.add_argument("--repo", help="OWNER/NAME")
343    ap.add_argument("--pr", type=int)
344    ap.add_argument("--head-sha")
345    ap.add_argument("--diff", help="unified diff file; default: fetch the PR's diff via gh")
346    ap.add_argument("--model", default="")
347    ap.add_argument("--provider", default="")
348    ap.add_argument("--ocr-version", default="")
349    ap.add_argument("--elapsed", default="")
350    ap.add_argument("--max-inline", type=int, default=15)
351    ap.add_argument("--dry-run", action="store_true")
352    ap.add_argument("--self-test", action="store_true")
353    return ap
def main(argv=None) -> int:
356def main(argv=None) -> int:
357    args = build_parser().parse_args(argv)
358    if args.self_test:
359        return self_test()
360    missing = [n for n in ("json", "repo", "pr", "head_sha") if not getattr(args, n)]
361    if missing:
362        print(
363            f"ocr-post: missing --{', --'.join(m.replace('_', '-') for m in missing)}",
364            file=sys.stderr,
365        )
366        return 2
367    return run(args, gh_api)
class FakeGh:
373class FakeGh:
374    """The whole GitHub side: a diff to serve, an issue-comment store, reviews received."""
375
376    def __init__(self, diff: str, reject_positions: bool = False):
377        self.diff, self.reject = diff, reject_positions
378        self.comments: list[dict] = []
379        self.reviews: list[dict] = []
380        self.patches = 0
381        self.next_id = 100
382
383    def __call__(self, method, path, payload=None, accept=None):
384        if accept and "diff" in accept:
385            return self.diff
386        if method == "POST" and path.endswith("/reviews"):
387            if self.reject and payload.get("comments"):
388                raise GhError(422, "HTTP 422: line must be part of the diff")
389            self.reviews.append(payload)
390            return {"id": 1}
391        if method == "GET" and "/issues/" in path:
392            return self.comments if "page=1" in path else []
393        if method == "PATCH" and "/issues/comments/" in path:
394            self.patches += 1
395            for c in self.comments:
396                if c["id"] == int(path.rsplit("/", 1)[1]):
397                    c["body"] = payload["body"]
398            return {"id": 1}
399        if method == "POST" and "/issues/" in path:
400            c = {"id": self.next_id, "body": payload["body"]}
401            self.next_id += 1
402            self.comments.append(c)
403            return c
404        raise AssertionError(f"unexpected {method} {path}")

The whole GitHub side: a diff to serve, an issue-comment store, reviews received.

FakeGh(diff: str, reject_positions: bool = False)
376    def __init__(self, diff: str, reject_positions: bool = False):
377        self.diff, self.reject = diff, reject_positions
378        self.comments: list[dict] = []
379        self.reviews: list[dict] = []
380        self.patches = 0
381        self.next_id = 100
comments: list[dict]
reviews: list[dict]
patches
next_id
def self_test() -> int:
407def self_test() -> int:  # noqa: C901 - a flat list of assertions reads better than helpers
408    fails = 0
409
410    def ok(cond, what):
411        nonlocal fails
412        if not cond:
413            fails += 1
414            print(f"  FAIL: {what}", file=sys.stderr)
415
416    diff = (FIXTURES / "diff.patch").read_text(encoding="utf-8")
417    lines = right_lines(diff)
418    ok(88 in lines["scripts/oods_ingest.py"], "an added line is addressable on the RIGHT side")
419    ok(200 not in lines["scripts/oods_ingest.py"], "a line outside every hunk is not")
420    ok(41 in lines["oods/src/main/scala/Planner.scala"], "the second file's hunk is parsed too")
421
422    def go(fixture, **kw):
423        argv = [
424            "--json", str(FIXTURES / fixture),
425            "--repo", "h0ffmann/marola", "--pr", "42", "--head-sha", "deadbee",
426            "--diff", str(FIXTURES / "diff.patch"), "--ocr-version", "1.12.7",
427        ]  # fmt: skip
428        for k, v in kw.items():
429            argv += [f"--{k}", str(v)]
430        api = FakeGh(diff)
431        return run(build_parser().parse_args(argv), api), api
432
433    rc, api = go("normal.json")
434    ok(rc == 0, "a normal run exits 0")
435    ok(len(api.reviews) == 1 and api.reviews[0]["event"] == "COMMENT", "one COMMENT review")
436    bodies = "\n".join(c["body"] for c in api.reviews[0]["comments"])
437    ok("`@hoffmann`" in bodies and "\n@hoffmann" not in bodies, "an @mention cannot ping anyone")
438    ok(MARKER not in bodies, "a finding cannot forge the sticky marker")
439    ok(len(api.comments) == 1 and MARKER in api.comments[0]["body"], "one sticky summary")
440    ok(QUIET_NOT_APPROVAL in api.comments[0]["body"], "the summary says a quiet run is no approval")
441
442    rc, api = go("out-of-diff.json")
443    ok(len(api.reviews[0]["comments"]) == 1, "the out-of-diff finding is not posted inline")
444    sticky = api.comments[0]["body"]
445    ok("Untouched.scala" in sticky and "outside this PR's diff" in sticky, "…it is counted instead")
446
447    rc, api = go("many.json")
448    posted = api.reviews[0]["comments"]
449    ok(len(posted) == 15, "inline comments are capped at 15 by default")
450    ranks = [
451        SEVERITY_RANK.get(b["body"].split("[")[1].split("/")[1].split("]")[0], 0) for b in posted
452    ]
453    ok(ranks == sorted(ranks, reverse=True), "the cap keeps the most severe findings, in order")
454    ok("over the inline cap of 15" in api.comments[0]["body"], "the rest are listed in the summary")
455    rc, api = go("many.json", **{"max-inline": 3})
456    ok(len(api.reviews[0]["comments"]) == 3, "--max-inline moves the cap")
457
458    api = FakeGh(diff)
459    argv = [
460        "--json", str(FIXTURES / "normal.json"), "--repo", "h0ffmann/marola", "--pr", "42",
461        "--head-sha", "deadbee", "--diff", str(FIXTURES / "diff.patch"),
462    ]  # fmt: skip
463    run(build_parser().parse_args(argv), api)
464    run(build_parser().parse_args(argv), api)
465    ok(len(api.comments) == 1 and api.patches == 1, "a second run PATCHes, never posts a twin")
466
467    for fixture, why in (
468        ("malformed.json", "not valid JSON"),
469        ("empty.json", "is empty"),
470        ("failed-run.json", "ocr failed on 4 file(s)"),
471    ):
472        rc, api = go(fixture)
473        ok(rc == 0, f"{fixture} still exits 0 — the check is advisory")
474        ok(not api.reviews, f"{fixture} posts no review")
475        ok(why in api.comments[0]["body"], f"{fixture} explains itself in the summary")
476        ok(
477            QUIET_NOT_APPROVAL in api.comments[0]["body"],
478            f"{fixture} still refuses to imply a pass",
479        )
480
481    api = FakeGh(diff, reject_positions=True)
482    run(build_parser().parse_args(argv), api)
483    ok(len(api.reviews) == 1 and "comments" not in api.reviews[0], "a 422 retries as summary-only")
484    ok("position rejected" in api.comments[0]["body"], "…and the summary keeps every finding")
485
486    print(f"ocr-post self-test: {'ok' if fails == 0 else f'{fails} FAILED'}")
487    return 0 if fails == 0 else 1