Files
2026-07-13 13:22:34 +08:00

367 lines
15 KiB
YAML

name: review
on:
issue_comment:
types: [created]
pull_request:
paths:
- ".github/workflows/review.yml"
- ".claude/skills/pr-review/**"
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.event.issue.number }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
defaults:
run:
shell: bash
permissions: {}
jobs:
# Auth gate: only users with admin or maintain role on the repo can trigger
# the review (plus the Copilot bot). For issue_comment, BOTH the PR author
# and the commenter are checked.
gate:
runs-on: ubuntu-slim
timeout-minutes: 5
permissions:
contents: read
# Cheap author_association prefilter so random users can't spawn workflow
# runs. The gate step below does the authoritative admin/maintain role
# check.
if: >
(github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.pull_request.draft == false &&
(contains(fromJson('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association) ||
(github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot')))
||
(github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
(
contains(fromJson('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.issue.author_association) ||
(github.event.issue.user.login == 'Copilot' && github.event.issue.user.type == 'Bot')
) &&
contains(fromJson('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) &&
startsWith(github.event.comment.body, '/review') &&
!startsWith(github.event.comment.body, '/review-'))
steps:
- name: Check user permission
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
EVENT_NAME: ${{ github.event_name }}
PR_AUTHOR: ${{ github.event.pull_request.user.login || github.event.issue.user.login }}
PR_AUTHOR_TYPE: ${{ github.event.pull_request.user.type || github.event.issue.user.type }}
COMMENTER: ${{ github.event.comment.user.login }}
run: |
check_user() {
local user=$1
local type=$2
if [ "$user" = "Copilot" ] && [ "$type" = "Bot" ]; then
echo "Allowing Copilot bot: $user"
return 0
fi
local role
role=$(gh api "repos/$REPO/collaborators/$user/permission" --jq .role_name) \
|| { echo "Failed to fetch permission for $user"; exit 1; }
case "$role" in
admin|maintain)
echo "User $user is allowed (admin or maintain role)"
;;
*)
echo "User $user is not allowed (admin or maintain role required)"
exit 1
;;
esac
}
check_user "$PR_AUTHOR" "$PR_AUTHOR_TYPE"
if [ "$EVENT_NAME" = "issue_comment" ]; then
check_user "$COMMENTER" "User"
fi
review:
needs: gate
runs-on: ubuntu-latest
# GITHUB_TOKEN is unused — all GitHub API calls go through the app tokens
# minted below. Grant nothing so any future accidental use of GITHUB_TOKEN
# fails closed.
permissions: {}
timeout-minutes: 30
steps:
# Two app tokens with split scopes:
# - app-token-read (read-only): used by the Claude step so prompt-injection
# in a diff can't trigger comments, approvals, or any PR mutation.
# - app-token-write (write): used by React/Post/Report (code we control).
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
id: app-token-read
with:
client-id: ${{ secrets.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
permission-contents: read
permission-pull-requests: read
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
if: github.event_name == 'issue_comment'
id: app-token-write
with:
client-id: ${{ secrets.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
permission-actions: read
permission-contents: read
permission-pull-requests: write
- name: Resolve job URL
if: github.event_name == 'issue_comment'
env:
GH_TOKEN: ${{ steps.app-token-write.outputs.token }}
REPO: ${{ github.repository }}
RUN_ID: ${{ github.run_id }}
RUN_ATTEMPT: ${{ github.run_attempt }}
PR_NUMBER: ${{ github.event.issue.number }}
run: |
# first(...) // empty: pick exactly one id, or output nothing if no match.
JOB_ID=$(gh api "repos/$REPO/actions/runs/$RUN_ID/attempts/$RUN_ATTEMPT/jobs" \
--jq 'first(.jobs[] | select(.name == "review") | .id) // empty')
# Fall back to the workflow-run URL so downstream links are never malformed.
if [ -n "$JOB_ID" ]; then
JOB_RUN_URL="https://github.com/$REPO/actions/runs/$RUN_ID/job/$JOB_ID?pr=$PR_NUMBER"
else
JOB_RUN_URL="https://github.com/$REPO/actions/runs/$RUN_ID?pr=$PR_NUMBER"
fi
echo "JOB_RUN_URL=$JOB_RUN_URL" >> "$GITHUB_ENV"
- name: React to comment
if: github.event_name == 'issue_comment'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.app-token-write.outputs.token }}
retries: 3
script: |
const { comment } = context.payload;
const message = `🚀 [Review running...](${process.env.JOB_RUN_URL})`;
const updatedBody = `${comment.body}\n\n---\n\n${message}`;
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: comment.id,
body: updatedBody,
});
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.app-token-read.outputs.token }}
ref: refs/pull/${{ github.event.pull_request.number || github.event.issue.number }}/merge
# Depth 2 so HEAD^1 (the merge ref's base parent) is reachable, letting
# the pr-review skill read pre-change content via `git show HEAD^1:<path>`.
fetch-depth: 2
- uses: ./.github/actions/setup-python
with:
pin-micro-version: false
- name: Install Claude CLI
run: |
.claude/scripts/install-claude.sh
- name: Verify Claude CLI
run: |
claude --version
- name: Parse review arguments
id: prompt
env:
COMMENT_BODY: ${{ github.event_name == 'issue_comment' && github.event.comment.body || '' }}
LABELS_JSON: ${{ toJson(github.event.pull_request.labels || github.event.issue.labels) }}
run: |
cat > /tmp/parse_args.py <<'EOF'
import argparse
import json
import os
import sys
MODEL_CHOICES = [
"fable",
"opus",
"sonnet",
"haiku",
"claude-fable-5",
"claude-opus-4-7",
"claude-sonnet-4-6",
"claude-haiku-4-5",
]
EFFORT_CHOICES = ["low", "medium", "high", "xhigh", "max"]
labels = {l["name"] for l in json.loads(os.environ["LABELS_JSON"])}
big = bool(labels & {"size/M", "size/L", "size/XL"})
default_model = "claude-opus-4-7" if big else "claude-sonnet-4-6"
body = sys.stdin.read().removeprefix("/review")
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument("-m", "--model", choices=MODEL_CHOICES, default=default_model)
parser.add_argument("-e", "--effort", choices=EFFORT_CHOICES, default="high")
args = parser.parse_args(body.split())
sys.stdout.write(f"model={args.model}\neffort={args.effort}\n")
EOF
echo "$COMMENT_BODY" | python /tmp/parse_args.py >> "$GITHUB_OUTPUT"
- name: Review
id: review
env:
# Read-only token: Claude can fetch PR/diff/comments but cannot post anything.
GH_TOKEN: ${{ steps.app-token-read.outputs.token }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
MODEL: ${{ steps.prompt.outputs.model }}
EFFORT: ${{ steps.prompt.outputs.effort }}
run: |
OUTPUT_FILE="/tmp/output.jsonl"
# Both event paths run /pr-review end-to-end against the PR; on
# pull_request (smoke), Post is gated off so no review is submitted.
EXIT_CODE=0
timeout 20m claude \
--model "$MODEL" \
--effort "$EFFORT" \
--max-budget-usd 5 \
--print \
--verbose \
--output-format stream-json \
"/pr-review $REPO $PR_NUMBER" \
| .claude/scripts/stream.sh "$OUTPUT_FILE" || EXIT_CODE=$?
if [ "${EXIT_CODE:-0}" -eq 124 ]; then
echo "Warning: Claude command timed out after 20 minutes"
elif [ "${EXIT_CODE:-0}" -ne 0 ]; then
echo "Error: Claude command failed with exit code $EXIT_CODE"
cat "$OUTPUT_FILE"
exit $EXIT_CODE
fi
- name: Validate review payload
run: |
if [ ! -f /tmp/review-payload.json ]; then
echo "::error::Claude did not produce /tmp/review-payload.json"
exit 1
fi
uv run --frozen --package skills skills validate-review /tmp/review-payload.json
echo "::group::Review payload"
jq . /tmp/review-payload.json
echo "::endgroup::"
- name: Post review
if: github.event_name == 'issue_comment'
env:
GH_TOKEN: ${{ steps.app-token-write.outputs.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.issue.number }}
run: |
gh api --method POST repos/"$REPO"/pulls/"$PR_NUMBER"/reviews --input /tmp/review-payload.json
- name: Redact secrets from transcript
if: always()
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GH_TOKEN_READ: ${{ steps.app-token-read.outputs.token }}
GH_TOKEN_WRITE: ${{ steps.app-token-write.outputs.token }}
run: |
python <<'PY'
import os
import sys
from pathlib import Path
path = Path("/tmp/output.jsonl")
if not path.exists():
sys.exit(0)
data = path.read_text()
for name in ("ANTHROPIC_API_KEY", "GH_TOKEN_READ", "GH_TOKEN_WRITE"):
if val := os.environ.get(name):
data = data.replace(val, "***")
path.write_text(data)
PY
- name: Upload Claude stream transcript
if: always()
continue-on-error: true
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: claude-stream-${{ github.run_id }}-${{ github.run_attempt }}
path: /tmp/output.jsonl
retention-days: 1
if-no-files-found: ignore
- name: Report review results
if: always() && github.event_name == 'issue_comment'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
JOB_STATUS: ${{ job.status }}
MODEL: ${{ steps.prompt.outputs.model }}
with:
github-token: ${{ steps.app-token-write.outputs.token }}
retries: 3
script: |
const fs = require('fs');
const { owner, repo } = context.repo;
const { comment } = context.payload;
const jobStatus = process.env.JOB_STATUS;
const model = process.env.MODEL;
// Read Claude output from file instead of environment variable
const outputPath = '/tmp/output.jsonl';
let claudeOutput = '';
try {
if (fs.existsSync(outputPath)) {
claudeOutput = fs.readFileSync(outputPath, 'utf8');
}
} catch (e) {
console.log('Failed to read Claude output file:', e);
}
let resultEvent = null;
if (claudeOutput) {
try {
const events = claudeOutput.trim().split('\n').filter(Boolean).map(JSON.parse);
resultEvent = events.findLast(({ type }) => type === 'result');
} catch (e) {
console.log('Failed to parse Claude output as JSON:', e);
}
}
const jobRunUrl = process.env.JOB_RUN_URL;
const failed = jobStatus !== 'success' || resultEvent?.is_error;
const icon = failed ? '❌' : '✅';
const verb = failed ? 'failed' : 'completed';
let resultLine = `${icon} [Review](${jobRunUrl}) ${verb}`;
if (resultEvent) {
const seconds = (resultEvent.duration_ms / 1000).toFixed(1);
const tokens = (resultEvent.usage?.input_tokens ?? 0) + (resultEvent.usage?.output_tokens ?? 0);
const cost = (Math.round(resultEvent.total_cost_usd * 100) / 100).toFixed(2);
resultLine += ` (${model}, ${seconds}s, ${resultEvent.num_turns} turns, ${tokens} tokens, $${cost})`;
const summary = resultEvent.is_error ? 'Error' : 'Result';
const formatted = JSON.stringify(resultEvent, null, 2);
resultLine += `\n<details><summary>${summary}</summary>\n\n\`\`\`json\n${formatted}\n\`\`\`\n\n</details>`;
if (resultEvent.is_error && /529|Overloaded/i.test(resultEvent.result || '')) {
resultLine += `\n\nThe Claude API is overloaded. Check [status.claude.com](https://status.claude.com/) and retry.`;
}
} else if (failed) {
resultLine += `\n\nSee [workflow logs](${jobRunUrl}) for details.`;
}
console.log('Result line to post:', resultLine);
const { data: currentComment } = await github.rest.issues.getComment({
owner,
repo,
comment_id: comment.id,
});
const originalBody = currentComment.body.split('\n\n---\n\n🚀 ')[0];
const updatedBody = `${originalBody}\n\n---\n\n${resultLine}`;
await github.rest.issues.updateComment({
owner,
repo,
comment_id: comment.id,
body: updatedBody
});