Skip to content

DO NOT MERGE: fix: add mechanism to force stop stuck emotes #7576

DO NOT MERGE: fix: add mechanism to force stop stuck emotes

DO NOT MERGE: fix: add mechanism to force stop stuck emotes #7576

name: Claude Review
on:
workflow_dispatch:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
pull_request:
types: [opened, synchronize, ready_for_review]
jobs:
auto-review:
if: |
github.event_name == 'pull_request' &&
github.event.pull_request.draft == false &&
!contains(github.event.pull_request.labels.*.name, 'no review') &&
!contains(github.event.pull_request.labels.*.name, 'auto-pr')
runs-on: ubuntu-latest
concurrency:
group: claude-auto-review-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
id-token: write
contents: read
pull-requests: write
issues: write
statuses: write
steps:
- name: Check PR type
id: check-type
uses: actions/github-script@v8
with:
script: |
const title = context.payload.pull_request.title || '';
const isAutoReview = /^(fix|chore)(\(.*?\))?!?:/i.test(title);
let touchesSensitive = false;
if (isAutoReview) {
const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
});
touchesSensitive = files.some(f =>
f.filename.startsWith('.github/prompts/') ||
f.filename.startsWith('.github/workflows/') ||
f.filename === 'CODEOWNERS'
);
if (touchesSensitive) {
core.notice('Sensitive paths modified (.github/prompts, .github/workflows, or CODEOWNERS): Claude will review but not auto-approve. Human DEV review required; QA can still be waived if Claude reports QA_REQUIRED: NO.');
}
}
core.setOutput('is-auto-review', isAutoReview.toString());
core.setOutput('touches-sensitive', touchesSensitive.toString());
- name: Set status to pending
if: steps.check-type.outputs.is-auto-review == 'true'
uses: actions/github-script@v8
with:
script: |
await github.rest.repos.createCommitStatus({
owner: context.repo.owner,
repo: context.repo.repo,
sha: context.payload.pull_request.head.sha,
state: 'pending',
context: 'Claude Review',
description: 'Claude auto-review in progress...'
});
- name: Checkout repository
if: steps.check-type.outputs.is-auto-review == 'true'
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 1
- name: Load review instructions
if: steps.check-type.outputs.is-auto-review == 'true'
id: prompt
env:
GH_TOKEN: ${{ github.token }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
DELIM="PROMPT_EOF_$(uuidgen)"
{
echo "instructions<<$DELIM"
gh api "repos/${{ github.repository }}/contents/.github/prompts/review-instructions.md?ref=$BASE_SHA" \
--jq '.content' | base64 -d
echo
echo "$DELIM"
} >> "$GITHUB_OUTPUT"
- name: Claude Review
if: steps.check-type.outputs.is-auto-review == 'true'
id: claude
uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
track_progress: true
prompt: |
REPO: ${{ github.repository }}
PR NUMBER: ${{ github.event.pull_request.number }}
This is an automatic review for a fix / chore PR. Review the full PR diff.
${{ steps.prompt.outputs.instructions }}
claude_args: |
--model claude-sonnet-4-6
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr review:*),Read,Glob,Grep"
--max-turns 40
- name: Upload Claude execution output
if: steps.check-type.outputs.is-auto-review == 'true' && always()
uses: actions/upload-artifact@v6
with:
name: claude-execution-output-${{ github.event.pull_request.number }}-${{ github.run_attempt }}
path: /home/runner/work/_temp/claude-execution-output.json
if-no-files-found: warn
retention-days: 7
- name: Report review cost
if: steps.check-type.outputs.is-auto-review == 'true' && always() && steps.claude.outcome != 'skipped'
continue-on-error: true
uses: actions/github-script@v8
env:
MODEL: claude-sonnet-4-6
with:
script: |
const reportCost = require('./.github/scripts/report-review-cost.js');
await reportCost({ github, context });
- name: Set status and approve if eligible
if: steps.check-type.outputs.is-auto-review == 'true' && always()
uses: actions/github-script@v8
with:
script: |
const prNumber = ${{ github.event.pull_request.number }};
const owner = context.repo.owner;
const repo = context.repo.repo;
const touchesSensitive = '${{ steps.check-type.outputs.touches-sensitive }}' === 'true';
const comments = await github.rest.issues.listComments({
owner, repo, issue_number: prNumber, per_page: 100
});
const claudeComments = comments.data.filter(c =>
c.user?.login === 'claude[bot]' && c.body?.includes('REVIEW_RESULT:')
);
const claudeComment = claudeComments[claudeComments.length - 1];
const output = claudeComment?.body ?? '';
console.log('Claude comment preview:', output.slice(0, 300));
const failed = output.includes('REVIEW_RESULT: FAIL');
const errored = '${{ steps.claude.outcome }}' === 'failure';
const isSimple = output.includes('COMPLEXITY: SIMPLE');
const qaRequired = !output.includes('QA_REQUIRED: NO');
const hasReviewResult = output.includes('REVIEW_RESULT:');
const passed = !failed && !errored;
const reviews = await github.rest.pulls.listReviews({
owner, repo, pull_number: prNumber, per_page: 100
});
const ourApprovals = reviews.data.filter(r =>
r.user?.login === 'github-actions[bot]' && r.state === 'APPROVED'
);
const prLabels = (context.payload.pull_request.labels || []).map(l => l.name);
const COMPLEX_MARKER = '🔍 Claude reviewed this PR and found no blocking issues';
const SENSITIVE_MARKER = '🔒 Claude reviewed this PR — sensitive paths modified';
const canAutoApprove = hasReviewResult && passed && isSimple && !touchesSensitive;
const canWaiveQa = hasReviewResult && passed && isSimple && !qaRequired;
if (canAutoApprove) {
if (ourApprovals.length === 0) {
const approvalBody = qaRequired
? 'Auto-approved by Claude — simple fix/chore with no blocking issues. QA approval is still required.'
: 'Auto-approved by Claude — simple fix/chore with no blocking issues. No QA needed (non-runtime changes only).';
await github.rest.pulls.createReview({
owner, repo, pull_number: prNumber, event: 'APPROVE', body: approvalBody
});
}
await github.rest.issues.addLabels({
owner, repo, issue_number: prNumber, labels: ['claude-approved']
});
} else if (hasReviewResult) {
for (const r of ourApprovals) {
await github.rest.pulls.dismissReview({
owner, repo, pull_number: prNumber, review_id: r.id,
message: 'Dismissed: latest Claude review no longer auto-approves this PR.'
}).catch(e => console.log(`dismissReview: ${e.message}`));
}
if (prLabels.includes('claude-approved')) {
await github.rest.issues.removeLabel({
owner, repo, issue_number: prNumber, name: 'claude-approved'
}).catch(e => console.log(`removeLabel claude-approved: ${e.message}`));
}
if (touchesSensitive && passed && isSimple && !comments.data.some(c =>
c.user?.login === 'github-actions[bot]' && c.body?.startsWith(SENSITIVE_MARKER))) {
const qaNote = qaRequired
? 'QA approval is still required.'
: 'No QA needed (Claude reported `QA_REQUIRED: NO`).';
await github.rest.issues.createComment({
owner, repo, issue_number: prNumber,
body: `${SENSITIVE_MARKER} (\`.github/prompts\`, \`.github/workflows\`, or \`CODEOWNERS\`). Claude will not auto-approve these PRs — human DEV review is required. ${qaNote}`
});
} else if (!touchesSensitive && passed && !isSimple && !comments.data.some(c =>
c.user?.login === 'github-actions[bot]' && c.body?.startsWith(COMPLEX_MARKER))) {
await github.rest.issues.createComment({
owner, repo, issue_number: prNumber,
body: COMPLEX_MARKER + ', but assessed it as **complex** — human DEV review is still required before merging.'
});
}
}
if (canWaiveQa) {
if (!prLabels.includes('no QA needed')) {
await github.rest.issues.addLabels({
owner, repo, issue_number: prNumber, labels: ['no QA needed']
});
}
} else if (hasReviewResult && prLabels.includes('no QA needed')) {
await github.rest.issues.removeLabel({
owner, repo, issue_number: prNumber, name: 'no QA needed'
}).catch(e => console.log(`removeLabel no QA needed: ${e.message}`));
}
for (const c of claudeComments.slice(0, -1)) {
await github.graphql(
`mutation($id: ID!) {
minimizeComment(input: { subjectId: $id, classifier: OUTDATED }) {
minimizedComment { isMinimized }
}
}`,
{ id: c.node_id }
).catch(e => console.log(`minimizeComment ${c.id}: ${e.message}`));
}
let description;
if (errored && !output) {
description = 'Claude review failed — credit balance too low or API error';
} else if (errored && output && !hasReviewResult) {
description = 'Claude review failed — reached maximum number of turns';
} else if (errored) {
description = 'Claude review encountered an error';
} else if (failed) {
description = 'Claude found issues that must be resolved';
} else if (touchesSensitive && isSimple) {
description = canWaiveQa
? 'No blocking issues — sensitive paths, DEV review required (no QA needed)'
: 'No blocking issues — sensitive paths, DEV review required';
} else if (isSimple) {
description = 'No blocking issues found — PR auto-approved';
} else {
description = 'No blocking issues — complex change, DEV review required';
}
const state = (failed || errored) ? 'failure' : 'success';
await github.rest.repos.createCommitStatus({
owner: context.repo.owner,
repo: context.repo.repo,
sha: '${{ github.event.pull_request.head.sha }}',
state,
context: 'Claude Review',
description
});
- name: Re-run failed enforce-approvals check
if: steps.check-type.outputs.is-auto-review == 'true' && always()
env:
GH_TOKEN: ${{ secrets.ORG_ACCESS_TOKEN }}
run: |
SHA="${{ github.event.pull_request.head.sha }}"
echo "Searching for failed enforce-approvals run for SHA $SHA..."
WORKFLOW_RUN_ID=$(gh run list \
--workflow="Enforce QA and DEV Approvals" \
--limit 100 \
--repo "${{ github.repository }}" \
--json databaseId,event,headSha,conclusion \
| jq -r --arg SHA "$SHA" '
.[] |
select(.event == "pull_request") |
select(.headSha == $SHA) |
select(.conclusion == "failure") |
.databaseId
' | head -n 1)
if [ -z "$WORKFLOW_RUN_ID" ]; then
echo "No failed enforce-approvals run found for this commit."
exit 0
fi
echo "Re-running failed enforce-approvals workflow: $WORKFLOW_RUN_ID"
gh run rerun "$WORKFLOW_RUN_ID" --repo "${{ github.repository }}"
echo "Re-run triggered successfully."
check-member:
if: |
github.event.issue.pull_request != null &&
(
contains(github.event.comment.body, '@claude review') ||
contains(github.event.comment.body, '@claude re-review') ||
contains(github.event.comment.body, '@claude approve')
) &&
!contains(github.event.issue.labels.*.name, 'no review') &&
!contains(github.event.issue.labels.*.name, 'auto-pr')
runs-on: ubuntu-latest
outputs:
is-member: ${{ steps.check.outputs.is-member }}
steps:
- name: Verify commenter is a decentraland org member
id: check
env:
GH_TOKEN: ${{ secrets.ORG_ACCESS_TOKEN }}
USER: ${{ github.event.comment.user.login }}
run: |
if gh api "/orgs/decentraland/members/$USER" --silent 2>/dev/null; then
echo "is-member=true" >> "$GITHUB_OUTPUT"
else
echo "::notice::User $USER is not a member of decentraland org; @claude review skipped"
echo "is-member=false" >> "$GITHUB_OUTPUT"
fi
review:
needs: check-member
if: needs.check-member.outputs.is-member == 'true'
runs-on: ubuntu-latest
concurrency:
group: claude-on-demand-review-${{ github.event.issue.number }}
cancel-in-progress: true
permissions:
id-token: write
contents: read
pull-requests: write
issues: write
statuses: write
steps:
- name: Resolve PR head (rejects post-comment pushes)
id: pr
uses: actions/github-script@v8
with:
script: |
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number
});
const commentAt = new Date(context.payload.comment.created_at);
const pushedAt = new Date(pr.data.head.repo.pushed_at);
if (pushedAt > commentAt) {
const shortSha = pr.data.head.sha.substring(0, 7);
core.setFailed(
`@claude review skipped: this PR was updated after your comment.\n` +
`Head commit ${shortSha} was pushed at ${pushedAt.toISOString()}, ` +
`after your comment at ${commentAt.toISOString()}.\n` +
`Take a look at the latest changes, then comment @claude review ` +
`again if you still want a review.`
);
return;
}
core.setOutput('sha', pr.data.head.sha);
core.setOutput('base_sha', pr.data.base.sha);
core.setOutput('is_draft', pr.data.draft);
- name: Resolve review model
id: model
env:
COMMENT_BODY: ${{ github.event.comment.body }}
run: |
# Default to Sonnet; opt into Opus by including "opus" in the comment
# (e.g. "@claude review opus" or "@claude re-review --model opus").
# max-turns is a cost ceiling. Opus costs ~5x/turn but reaches a verdict in
# fewer turns, so it gets a tighter cap. Raise a cap if reviews start failing
# with "reached maximum number of turns" (a truncated run is paid-for but wasted).
MODEL="claude-sonnet-4-6"
LABEL="sonnet"
MAX_TURNS=50
if grep -qiE '\bopus\b' <<< "$COMMENT_BODY"; then
MODEL="claude-opus-4-6"
LABEL="opus"
MAX_TURNS=50
fi
echo "model=$MODEL" >> "$GITHUB_OUTPUT"
echo "label=$LABEL" >> "$GITHUB_OUTPUT"
echo "max_turns=$MAX_TURNS" >> "$GITHUB_OUTPUT"
echo "Selected review model: $MODEL ($LABEL), max-turns: $MAX_TURNS"
- name: Set status to pending
uses: actions/github-script@v8
with:
script: |
await github.rest.repos.createCommitStatus({
owner: context.repo.owner,
repo: context.repo.repo,
sha: '${{ steps.pr.outputs.sha }}',
state: 'pending',
context: 'Claude Review',
description: 'Claude review in progress (${{ steps.model.outputs.label }})...'
});
- name: Checkout repository
uses: actions/checkout@v6
with:
ref: ${{ steps.pr.outputs.sha }}
fetch-depth: 1
- name: Load review instructions
id: prompt
env:
GH_TOKEN: ${{ github.token }}
BASE_SHA: ${{ steps.pr.outputs.base_sha }}
run: |
DELIM="PROMPT_EOF_$(uuidgen)"
{
echo "instructions<<$DELIM"
gh api "repos/${{ github.repository }}/contents/.github/prompts/review-instructions.md?ref=$BASE_SHA" \
--jq '.content' | base64 -d
echo
echo "$DELIM"
} >> "$GITHUB_OUTPUT"
- name: Claude Review
id: claude
uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
track_progress: true
prompt: |
REPO: ${{ github.repository }}
PR NUMBER: ${{ github.event.issue.number }}
${{ contains(github.event.comment.body, '@claude re-review') &&
'A re-review has been requested. Focus on whether previously raised issues have been addressed. Check prior review comments on this PR before assessing the diff.' ||
'A review has been requested. Review the full PR diff.' }}
${{ steps.prompt.outputs.instructions }}
claude_args: |
--model ${{ steps.model.outputs.model }}
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr review:*),Read,Glob,Grep"
--max-turns ${{ steps.model.outputs.max_turns }}
- name: Upload Claude execution output
if: always()
uses: actions/upload-artifact@v6
with:
name: claude-execution-output-${{ github.event.issue.number }}-${{ github.run_attempt }}
path: /home/runner/work/_temp/claude-execution-output.json
if-no-files-found: warn
retention-days: 7
- name: Report review cost
if: always() && steps.claude.outcome != 'skipped'
continue-on-error: true
uses: actions/github-script@v8
env:
MODEL: ${{ steps.model.outputs.model }}
with:
script: |
const reportCost = require('./.github/scripts/report-review-cost.js');
await reportCost({ github, context });
- name: Set final commit status
if: always()
uses: actions/github-script@v8
with:
script: |
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: ${{ github.event.issue.number }},
per_page: 100
});
const claudeComments = comments.data.filter(c =>
c.user?.login === 'claude[bot]' && c.body?.includes('REVIEW_RESULT:')
);
const claudeComment = claudeComments[claudeComments.length - 1];
const output = claudeComment?.body ?? '';
console.log('Claude comment preview:', output.slice(0, 200));
const failed = output.includes('REVIEW_RESULT: FAIL');
const errored = '${{ steps.claude.outcome }}' === 'failure';
const hasReviewResult = output.includes('REVIEW_RESULT:');
for (const c of claudeComments.slice(0, -1)) {
await github.graphql(
`mutation($id: ID!) {
minimizeComment(input: { subjectId: $id, classifier: OUTDATED }) {
minimizedComment { isMinimized }
}
}`,
{ id: c.node_id }
).catch(e => console.log(`minimizeComment ${c.id}: ${e.message}`));
}
let description;
if (errored && !output) {
description = 'Claude review failed — credit balance too low or API error';
} else if (errored && output && !hasReviewResult) {
description = 'Claude review failed — reached maximum number of turns';
} else if (errored) {
description = 'Claude review encountered an error';
} else if (failed) {
description = 'Claude found issues that must be resolved';
} else {
description = 'No blocking issues found';
}
const state = (failed || errored) ? 'failure' : 'success';
await github.rest.repos.createCommitStatus({
owner: context.repo.owner,
repo: context.repo.repo,
sha: '${{ steps.pr.outputs.sha }}',
state,
context: 'Claude Review',
description
});