DO NOT MERGE: fix: add mechanism to force stop stuck emotes #7576
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | |
| }); |