ci: harden opencode pr review workflow (#1434)
This commit is contained in:
@@ -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
|
||||
|
||||
Executable
+371
@@ -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())
|
||||
Executable
+238
@@ -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()
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user