Files
kandev/scripts/opencode-code-review
T
2026-06-18 19:57:40 +01:00

372 lines
12 KiB
Python
Executable File

#!/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
NO_FINDINGS_MARKER = "<!-- opencode-review:no-findings -->"
DIAGNOSTIC_MARKER = "<!-- opencode-review:diagnostic -->"
FALLBACK_MARKER = "<!-- opencode-review:fallback-findings -->"
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")
return subprocess.run(
[gh_bin, "api", *args],
text=True,
capture_output=True,
check=False,
)
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"<opencode_findings>\s*(.*?)\s*</opencode_findings>", raw, re.S)
if not match:
return None, "No parseable findings block was found."
try:
payload = json.loads(match.group(1))
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 review check failed 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 = "\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) > MAX_COMMENT_BODY:
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) > MAX_COMMENT_BODY:
omitted += 1
continue
body += section
included += 1
append_section("GitHub rejected inline placement", 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(body) + len(notice) <= MAX_COMMENT_BODY:
body += notice
return body, included, omitted
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) or not isinstance(line, int):
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) -> 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
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)
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)
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())
posted = 0
rejected = []
valid_findings = [
finding
for finding in (normalize_finding(item, changed_paths) for item in findings)
if finding is not None
]
inline_findings = valid_findings[:MAX_INLINE_FINDINGS]
overflow_findings = valid_findings[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
if rejected or overflow_findings:
fallback_comment, fallback_included, fallback_omitted = fallback_body(rejected, overflow_findings)
posted_fallback = upsert_issue_comment(FALLBACK_MARKER, fallback_comment)
append_summary(
args.summary,
"OpenCode review",
[
f"Parsed findings: `{len(findings)}`",
f"Inline comments posted: `{posted}`",
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}`",
"",
*snippet("OpenCode stdout", stdout),
*snippet("OpenCode stderr", stderr),
],
)
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.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())