#!/usr/bin/env python3 import argparse import json import os import re import subprocess import sys from pathlib import Path from typing import Any MAX_INLINE_FINDINGS = 20 MAX_COMMENT_BODY = 60000 OMISSION_NOTICE_RESERVE = 256 NO_FINDINGS_MARKER = "" DIAGNOSTIC_MARKER = "" FALLBACK_MARKER = "" def required_env(name: str) -> str: value = os.environ.get(name) if not value: raise RuntimeError(f"{name} is required") return value def gh_api(args: list[str]) -> subprocess.CompletedProcess[str]: gh_bin = os.environ.get("GH_BIN", "gh") try: return subprocess.run( [gh_bin, "api", *args], text=True, capture_output=True, check=False, ) except FileNotFoundError as exc: return subprocess.CompletedProcess( args=[gh_bin, "api", *args], returncode=127, stdout="", stderr=f"Binary not found: {exc}", ) def list_issue_comments() -> list[dict[str, Any]]: repository = required_env("GITHUB_REPOSITORY") pr_number = required_env("PR_NUMBER") result = gh_api(["--paginate", f"repos/{repository}/issues/{pr_number}/comments?per_page=100"]) if result.returncode != 0: print(f"Could not list existing PR comments: {result.stderr.strip()}", file=sys.stderr) return [] try: comments = json.loads(result.stdout) except json.JSONDecodeError as exc: print(f"Could not parse existing PR comments: {exc}", file=sys.stderr) return [] if isinstance(comments, list): return [comment for comment in comments if isinstance(comment, dict)] return [] def upsert_issue_comment(marker: str, body: str) -> bool: repository = required_env("GITHUB_REPOSITORY") pr_number = required_env("PR_NUMBER") comment_id = None for comment in reversed(list_issue_comments()): if marker in (comment.get("body") or ""): comment_id = comment.get("id") break method = "PATCH" if comment_id else "POST" endpoint = ( f"repos/{repository}/issues/comments/{comment_id}" if comment_id else f"repos/{repository}/issues/{pr_number}/comments" ) result = gh_api(["-X", method, endpoint, "-f", f"body={body}"]) if result.returncode == 0: action = "Updated" if comment_id else "Posted" print(f"{action} OpenCode comment for marker {marker}.") return True print(f"Could not post OpenCode comment for marker {marker}: {result.stderr.strip()}", file=sys.stderr) return False def append_summary(path: Path, title: str, lines: list[str]) -> None: with path.open("a", encoding="utf-8") as handle: handle.write(f"## {title}\n\n") for line in lines: handle.write(f"{line}\n") handle.write("\n") def snippet(label: str, text: str, *, max_chars: int = 12000) -> list[str]: trimmed = text.strip() if len(trimmed) > max_chars: trimmed = trimmed[:max_chars] + "\n... truncated ..." if not trimmed: trimmed = "(empty)" return [f"### {label}", "", "```text", trimmed, "```", ""] def read_status(path: Path) -> int: try: return int(path.read_text(encoding="utf-8").strip()) except (OSError, ValueError): return 1 def parse_findings(raw: str) -> tuple[list[Any] | None, str | None]: match = re.search(r"\s*(.*?)\s*", raw, re.S) if not match: return None, "No parseable findings block was found." payload_text = match.group(1).strip() fenced = re.fullmatch(r"```(?:json)?\s*(.*?)\s*```", payload_text, re.S) if fenced: payload_text = fenced.group(1).strip() try: payload = json.loads(payload_text) except json.JSONDecodeError as exc: return None, f"Findings block contained invalid JSON: {exc}" if not isinstance(payload, list): return None, "Findings block JSON was not an array." return payload, None def diagnostic_body(reason: str, status: int, stdout: str, stderr: str) -> str: short_sha = required_env("HEAD_SHA")[:12] model = required_env("OPENCODE_MODEL") lines = [ DIAGNOSTIC_MARKER, "**OpenCode review diagnostic**", "", "OpenCode did not produce parseable findings, so the workflow posted this diagnostic instead of silently skipping comments.", "", f"OpenCode model: `{model}`", f"Commit: `{short_sha}`", f"OpenCode exit status: `{status}`", f"Reason: {reason}", ] stderr_text = stderr.strip() stdout_text = stdout.strip() if stderr_text: lines.extend(["", "Recent stderr:", "```text", stderr_text[-1800:], "```"]) elif stdout_text: lines.extend(["", "Recent stdout:", "```text", stdout_text[-1800:], "```"]) return "\n".join(lines) def no_findings_body() -> str: short_sha = required_env("HEAD_SHA")[:12] model = required_env("OPENCODE_MODEL") return ( f"{NO_FINDINGS_MARKER}\n" "**OpenCode review complete**\n\n" f"OpenCode model: `{model}`\n\n" f"No suggestions found for commit `{short_sha}`." ) def fallback_body(rejected: list[dict[str, Any]], overflow: list[dict[str, Any]]) -> tuple[str, int, int]: model = required_env("OPENCODE_MODEL") short_sha = required_env("HEAD_SHA")[:12] body_limit = MAX_COMMENT_BODY - OMISSION_NOTICE_RESERVE body = "\n".join( [ FALLBACK_MARKER, "**OpenCode findings that could not be posted inline**", "", f"OpenCode model: `{model}`", f"Commit: `{short_sha}`", "", "Some findings could not be posted inline, so they are preserved here.", ] ) included = 0 omitted = 0 def append_section(title: str, findings: list[dict[str, Any]]) -> None: nonlocal body, included, omitted if not findings: return heading = f"\n\n## {title}" if len(body) + len(heading) > body_limit: omitted += len(findings) return body += heading for finding in findings: section = "\n\n".join( [ "", f"### {finding['path']}:{finding['line']} - {finding['title']}", str(finding["body"]).strip(), ] ) if len(body) + len(section) > body_limit: omitted += 1 continue body += section included += 1 append_section("Inline placement unavailable", rejected) append_section("Additional findings beyond inline comment limit", overflow) if omitted: notice = f"\n\n_Additional fallback findings omitted from this comment due to GitHub body size limits: {omitted}._" if len(notice) > OMISSION_NOTICE_RESERVE: notice = notice[: OMISSION_NOTICE_RESERVE - 3].rstrip() + "..." body += notice return body, included, omitted def parse_commentable_right_lines(patch: str) -> dict[str, set[int]]: commentable: dict[str, set[int]] = {} path: str | None = None next_right_line: int | None = None for line in patch.splitlines(): if line.startswith("diff --git "): path = None next_right_line = None continue if line.startswith("+++ b/"): path = line[len("+++ b/") :] commentable.setdefault(path, set()) continue if line.startswith("@@ "): match = re.search(r"\+(\d+)(?:,(\d+))?", line) next_right_line = int(match.group(1)) if match else None continue if path is None or next_right_line is None: continue if line.startswith("+") and not line.startswith("+++ "): commentable.setdefault(path, set()).add(next_right_line) next_right_line += 1 continue if line.startswith("-"): continue if line.startswith("\\"): continue commentable.setdefault(path, set()).add(next_right_line) next_right_line += 1 return commentable def normalize_finding(value: Any, changed_paths: set[str]) -> dict[str, Any] | None: if not isinstance(value, dict): return None path = value.get("path") line = value.get("line") title = value.get("title") body = value.get("body") if not isinstance(path, str): print(f"Skipped OpenCode finding with invalid path value: {path}") return None if isinstance(line, bool): print(f"Skipped OpenCode finding with invalid line value: {path}:{line}") return None if isinstance(line, float) and line.is_integer(): line = int(line) if not isinstance(line, int): print(f"Skipped OpenCode finding with invalid line value: {path}:{line}") return None if not isinstance(title, str) or not isinstance(body, str): return None if path not in changed_paths: print(f"Skipped OpenCode finding outside diff scope: {path}:{line}") return None return { "path": path, "line": line, "title": title.strip(), "body": body.strip(), } def post_inline_finding(finding: dict[str, Any]) -> tuple[bool, str]: repository = required_env("GITHUB_REPOSITORY") pr_number = required_env("PR_NUMBER") head_sha = required_env("HEAD_SHA") comment = f"**OpenCode: {finding['title']}**\n\n{finding['body']}"[:4000] result = gh_api( [ "-X", "POST", f"repos/{repository}/pulls/{pr_number}/comments", "-f", f"body={comment}", "-f", f"commit_id={head_sha}", "-f", f"path={finding['path']}", "-F", f"line={finding['line']}", "-f", "side=RIGHT", ] ) if result.returncode == 0: return True, "" return False, result.stderr.strip() def handle_diagnostic( args: argparse.Namespace, reason: str, status: int, stdout: str, stderr: str, raw: str, *, fail_check: bool, ) -> int: append_summary( args.summary, "OpenCode review diagnostic", [ f"OpenCode status: `{status}`", f"Reason: {reason}", "", *snippet("OpenCode stdout", stdout), *snippet("OpenCode stderr", stderr), *snippet("OpenCode findings output", raw), ], ) upsert_issue_comment(DIAGNOSTIC_MARKER, diagnostic_body(reason, status, stdout, stderr)) print(f"OpenCode did not produce parseable findings: {reason}", file=sys.stderr) return 1 if fail_check else 0 def post_findings(args: argparse.Namespace) -> int: raw = args.output.read_text(encoding="utf-8") stdout = args.stdout.read_text(encoding="utf-8") if args.stdout.exists() else raw stderr = args.stderr.read_text(encoding="utf-8") if args.stderr.exists() else "" status = read_status(args.opencode_status) if status != 0: return handle_diagnostic( args, f"OpenCode exited with status {status}.", status, stdout, stderr, raw, fail_check=True, ) findings, error = parse_findings(raw) if error is not None or findings is None: return handle_diagnostic( args, error or "Unknown parser error.", status, stdout, stderr, raw, fail_check=False, ) if not findings: posted_no_findings = upsert_issue_comment(NO_FINDINGS_MARKER, no_findings_body()) append_summary( args.summary, "OpenCode review", [ "OpenCode returned a valid empty findings array.", "", *snippet("OpenCode stdout", stdout), *snippet("OpenCode stderr", stderr), ], ) return 0 if posted_no_findings else 1 changed_paths = set(args.files.read_text(encoding="utf-8").splitlines()) commentable_lines = None patch_filter_warning = "" if args.patch is not None: try: commentable_lines = parse_commentable_right_lines(args.patch.read_text(encoding="utf-8")) except OSError as exc: patch_filter_warning = f"Could not read OpenCode review patch for inline filtering: {exc}" print(patch_filter_warning, file=sys.stderr) posted = 0 rejected = [] valid_findings = [ finding for finding in (normalize_finding(item, changed_paths) for item in findings) if finding is not None ] prefiltered = [] inline_candidates = [] for finding in valid_findings: if commentable_lines is not None and finding["line"] not in commentable_lines.get(finding["path"], set()): print( f"Skipped OpenCode inline finding outside commentable diff lines: {finding['path']}:{finding['line']}", file=sys.stderr, ) prefiltered.append(finding) continue inline_candidates.append(finding) inline_findings = inline_candidates[:MAX_INLINE_FINDINGS] overflow_findings = inline_candidates[MAX_INLINE_FINDINGS:] for finding in inline_findings: ok, error_message = post_inline_finding(finding) if ok: posted += 1 else: print( f"GitHub rejected OpenCode inline finding for {finding['path']}:{finding['line']}: {error_message}", file=sys.stderr, ) rejected.append(finding) if overflow_findings: print(f"Preserving {len(overflow_findings)} OpenCode findings beyond inline limit in fallback comment.") posted_fallback = True fallback_included = 0 fallback_omitted = 0 unplaced_findings = prefiltered + rejected if unplaced_findings or overflow_findings: fallback_comment, fallback_included, fallback_omitted = fallback_body(unplaced_findings, overflow_findings) posted_fallback = upsert_issue_comment(FALLBACK_MARKER, fallback_comment) summary_lines = [ f"Parsed findings: `{len(findings)}`", f"Inline comments posted: `{posted}`", f"Inline comments skipped before posting: `{len(prefiltered)}`", f"Inline comments rejected: `{len(rejected)}`", f"Findings beyond inline limit: `{len(overflow_findings)}`", f"Fallback findings included in comment: `{fallback_included}`", f"Fallback findings omitted from comment: `{fallback_omitted}`", ] if patch_filter_warning: summary_lines.append(f"Patch filtering warning: {patch_filter_warning}") summary_lines.extend(["", *snippet("OpenCode stdout", stdout), *snippet("OpenCode stderr", stderr)]) append_summary(args.summary, "OpenCode review", summary_lines) print(f"Posted {posted} OpenCode inline comments; preserved {fallback_included} fallback findings.") return 0 if posted_fallback else 1 def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="OpenCode PR review workflow helpers") subparsers = parser.add_subparsers(dest="command", required=True) post = subparsers.add_parser("post-findings") post.add_argument("--output", type=Path, required=True) post.add_argument("--files", type=Path, required=True) post.add_argument("--opencode-status", type=Path, required=True) post.add_argument("--stdout", type=Path, required=True) post.add_argument("--stderr", type=Path, required=True) post.add_argument("--summary", type=Path, required=True) post.add_argument("--patch", type=Path) post.set_defaults(func=post_findings) return parser def main() -> int: parser = build_parser() args = parser.parse_args() try: return args.func(args) except RuntimeError as exc: print(str(exc), file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main())