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
|
||||
|
||||
Reference in New Issue
Block a user