367 lines
15 KiB
YAML
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
|
|
});
|