Files
3x-ui/.github/workflows/claude-pr-review.yml
T
BlindMaster24 1c0ce80e8e fix(ci): keep a refused Claude credential from reddening a pull request (#6585)
* fix(ci): keep a refused Claude credential from reddening a PR

An expired subscription ends the claude-code-action step with exit 0, so the
classifier that exists for "the API refused this run" never sees it -- its
condition is a failed step -- and the final "posted nothing" step reddens the
pull request although nothing is wrong with the repository.

Verified against five real runs (35159059540, 35184688775, 35185722358,
35186543654, 35187380192): step 8 success, step 10 found no cause, step 11
failure, transcript {"error":"oauth_org_not_allowed"} plus a result entry with
api_error_status 403. A usage-limited run carries 429 and a rejected
rate_limit_event, and a real review carries is_error false with no status, so
the 401/403 test fires on the refused credential alone.

* fix(ci): stop a refused credential reddening the issue analysis

The same exit-0 refusal reaches this workflow's "posted no reply" check, which
fails for the same reason and shows up as seven failed runs in a day. It never
attaches to a pull request -- the trigger excludes them -- so this is the same
step and the same 401/403 transcript test applied where the refusal lands.

Reported only as a warning annotation: nothing was analysed, and there is no
comment worth posting about a credential the maintainer has to renew.
2026-09-17 10:54:12 +03:00

262 lines
14 KiB
YAML

name: Claude PR Review
on:
issue_comment:
types: [created]
pull_request_target:
types: [opened, ready_for_review]
permissions:
contents: read
issues: read
pull-requests: write
id-token: write
jobs:
review:
if: >-
(github.event_name == 'pull_request_target'
&& github.event.pull_request.user.type != 'Bot'
&& !github.event.pull_request.draft)
|| (github.event_name == 'issue_comment'
&& github.event.issue.pull_request
&& github.event.issue.state == 'open'
&& startsWith(github.event.comment.body, '@claude review')
&& contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association))
runs-on: ubuntu-latest
timeout-minutes: 45
concurrency:
group: claude-review-${{ github.event.pull_request.number || github.event.issue.number }}
cancel-in-progress: false
permissions:
contents: read
pull-requests: write
issues: read
id-token: write
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number || github.event.issue.number }}
steps:
- name: Record when this run started
id: started
run: echo "at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
# A custom prompt puts the action in agent mode, which never reacts on its
# own, so the requester gets no sign the run started.
- name: Acknowledge the request
if: github.event_name == 'issue_comment'
continue-on-error: true
env:
COMMENT_ID: ${{ github.event.comment.id }}
run: gh api "repos/${REPO}/issues/comments/${COMMENT_ID}/reactions" -f content=eyes
- uses: actions/checkout@v7
with:
persist-credentials: false
# An `@claude review` vouches for the head that existed when it was typed;
# a push after it would swap the code out from under that approval.
- name: Pin the head this run reviews
id: pinned-sha
env:
PAYLOAD_SHA: ${{ github.event.pull_request.head.sha }}
COMMENT_AT: ${{ github.event.comment.created_at }}
run: |
set -euo pipefail
if [ -n "$PAYLOAD_SHA" ]; then
echo "sha=${PAYLOAD_SHA}" >> "$GITHUB_OUTPUT"
exit 0
fi
head=$(gh api "repos/${REPO}/pulls/${PR}" --jq '"\(.head.sha) \(.head.repo.pushed_at // "")"')
HEAD_SHA=${head%% *}
HEAD_PUSHED_AT=${head#* }
if [ -z "$HEAD_PUSHED_AT" ]; then
gh pr comment "$PR" --repo "$REPO" --body "The head repository of this pull request is gone, so the code to review cannot be verified. Nothing was reviewed."
echo "::error::The head repository is unavailable; refusing to check it out."
exit 1
fi
if [ "$(date -d "$HEAD_PUSHED_AT" +%s)" -gt "$(date -d "$COMMENT_AT" +%s)" ]; then
gh pr comment "$PR" --repo "$REPO" --body "The head branch was pushed to at ${HEAD_PUSHED_AT}, after this review was requested at ${COMMENT_AT}, so the code that would be checked out here is not the code the request vouched for. Nothing was reviewed. Ask again to review the current head."
echo "::error::The head moved after the request; refusing to check it out."
exit 1
fi
echo "sha=${HEAD_SHA}" >> "$GITHUB_OUTPUT"
# One automatic review per pull request: a later push is reviewed only
# when a maintainer asks for it with `@claude review`.
- name: Skip a pull request that already has a review
id: reviewed
if: github.event_name == 'pull_request_target'
run: |
set -euo pipefail
posted=$(gh api "repos/${REPO}/issues/${PR}/comments" --paginate \
--jq '[.[] | select(.user.login == "github-actions[bot]") | select(.body | contains("Reviewed head:"))] | length' \
| awk '{n += $1} END {print n + 0}')
if [ "$posted" != "0" ]; then
echo "done=true" >> "$GITHUB_OUTPUT"
echo "::notice::#${PR} already carries a review; nothing to review."
fi
# Read-only, and pinned to one immutable commit: this job holds a
# write-scoped token, so running anything out of pr-head/ would be a pwn-request.
- uses: actions/checkout@v7
if: steps.reviewed.outputs.done != 'true'
with:
ref: ${{ steps.pinned-sha.outputs.sha }}
path: pr-head
persist-credentials: false
allow-unsafe-pr-checkout: true
- uses: anthropics/claude-code-action@v1
id: review
if: steps.reviewed.outputs.done != 'true'
# A refused run fails this step exactly like a real defect would, so the
# job classifies the failure below instead of going red on both alike.
continue-on-error: true
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
allowed_non_write_users: "*"
# Claude Code loads a CLAUDE.md or .claude/rules/ file the moment a file
# beside it is read, so a fork's copy under pr-head/ would brief its own review.
settings: '{"claudeMdExcludes": ["**/pr-head/**"]}'
# allowedTools only pre-approves; it denies nothing. Only the deny list
# stops the review executing what it just checked out, or delegating.
claude_args: |
--model claude-opus-5
--effort xhigh
--max-turns 300
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh api:*),Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh pr comment ${{ env.PR }}:*),Bash(grep:*),Bash(rg:*),Bash(ls:*),Bash(find:*),Bash(sed:*),Bash(git log:*),Bash(git show:*),Bash(git diff:*),Bash(git blame:*),Bash(go doc:*),Bash(go env:*),Read,Glob,Grep,WebFetch,WebSearch"
--disallowedTools "Agent,Bash(go build:*),Bash(go run:*),Bash(go test:*),Bash(go generate:*),Bash(go install:*),Bash(make:*),Bash(npm:*),Bash(npx:*),Bash(pnpm:*),Bash(yarn:*),Bash(node:*),Bash(bash:*),Bash(sh:*),Bash(docker:*),Bash(chmod:*),Edit,Write,NotebookEdit"
prompt: |
You are a Senior Software Engineer performing a production-grade code
review of pull request #${{ env.PR }} in ${{ env.REPO }}. You are the
only reviewer: no other role, no subagent, no second pass. What you
post is the whole review.
Your goal is to identify real defects and meaningful risks, not to
criticise style or suggest refactoring nobody needs. Review the entire
change in the context of the existing codebase, not the hunks alone.
Prioritise, in this order:
1. Correctness
2. Bugs and edge cases
3. Security
4. Concurrency and race conditions
5. Performance
6. Data integrity
7. API and backward compatibility
8. Error handling
9. Maintainability
10. Test coverage
Report only what is actionable and supported by evidence from the
code. Do not invent hypothetical problems. Do not nitpick formatting
or personal style. Do not request tests merely to raise coverage.
If the implementation is correct, say so. Do not manufacture findings.
For every finding, explain the problem, why it can happen, which code
is affected (`file:line`), and the impact. Mark it with one severity:
CRITICAL - security, data loss, corruption, or severe production failure
HIGH - a significant functional or production issue
MEDIUM - a real bug or a meaningful reliability or performance problem
LOW - a minor but legitimate issue
THE RUBRIC
Read `REVIEW.md` at the repository root before the diff, and follow it:
what is HIGH in this repository, the checks to always run, what not to
report, the verification bar, the volume cap and the shape of the
comment. It also settles the one thing a finding never carries: the
fix. Name where the fix belongs, never what it is - no patch, no
snippet, no suggestion block, no rewrite in prose. The maintainer
decides the change.
WHAT IS CHECKED OUT WHERE
The working tree is the BASE branch. The head under review,
${{ steps.pinned-sha.outputs.sha }}, is checked out read-only in
`pr-head/`: read and grep the changed files there, and treat anything
outside it as the pre-merge baseline. Never build, install or execute
anything from `pr-head/`. This job holds a write-scoped token, and
running pull-request code with it is the workflow vulnerability
`REVIEW.md` calls blocking.
CI IS THE BUILD
You cannot build or test here, but CI already ran on the head. Read
its check runs with
`gh api repos/${{ env.REPO }}/commits/${{ steps.pinned-sha.outputs.sha }}/check-runs`
and report what they concluded instead of writing that verification
was unavailable. A required check that failed, or never ran on this
head, is itself a finding.
ROUNDS
Trigger: ${{ github.event_name }} / ${{ github.event.action }}. On an
`@claude review`, review in full even when an earlier comment of yours
exists, focusing on the commits since the head it names, and apply the
rounds rule in `REVIEW.md`: after the first review of a pull request,
MEDIUM and above only.
THE COMMENT
This run ends the moment you end your turn, and a run that ends
without posting has failed. Anchor each finding to its line with an
inline comment, then post the summary with
`gh pr comment ${{ env.PR }} --repo ${{ env.REPO }}`. The summary opens
with the tally, carries the line
`Reviewed head: ${{ steps.pinned-sha.outputs.sha }}`, and ends with the
coverage list `REVIEW.md` asks for, whether or not you found anything.
- name: Upload the run transcript
if: always()
env:
NODE_OPTIONS: ""
uses: actions/upload-artifact@v7
with:
name: claude-review-${{ env.PR }}-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/claude-execution-output.json
if-no-files-found: ignore
retention-days: 7
# An exhausted usage window or an overloaded API is not a broken workflow.
# Say so where the maintainer will see it, and leave the job green.
- name: Report a review the API refused to run
id: throttled
if: ${{ !cancelled() && steps.review.outcome == 'failure' }}
env:
TRANSCRIPT: ${{ runner.temp }}/claude-execution-output.json
run: |
set -euo pipefail
[ -f "$TRANSCRIPT" ] || exit 0
if jq -e 'any(.[]; .type == "rate_limit_event" and .rate_limit_info.status == "rejected")' "$TRANSCRIPT" >/dev/null 2>&1; then
reason="the account's usage limit was already spent when this run started"
elif jq -e 'any(.[]; .subtype == "api_retry" and .error_status == 529)' "$TRANSCRIPT" >/dev/null 2>&1; then
reason="the API stayed overloaded through every retry"
else
exit 0
fi
echo "skipped=true" >> "$GITHUB_OUTPUT"
echo "::notice::No review of #${PR}: ${reason}."
gh pr comment "$PR" --repo "$REPO" --body "No review ran on this head: ${reason}. Nothing in this pull request was examined. A maintainer can ask for one with \`@claude review\`."
# A refused credential ends the action with exit 0, so the step above never
# sees it: the transcript is the only place that refusal appears.
- name: Report a review the credential refused
id: refused
if: ${{ !cancelled() }}
env:
TRANSCRIPT: ${{ runner.temp }}/claude-execution-output.json
run: |
set -euo pipefail
[ -f "$TRANSCRIPT" ] || exit 0
jq -e 'any(.[]; .type == "result" and ((.api_error_status // 0) == 401 or (.api_error_status // 0) == 403))' "$TRANSCRIPT" >/dev/null 2>&1 \
|| jq -e 'any(.[]; ((.error // "") | test("^(oauth_|authentication_|invalid_api_key)")))' "$TRANSCRIPT" >/dev/null 2>&1 \
|| exit 0
echo "skipped=true" >> "$GITHUB_OUTPUT"
echo "::warning::No review of #${PR}: the Claude credential was refused, so nothing in this pull request was examined."
# updated_at, not created_at: a re-review may edit its earlier comment.
# --paginate prints one jq count per page, so the pages are summed.
- name: Fail if the review posted nothing
if: ${{ !cancelled() && steps.pinned-sha.outcome == 'success' && steps.reviewed.outputs.done != 'true' && steps.throttled.outputs.skipped != 'true' && steps.refused.outputs.skipped != 'true' }}
env:
HEAD_SHA: ${{ steps.pinned-sha.outputs.sha }}
STARTED_AT: ${{ steps.started.outputs.at }}
run: |
set -euo pipefail
since="[.[] | select(.user.login == \"github-actions[bot]\") | select(.updated_at >= \"${STARTED_AT}\")] | length"
posted=$(gh api "repos/${REPO}/issues/${PR}/comments" --paginate --jq "$since" | awk '{n += $1} END {print n + 0}')
inline=$(gh api "repos/${REPO}/pulls/${PR}/comments" --paginate --jq "$since" | awk '{n += $1} END {print n + 0}')
if [ "$posted" = "0" ] && [ "$inline" = "0" ]; then
echo "::error::The review run ended without posting a review of ${HEAD_SHA} on #${PR}. Read the uploaded transcript before re-running."
exit 1
fi