ci: harden opencode pr review workflow (#1434)

This commit is contained in:
Carlos Florêncio
2026-06-18 19:57:40 +01:00
committed by GitHub
parent 2c4887a3ee
commit 2560f194ee
4 changed files with 733 additions and 277 deletions
+85 -268
View File
@@ -51,6 +51,10 @@ jobs:
run: |
set -euo pipefail
REVIEW_DIR=.opencode-review
rm -rf "$REVIEW_DIR"
mkdir -p "$REVIEW_DIR"
if [[ "$ACTION" == "synchronize" ]] &&
[[ -n "${BEFORE:-}" ]] &&
[[ "$BEFORE" != "0000000000000000000000000000000000000000" ]] &&
@@ -62,14 +66,14 @@ jobs:
MODE="full-pr"
fi
git diff --find-renames --find-copies --unified=80 "$RANGE" > /tmp/opencode-review.patch
git diff --name-only "$RANGE" > /tmp/opencode-review-files.txt
git diff --find-renames --find-copies --unified=80 "$RANGE" > "$REVIEW_DIR/review.patch"
git diff --name-only "$RANGE" > "$REVIEW_DIR/files.txt"
{
echo "Review mode: $MODE"
echo "Diff range: $RANGE"
echo
echo "Changed files:"
sed 's/^/- /' /tmp/opencode-review-files.txt
sed 's/^/- /' "$REVIEW_DIR/files.txt"
echo
echo "Repository review guidelines from base commit:"
echo
@@ -78,7 +82,7 @@ jobs:
echo "Code review skill from base commit:"
echo
git show "$BASE_SHA:.agents/skills/code-review/SKILL.md" 2>/dev/null | sed -n '1,220p' || echo "(code-review skill not present in base commit)"
} > /tmp/opencode-review-guidelines.md
} > "$REVIEW_DIR/guidelines.md"
{
echo "mode=$MODE"
@@ -110,6 +114,9 @@ jobs:
run: |
set -euo pipefail
REVIEW_DIR=.opencode-review
mkdir -p "$REVIEW_DIR"
rm -rf .opencode
mkdir -p .opencode/agents
cat > .opencode/agents/github-pr-review.md <<'EOF'
---
@@ -155,15 +162,28 @@ jobs:
- Use an empty array when there are no high-confidence findings.
EOF
set +e
"$HOME/.opencode/bin/opencode" run \
--agent github-pr-review \
--model "$OPENCODE_MODEL" \
--title "PR #${{ github.event.pull_request.number }} OpenCode review" \
--file /tmp/opencode-review-guidelines.md \
--file /tmp/opencode-review.patch \
--file "$REVIEW_DIR/guidelines.md" \
--file "$REVIEW_DIR/review.patch" \
-- \
"Review PR #${{ github.event.pull_request.number }} in ${REVIEW_MODE} mode. The attached patch is the review scope. Read repository files only when needed to understand architecture around the patched files. Return only the requested JSON findings block; a trusted workflow step will publish inline comments." \
> /tmp/opencode-review-output.txt
> "$REVIEW_DIR/opencode.stdout" \
2> "$REVIEW_DIR/opencode.stderr"
opencode_status=$?
set -e
cp "$REVIEW_DIR/opencode.stdout" "$REVIEW_DIR/output.txt"
printf '%s\n' "$opencode_status" > "$REVIEW_DIR/opencode.status"
{
echo "## OpenCode command"
echo
echo "OpenCode exit status: \`$opencode_status\`"
echo "Output files are preserved under \`$REVIEW_DIR/\`."
} >> "$GITHUB_STEP_SUMMARY"
- name: Post OpenCode findings
env:
@@ -171,137 +191,25 @@ jobs:
GITHUB_REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
OPENCODE_MODEL: ${{ env.OPENCODE_MODEL }}
run: |
set -euo pipefail
python3 <<'PY'
import json
import os
import re
import subprocess
from pathlib import Path
python3 scripts/opencode-code-review post-findings \
--output .opencode-review/output.txt \
--files .opencode-review/files.txt \
--opencode-status .opencode-review/opencode.status \
--stdout .opencode-review/opencode.stdout \
--stderr .opencode-review/opencode.stderr \
--summary "$GITHUB_STEP_SUMMARY"
raw = Path("/tmp/opencode-review-output.txt").read_text()
changed_paths = set(Path("/tmp/opencode-review-files.txt").read_text().splitlines())
match = re.search(r"<opencode_findings>\s*(\[.*?\])\s*</opencode_findings>", raw, re.S)
if not match:
print("OpenCode did not return a valid findings block; skipping comments.")
raise SystemExit(0)
try:
findings = json.loads(match.group(1))
except json.JSONDecodeError as exc:
print(f"OpenCode returned invalid findings JSON: {exc}")
raise SystemExit(0)
if not isinstance(findings, list):
print("OpenCode findings payload was not a JSON array; skipping comments.")
raise SystemExit(0)
if not findings:
marker = "<!-- opencode-review:no-findings -->"
short_sha = os.environ["HEAD_SHA"][:12]
model = os.environ["OPENCODE_MODEL"]
body = (
f"{marker}\n"
"**OpenCode review complete**\n\n"
f"OpenCode model: `{model}`\n\n"
f"No suggestions found for commit `{short_sha}`."
)
comments_result = subprocess.run(
[
"gh", "api", "--paginate",
f"repos/{os.environ['GITHUB_REPOSITORY']}/issues/{os.environ['PR_NUMBER']}/comments?per_page=100",
],
text=True,
capture_output=True,
check=False,
)
comment_id = None
if comments_result.returncode == 0:
try:
comments = json.loads(comments_result.stdout)
except json.JSONDecodeError as exc:
print(f"Could not parse existing PR comments: {exc}")
comments = []
for comment in reversed(comments):
if marker in (comment.get("body") or ""):
comment_id = comment.get("id")
break
else:
print(f"Could not list existing PR comments: {comments_result.stderr.strip()}")
method = "PATCH" if comment_id else "POST"
endpoint = (
f"repos/{os.environ['GITHUB_REPOSITORY']}/issues/comments/{comment_id}"
if comment_id
else f"repos/{os.environ['GITHUB_REPOSITORY']}/issues/{os.environ['PR_NUMBER']}/comments"
)
result = subprocess.run(
[
"gh", "api", "-X", method,
endpoint,
"-f", f"body={body}",
],
text=True,
capture_output=True,
check=False,
)
if result.returncode == 0:
action = "Updated" if comment_id else "Posted"
print(f"{action} OpenCode no-suggestions comment.")
else:
print(f"Skipped OpenCode no-suggestions comment: {result.stderr.strip()}")
raise SystemExit(0)
posted = 0
for finding in findings[:20]:
if not isinstance(finding, dict):
continue
path = finding.get("path")
line = finding.get("line")
title = finding.get("title")
body = finding.get("body")
if not isinstance(path, str) or not isinstance(line, int):
continue
if not isinstance(title, str) or not isinstance(body, str):
continue
if path not in changed_paths:
print(f"Skipped OpenCode finding outside diff scope: {path}:{line}")
continue
comment = f"**OpenCode: {title.strip()}**\n\n{body.strip()}"[:4000]
result = subprocess.run(
[
"gh", "api", "-X", "POST",
f"repos/{os.environ['GITHUB_REPOSITORY']}/pulls/{os.environ['PR_NUMBER']}/comments",
"-f", f"body={comment}",
"-f", f"commit_id={os.environ['HEAD_SHA']}",
"-f", f"path={path}",
"-F", f"line={line}",
"-f", "side=RIGHT",
],
text=True,
capture_output=True,
check=False,
)
if result.returncode == 0:
posted += 1
else:
print(f"Skipped OpenCode finding for {path}:{line}: {result.stderr.strip()}")
print(f"Posted {posted} OpenCode inline comments.")
PY
- name: Summarize OpenCode review
run: |
set -euo pipefail
{
echo "## OpenCode review"
echo
sed -n '1,180p' /tmp/opencode-review-output.txt
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload OpenCode review artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: opencode-review-same-repo-${{ github.event.pull_request.number }}
path: .opencode-review/
if-no-files-found: ignore
# Strip safe-to-review label on new pushes to fork PRs.
# Together with the `github.event.action != 'synchronize'` guard on the
@@ -371,6 +279,9 @@ jobs:
run: |
set -euo pipefail
REVIEW_DIR=.opencode-review
rm -rf "$REVIEW_DIR"
mkdir -p "$REVIEW_DIR"
git fetch --no-tags --prune --depth=1 origin "$BASE_SHA"
if [[ "$ACTION" == "synchronize" ]] &&
@@ -384,14 +295,14 @@ jobs:
MODE="full-pr"
fi
git diff --find-renames --find-copies --unified=80 "$RANGE" > /tmp/opencode-review.patch
git diff --name-only "$RANGE" > /tmp/opencode-review-files.txt
git diff --find-renames --find-copies --unified=80 "$RANGE" > "$REVIEW_DIR/review.patch"
git diff --name-only "$RANGE" > "$REVIEW_DIR/files.txt"
{
echo "Review mode: $MODE"
echo "Diff range: $RANGE"
echo
echo "Changed files:"
sed 's/^/- /' /tmp/opencode-review-files.txt
sed 's/^/- /' "$REVIEW_DIR/files.txt"
echo
echo "Repository review guidelines from base commit:"
echo
@@ -400,7 +311,7 @@ jobs:
echo "Code review skill from base commit:"
echo
git show "$BASE_SHA:.agents/skills/code-review/SKILL.md" 2>/dev/null | sed -n '1,220p' || echo "(code-review skill not present in base commit)"
} > /tmp/opencode-review-guidelines.md
} > "$REVIEW_DIR/guidelines.md"
{
echo "mode=$MODE"
@@ -432,6 +343,9 @@ jobs:
run: |
set -euo pipefail
REVIEW_DIR=.opencode-review
mkdir -p "$REVIEW_DIR"
rm -rf .opencode
mkdir -p .opencode/agents
cat > .opencode/agents/github-pr-review.md <<'EOF'
---
@@ -477,15 +391,28 @@ jobs:
- Use an empty array when there are no high-confidence findings.
EOF
set +e
"$HOME/.opencode/bin/opencode" run \
--agent github-pr-review \
--model "$OPENCODE_MODEL" \
--title "PR #${{ github.event.pull_request.number }} OpenCode review" \
--file /tmp/opencode-review-guidelines.md \
--file /tmp/opencode-review.patch \
--file "$REVIEW_DIR/guidelines.md" \
--file "$REVIEW_DIR/review.patch" \
-- \
"Review PR #${{ github.event.pull_request.number }} in ${REVIEW_MODE} mode. The attached patch is the review scope. Read repository files only when needed to understand architecture around the patched files. Return only the requested JSON findings block; a trusted workflow step will publish inline comments." \
> /tmp/opencode-review-output.txt
> "$REVIEW_DIR/opencode.stdout" \
2> "$REVIEW_DIR/opencode.stderr"
opencode_status=$?
set -e
cp "$REVIEW_DIR/opencode.stdout" "$REVIEW_DIR/output.txt"
printf '%s\n' "$opencode_status" > "$REVIEW_DIR/opencode.status"
{
echo "## OpenCode command"
echo
echo "OpenCode exit status: \`$opencode_status\`"
echo "Output files are preserved under \`$REVIEW_DIR/\`."
} >> "$GITHUB_STEP_SUMMARY"
- name: Post OpenCode findings
env:
@@ -493,134 +420,24 @@ jobs:
GITHUB_REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
OPENCODE_MODEL: ${{ env.OPENCODE_MODEL }}
run: |
set -euo pipefail
python3 <<'PY'
import json
import os
import re
import subprocess
from pathlib import Path
git show "$BASE_SHA:scripts/opencode-code-review" > .opencode-review/opencode-code-review
python3 .opencode-review/opencode-code-review post-findings \
--output .opencode-review/output.txt \
--files .opencode-review/files.txt \
--opencode-status .opencode-review/opencode.status \
--stdout .opencode-review/opencode.stdout \
--stderr .opencode-review/opencode.stderr \
--summary "$GITHUB_STEP_SUMMARY"
raw = Path("/tmp/opencode-review-output.txt").read_text()
changed_paths = set(Path("/tmp/opencode-review-files.txt").read_text().splitlines())
match = re.search(r"<opencode_findings>\s*(\[.*?\])\s*</opencode_findings>", raw, re.S)
if not match:
print("OpenCode did not return a valid findings block; skipping comments.")
raise SystemExit(0)
try:
findings = json.loads(match.group(1))
except json.JSONDecodeError as exc:
print(f"OpenCode returned invalid findings JSON: {exc}")
raise SystemExit(0)
if not isinstance(findings, list):
print("OpenCode findings payload was not a JSON array; skipping comments.")
raise SystemExit(0)
if not findings:
marker = "<!-- opencode-review:no-findings -->"
short_sha = os.environ["HEAD_SHA"][:12]
model = os.environ["OPENCODE_MODEL"]
body = (
f"{marker}\n"
"**OpenCode review complete**\n\n"
f"OpenCode model: `{model}`\n\n"
f"No suggestions found for commit `{short_sha}`."
)
comments_result = subprocess.run(
[
"gh", "api", "--paginate",
f"repos/{os.environ['GITHUB_REPOSITORY']}/issues/{os.environ['PR_NUMBER']}/comments?per_page=100",
],
text=True,
capture_output=True,
check=False,
)
comment_id = None
if comments_result.returncode == 0:
try:
comments = json.loads(comments_result.stdout)
except json.JSONDecodeError as exc:
print(f"Could not parse existing PR comments: {exc}")
comments = []
for comment in reversed(comments):
if marker in (comment.get("body") or ""):
comment_id = comment.get("id")
break
else:
print(f"Could not list existing PR comments: {comments_result.stderr.strip()}")
method = "PATCH" if comment_id else "POST"
endpoint = (
f"repos/{os.environ['GITHUB_REPOSITORY']}/issues/comments/{comment_id}"
if comment_id
else f"repos/{os.environ['GITHUB_REPOSITORY']}/issues/{os.environ['PR_NUMBER']}/comments"
)
result = subprocess.run(
[
"gh", "api", "-X", method,
endpoint,
"-f", f"body={body}",
],
text=True,
capture_output=True,
check=False,
)
if result.returncode == 0:
action = "Updated" if comment_id else "Posted"
print(f"{action} OpenCode no-suggestions comment.")
else:
print(f"Skipped OpenCode no-suggestions comment: {result.stderr.strip()}")
raise SystemExit(0)
posted = 0
for finding in findings[:20]:
if not isinstance(finding, dict):
continue
path = finding.get("path")
line = finding.get("line")
title = finding.get("title")
body = finding.get("body")
if not isinstance(path, str) or not isinstance(line, int):
continue
if not isinstance(title, str) or not isinstance(body, str):
continue
if path not in changed_paths:
print(f"Skipped OpenCode finding outside diff scope: {path}:{line}")
continue
comment = f"**OpenCode: {title.strip()}**\n\n{body.strip()}"[:4000]
result = subprocess.run(
[
"gh", "api", "-X", "POST",
f"repos/{os.environ['GITHUB_REPOSITORY']}/pulls/{os.environ['PR_NUMBER']}/comments",
"-f", f"body={comment}",
"-f", f"commit_id={os.environ['HEAD_SHA']}",
"-f", f"path={path}",
"-F", f"line={line}",
"-f", "side=RIGHT",
],
text=True,
capture_output=True,
check=False,
)
if result.returncode == 0:
posted += 1
else:
print(f"Skipped OpenCode finding for {path}:{line}: {result.stderr.strip()}")
print(f"Posted {posted} OpenCode inline comments.")
PY
- name: Summarize OpenCode review
run: |
set -euo pipefail
{
echo "## OpenCode review"
echo
sed -n '1,180p' /tmp/opencode-review-output.txt
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload OpenCode review artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: opencode-review-fork-${{ github.event.pull_request.number }}
path: .opencode-review/
if-no-files-found: ignore
+371
View File
@@ -0,0 +1,371 @@
#!/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())
+238
View File
@@ -0,0 +1,238 @@
#!/usr/bin/env python3
import json
import os
import re
import subprocess
import sys
import tempfile
import textwrap
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "scripts" / "opencode-code-review"
class OpenCodeReviewScriptTest(unittest.TestCase):
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.workdir = Path(self.tmp.name)
self.calls_path = self.workdir / "gh-calls.jsonl"
self.output_path = self.workdir / "output.txt"
self.files_path = self.workdir / "files.txt"
self.status_path = self.workdir / "status.txt"
self.stdout_path = self.workdir / "stdout.txt"
self.stderr_path = self.workdir / "stderr.txt"
self.summary_path = self.workdir / "summary.md"
self.files_path.write_text("src/app.ts\n", encoding="utf-8")
self.status_path.write_text("0\n", encoding="utf-8")
self.stdout_path.write_text("stdout line\n", encoding="utf-8")
self.stderr_path.write_text("stderr line\n", encoding="utf-8")
self.summary_path.write_text("", encoding="utf-8")
self.fake_gh = self.write_fake_gh()
def write_fake_gh(self) -> Path:
fake_gh = self.workdir / "gh"
fake_gh.write_text(
textwrap.dedent(
"""\
#!/usr/bin/env python3
import json
import os
import sys
from pathlib import Path
calls_path = Path(os.environ["GH_CALLS"])
args = sys.argv[1:]
calls_path.write_text(
calls_path.read_text() + json.dumps(args) + "\\n"
if calls_path.exists()
else json.dumps(args) + "\\n"
)
if "comments?per_page=100" in " ".join(args):
print("[]")
raise SystemExit(0)
joined = " ".join(args)
if "/pulls/" in joined and os.environ.get("INLINE_FAIL") == "1":
print("line is not part of the diff", file=sys.stderr)
raise SystemExit(1)
if "/issues/" in joined and os.environ.get("ISSUE_COMMENT_FAIL") == "1":
print("issue comments unavailable", file=sys.stderr)
raise SystemExit(1)
print("{}")
"""
),
encoding="utf-8",
)
fake_gh.chmod(0o755)
return fake_gh
def run_script(
self,
*,
output: str,
inline_fail: bool = False,
issue_comment_fail: bool = False,
) -> subprocess.CompletedProcess[str]:
self.output_path.write_text(output, encoding="utf-8")
env = {
**os.environ,
"GH_BIN": str(self.fake_gh),
"GH_CALLS": str(self.calls_path),
"GITHUB_REPOSITORY": "kdlbs/kandev",
"PR_NUMBER": "42",
"HEAD_SHA": "0123456789abcdef",
"OPENCODE_MODEL": "opencode-go/minimax-m3",
}
if inline_fail:
env["INLINE_FAIL"] = "1"
if issue_comment_fail:
env["ISSUE_COMMENT_FAIL"] = "1"
return subprocess.run(
[
sys.executable,
str(SCRIPT),
"post-findings",
"--output",
str(self.output_path),
"--files",
str(self.files_path),
"--opencode-status",
str(self.status_path),
"--stdout",
str(self.stdout_path),
"--stderr",
str(self.stderr_path),
"--summary",
str(self.summary_path),
],
text=True,
capture_output=True,
env=env,
check=False,
)
def read_calls(self) -> list[list[str]]:
if not self.calls_path.exists():
return []
return [json.loads(line) for line in self.calls_path.read_text().splitlines()]
def bodies(self) -> list[str]:
bodies = []
for call in self.read_calls():
for index, arg in enumerate(call):
if arg == "-f" and index + 1 < len(call) and call[index + 1].startswith("body="):
bodies.append(call[index + 1][len("body=") :])
return bodies
def test_missing_findings_block_fails_and_posts_diagnostic(self) -> None:
result = self.run_script(output="OpenCode refused to read external_directory (/tmp/*)\n")
self.assertNotEqual(result.returncode, 0)
self.assertIn("did not produce parseable findings", result.stderr)
self.assertTrue(
any("<!-- opencode-review:diagnostic -->" in body for body in self.bodies()),
"expected a stable diagnostic PR comment",
)
self.assertIn("No parseable findings block", self.summary_path.read_text())
def test_invalid_findings_json_fails_and_posts_diagnostic(self) -> None:
result = self.run_script(output="<opencode_findings>{}</opencode_findings>\n")
self.assertNotEqual(result.returncode, 0)
self.assertIn("did not produce parseable findings", result.stderr)
self.assertTrue(any("<!-- opencode-review:diagnostic -->" in body for body in self.bodies()))
self.assertIn("not an array", self.summary_path.read_text())
def test_valid_empty_array_posts_no_findings_comment(self) -> None:
result = self.run_script(output="<opencode_findings>[]</opencode_findings>\n")
self.assertEqual(result.returncode, 0, result.stderr)
bodies = self.bodies()
self.assertTrue(any("<!-- opencode-review:no-findings -->" in body for body in bodies))
self.assertFalse(any("<!-- opencode-review:diagnostic -->" in body for body in bodies))
def test_inline_failures_are_posted_as_one_fallback_comment(self) -> None:
result = self.run_script(
output=textwrap.dedent(
"""\
<opencode_findings>
[{"path":"src/app.ts","line":99,"title":"Bad line","body":"This line moved."}]
</opencode_findings>
"""
),
inline_fail=True,
)
self.assertEqual(result.returncode, 0, result.stderr)
bodies = self.bodies()
self.assertTrue(any("<!-- opencode-review:fallback-findings -->" in body for body in bodies))
self.assertTrue(any("src/app.ts:99" in body for body in bodies))
def test_inline_findings_beyond_limit_are_preserved_in_fallback_comment(self) -> None:
findings = [
{"path": "src/app.ts", "line": index + 1, "title": f"Finding {index + 1}", "body": "body"}
for index in range(30)
]
result = self.run_script(
output=f"<opencode_findings>{json.dumps(findings)}</opencode_findings>\n",
inline_fail=True,
)
self.assertEqual(result.returncode, 0, result.stderr)
bodies = self.bodies()
self.assertTrue(any("<!-- opencode-review:fallback-findings -->" in body for body in bodies))
self.assertTrue(any("src/app.ts:1" in body for body in bodies))
self.assertTrue(any("src/app.ts:30" in body for body in bodies))
self.assertTrue(any("## GitHub rejected inline placement" in body for body in bodies))
self.assertTrue(any("## Additional findings beyond inline comment limit" in body for body in bodies))
summary = self.summary_path.read_text()
self.assertIn("Inline comments rejected: `20`", summary)
self.assertIn("Findings beyond inline limit: `10`", summary)
self.assertIn("Fallback findings included in comment: `30`", summary)
self.assertIn("Fallback findings omitted from comment: `0`", summary)
def test_fallback_comment_reports_findings_omitted_by_body_limit(self) -> None:
findings = [
{"path": "src/app.ts", "line": index + 1, "title": f"Finding {index + 1}", "body": "x" * 4000}
for index in range(30)
]
result = self.run_script(
output=f"<opencode_findings>{json.dumps(findings)}</opencode_findings>\n",
inline_fail=True,
)
self.assertEqual(result.returncode, 0, result.stderr)
bodies = self.bodies()
self.assertTrue(any("Additional fallback findings omitted" in body for body in bodies))
summary = self.summary_path.read_text()
self.assertRegex(summary, r"Fallback findings included in comment: `1[0-9]`")
omitted_match = re.search(r"Fallback findings omitted from comment: `([1-9][0-9]*)`", summary)
self.assertIsNotNone(omitted_match, summary)
def test_fallback_comment_failure_fails_the_step(self) -> None:
result = self.run_script(
output=textwrap.dedent(
"""\
<opencode_findings>
[{"path":"src/app.ts","line":99,"title":"Bad line","body":"This line moved."}]
</opencode_findings>
"""
),
inline_fail=True,
issue_comment_fail=True,
)
self.assertNotEqual(result.returncode, 0)
self.assertIn("Could not post OpenCode comment", result.stderr)
if __name__ == "__main__":
unittest.main()
+39 -9
View File
@@ -16,7 +16,7 @@ fail() {
count_occurrences() {
local pattern=$1
local output
output="$(rg --fixed-strings --count-matches "$pattern" "$WORKFLOW" || true)"
output="$(rg --fixed-strings --count-matches -- "$pattern" "$WORKFLOW" || true)"
if [[ -z "$output" ]]; then
printf '0\n'
return
@@ -28,14 +28,44 @@ count_occurrences() {
awk -F: '{ print $2 }' <<<"$output"
}
model_reference_count="$(count_occurrences 'OpenCode model: `{model}`')"
if [[ "$model_reference_count" != "2" ]]; then
fail "OpenCode no-suggestions comments include the model in both workflow paths"
tmp_review_reference_count="$(count_occurrences '/tmp/opencode-review')"
if [[ "$tmp_review_reference_count" != "0" ]]; then
fail "OpenCode review artifacts are not written under /tmp"
fi
pass "OpenCode no-suggestions comments include the model in both workflow paths"
pass "OpenCode review artifacts are not written under /tmp"
paginated_comment_lookup_count="$(count_occurrences '"gh", "api", "--paginate",')"
if [[ "$paginated_comment_lookup_count" != "2" ]]; then
fail "OpenCode no-suggestions comment lookup paginates in both workflow paths"
relative_guidelines_count="$(count_occurrences '--file "$REVIEW_DIR/guidelines.md"')"
if [[ "$relative_guidelines_count" != "2" ]]; then
fail "OpenCode guidelines are passed as relative workspace files in both workflow paths"
fi
pass "OpenCode no-suggestions comment lookup paginates in both workflow paths"
pass "OpenCode guidelines are passed as relative workspace files in both workflow paths"
relative_patch_count="$(count_occurrences '--file "$REVIEW_DIR/review.patch"')"
if [[ "$relative_patch_count" != "2" ]]; then
fail "OpenCode patches are passed as relative workspace files in both workflow paths"
fi
pass "OpenCode patches are passed as relative workspace files in both workflow paths"
recreate_config_count="$(count_occurrences 'rm -rf .opencode')"
if [[ "$recreate_config_count" != "2" ]]; then
fail "OpenCode project config is recreated before writing the review agent"
fi
pass "OpenCode project config is recreated before writing the review agent"
explicit_model_env_count="$(count_occurrences 'OPENCODE_MODEL: ${{ env.OPENCODE_MODEL }}')"
if [[ "$explicit_model_env_count" != "2" ]]; then
fail "OpenCode posting steps explicitly pass OPENCODE_MODEL"
fi
pass "OpenCode posting steps explicitly pass OPENCODE_MODEL"
trusted_fork_script_count="$(count_occurrences 'git show "$BASE_SHA:scripts/opencode-code-review" > .opencode-review/opencode-code-review')"
if [[ "$trusted_fork_script_count" != "1" ]]; then
fail "Fork review executes the parser script from the trusted base commit"
fi
pass "Fork review executes the parser script from the trusted base commit"
artifact_upload_count="$(count_occurrences 'uses: actions/upload-artifact@v4')"
if [[ "$artifact_upload_count" != "2" ]]; then
fail "OpenCode review artifacts are uploaded from both workflow paths"
fi
pass "OpenCode review artifacts are uploaded from both workflow paths"