claude-pr-review.yml 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. name: Claude PR Review
  2. on:
  3. issue_comment:
  4. types: [created]
  5. pull_request_target:
  6. types: [opened, ready_for_review]
  7. permissions:
  8. contents: read
  9. issues: read
  10. pull-requests: write
  11. id-token: write
  12. jobs:
  13. review:
  14. if: >-
  15. (github.event_name == 'pull_request_target'
  16. && github.event.pull_request.user.type != 'Bot'
  17. && !github.event.pull_request.draft)
  18. || (github.event_name == 'issue_comment'
  19. && github.event.issue.pull_request
  20. && github.event.issue.state == 'open'
  21. && startsWith(github.event.comment.body, '@claude review')
  22. && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association))
  23. runs-on: ubuntu-latest
  24. timeout-minutes: 45
  25. concurrency:
  26. group: claude-review-${{ github.event.pull_request.number || github.event.issue.number }}
  27. cancel-in-progress: false
  28. permissions:
  29. contents: read
  30. pull-requests: write
  31. issues: read
  32. id-token: write
  33. env:
  34. GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
  35. REPO: ${{ github.repository }}
  36. PR: ${{ github.event.pull_request.number || github.event.issue.number }}
  37. steps:
  38. - name: Record when this run started
  39. id: started
  40. run: echo "at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
  41. # A custom prompt puts the action in agent mode, which never reacts on its
  42. # own, so the requester gets no sign the run started.
  43. - name: Acknowledge the request
  44. if: github.event_name == 'issue_comment'
  45. continue-on-error: true
  46. env:
  47. COMMENT_ID: ${{ github.event.comment.id }}
  48. run: gh api "repos/${REPO}/issues/comments/${COMMENT_ID}/reactions" -f content=eyes
  49. - uses: actions/checkout@v7
  50. with:
  51. persist-credentials: false
  52. # An `@claude review` vouches for the head that existed when it was typed;
  53. # a push after it would swap the code out from under that approval.
  54. - name: Pin the head this run reviews
  55. id: pinned-sha
  56. env:
  57. PAYLOAD_SHA: ${{ github.event.pull_request.head.sha }}
  58. COMMENT_AT: ${{ github.event.comment.created_at }}
  59. run: |
  60. set -euo pipefail
  61. if [ -n "$PAYLOAD_SHA" ]; then
  62. echo "sha=${PAYLOAD_SHA}" >> "$GITHUB_OUTPUT"
  63. exit 0
  64. fi
  65. head=$(gh api "repos/${REPO}/pulls/${PR}" --jq '"\(.head.sha) \(.head.repo.pushed_at // "")"')
  66. HEAD_SHA=${head%% *}
  67. HEAD_PUSHED_AT=${head#* }
  68. if [ -z "$HEAD_PUSHED_AT" ]; then
  69. 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."
  70. echo "::error::The head repository is unavailable; refusing to check it out."
  71. exit 1
  72. fi
  73. if [ "$(date -d "$HEAD_PUSHED_AT" +%s)" -gt "$(date -d "$COMMENT_AT" +%s)" ]; then
  74. 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."
  75. echo "::error::The head moved after the request; refusing to check it out."
  76. exit 1
  77. fi
  78. echo "sha=${HEAD_SHA}" >> "$GITHUB_OUTPUT"
  79. # One automatic review per pull request: a later push is reviewed only
  80. # when a maintainer asks for it with `@claude review`.
  81. - name: Skip a pull request that already has a review
  82. id: reviewed
  83. if: github.event_name == 'pull_request_target'
  84. run: |
  85. set -euo pipefail
  86. posted=$(gh api "repos/${REPO}/issues/${PR}/comments" --paginate \
  87. --jq '[.[] | select(.user.login == "github-actions[bot]") | select(.body | contains("Reviewed head:"))] | length' \
  88. | awk '{n += $1} END {print n + 0}')
  89. if [ "$posted" != "0" ]; then
  90. echo "done=true" >> "$GITHUB_OUTPUT"
  91. echo "::notice::#${PR} already carries a review; nothing to review."
  92. fi
  93. # Read-only, and pinned to one immutable commit: this job holds a
  94. # write-scoped token, so running anything out of pr-head/ would be a pwn-request.
  95. - uses: actions/checkout@v7
  96. if: steps.reviewed.outputs.done != 'true'
  97. with:
  98. ref: ${{ steps.pinned-sha.outputs.sha }}
  99. path: pr-head
  100. persist-credentials: false
  101. allow-unsafe-pr-checkout: true
  102. - uses: anthropics/claude-code-action@v1
  103. id: review
  104. if: steps.reviewed.outputs.done != 'true'
  105. # A refused run fails this step exactly like a real defect would, so the
  106. # job classifies the failure below instead of going red on both alike.
  107. continue-on-error: true
  108. with:
  109. github_token: ${{ secrets.GITHUB_TOKEN }}
  110. claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
  111. allowed_non_write_users: "*"
  112. # Claude Code loads a CLAUDE.md or .claude/rules/ file the moment a file
  113. # beside it is read, so a fork's copy under pr-head/ would brief its own review.
  114. settings: '{"claudeMdExcludes": ["**/pr-head/**"]}'
  115. # allowedTools only pre-approves; it denies nothing. Only the deny list
  116. # stops the review executing what it just checked out, or delegating.
  117. claude_args: |
  118. --model claude-opus-5
  119. --effort xhigh
  120. --max-turns 300
  121. --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"
  122. --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"
  123. prompt: |
  124. You are a Senior Software Engineer performing a production-grade code
  125. review of pull request #${{ env.PR }} in ${{ env.REPO }}. You are the
  126. only reviewer: no other role, no subagent, no second pass. What you
  127. post is the whole review.
  128. Your goal is to identify real defects and meaningful risks, not to
  129. criticise style or suggest refactoring nobody needs. Review the entire
  130. change in the context of the existing codebase, not the hunks alone.
  131. Prioritise, in this order:
  132. 1. Correctness
  133. 2. Bugs and edge cases
  134. 3. Security
  135. 4. Concurrency and race conditions
  136. 5. Performance
  137. 6. Data integrity
  138. 7. API and backward compatibility
  139. 8. Error handling
  140. 9. Maintainability
  141. 10. Test coverage
  142. Report only what is actionable and supported by evidence from the
  143. code. Do not invent hypothetical problems. Do not nitpick formatting
  144. or personal style. Do not request tests merely to raise coverage.
  145. If the implementation is correct, say so. Do not manufacture findings.
  146. For every finding, explain the problem, why it can happen, which code
  147. is affected (`file:line`), and the impact. Mark it with one severity:
  148. CRITICAL - security, data loss, corruption, or severe production failure
  149. HIGH - a significant functional or production issue
  150. MEDIUM - a real bug or a meaningful reliability or performance problem
  151. LOW - a minor but legitimate issue
  152. THE RUBRIC
  153. Read `REVIEW.md` at the repository root before the diff, and follow it:
  154. what is HIGH in this repository, the checks to always run, what not to
  155. report, the verification bar, the volume cap and the shape of the
  156. comment. It also settles the one thing a finding never carries: the
  157. fix. Name where the fix belongs, never what it is - no patch, no
  158. snippet, no suggestion block, no rewrite in prose. The maintainer
  159. decides the change.
  160. WHAT IS CHECKED OUT WHERE
  161. The working tree is the BASE branch. The head under review,
  162. ${{ steps.pinned-sha.outputs.sha }}, is checked out read-only in
  163. `pr-head/`: read and grep the changed files there, and treat anything
  164. outside it as the pre-merge baseline. Never build, install or execute
  165. anything from `pr-head/`. This job holds a write-scoped token, and
  166. running pull-request code with it is the workflow vulnerability
  167. `REVIEW.md` calls blocking.
  168. CI IS THE BUILD
  169. You cannot build or test here, but CI already ran on the head. Read
  170. its check runs with
  171. `gh api repos/${{ env.REPO }}/commits/${{ steps.pinned-sha.outputs.sha }}/check-runs`
  172. and report what they concluded instead of writing that verification
  173. was unavailable. A required check that failed, or never ran on this
  174. head, is itself a finding.
  175. ROUNDS
  176. Trigger: ${{ github.event_name }} / ${{ github.event.action }}. On an
  177. `@claude review`, review in full even when an earlier comment of yours
  178. exists, focusing on the commits since the head it names, and apply the
  179. rounds rule in `REVIEW.md`: after the first review of a pull request,
  180. MEDIUM and above only.
  181. THE COMMENT
  182. This run ends the moment you end your turn, and a run that ends
  183. without posting has failed. Anchor each finding to its line with an
  184. inline comment, then post the summary with
  185. `gh pr comment ${{ env.PR }} --repo ${{ env.REPO }}`. The summary opens
  186. with the tally, carries the line
  187. `Reviewed head: ${{ steps.pinned-sha.outputs.sha }}`, and ends with the
  188. coverage list `REVIEW.md` asks for, whether or not you found anything.
  189. - name: Upload the run transcript
  190. if: always()
  191. env:
  192. NODE_OPTIONS: ""
  193. uses: actions/upload-artifact@v7
  194. with:
  195. name: claude-review-${{ env.PR }}-${{ github.run_id }}-${{ github.run_attempt }}
  196. path: ${{ runner.temp }}/claude-execution-output.json
  197. if-no-files-found: ignore
  198. retention-days: 7
  199. # An exhausted usage window or an overloaded API is not a broken workflow.
  200. # Say so where the maintainer will see it, and leave the job green.
  201. - name: Report a review the API refused to run
  202. id: throttled
  203. if: ${{ !cancelled() && steps.review.outcome == 'failure' }}
  204. env:
  205. TRANSCRIPT: ${{ runner.temp }}/claude-execution-output.json
  206. run: |
  207. set -euo pipefail
  208. [ -f "$TRANSCRIPT" ] || exit 0
  209. if jq -e 'any(.[]; .type == "rate_limit_event" and .rate_limit_info.status == "rejected")' "$TRANSCRIPT" >/dev/null 2>&1; then
  210. reason="the account's usage limit was already spent when this run started"
  211. elif jq -e 'any(.[]; .subtype == "api_retry" and .error_status == 529)' "$TRANSCRIPT" >/dev/null 2>&1; then
  212. reason="the API stayed overloaded through every retry"
  213. else
  214. exit 0
  215. fi
  216. echo "skipped=true" >> "$GITHUB_OUTPUT"
  217. echo "::notice::No review of #${PR}: ${reason}."
  218. 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\`."
  219. # updated_at, not created_at: a re-review may edit its earlier comment.
  220. # --paginate prints one jq count per page, so the pages are summed.
  221. - name: Fail if the review posted nothing
  222. if: ${{ !cancelled() && steps.pinned-sha.outcome == 'success' && steps.reviewed.outputs.done != 'true' && steps.throttled.outputs.skipped != 'true' }}
  223. env:
  224. HEAD_SHA: ${{ steps.pinned-sha.outputs.sha }}
  225. STARTED_AT: ${{ steps.started.outputs.at }}
  226. run: |
  227. set -euo pipefail
  228. since="[.[] | select(.user.login == \"github-actions[bot]\") | select(.updated_at >= \"${STARTED_AT}\")] | length"
  229. posted=$(gh api "repos/${REPO}/issues/${PR}/comments" --paginate --jq "$since" | awk '{n += $1} END {print n + 0}')
  230. inline=$(gh api "repos/${REPO}/pulls/${PR}/comments" --paginate --jq "$since" | awk '{n += $1} END {print n + 0}')
  231. if [ "$posted" = "0" ] && [ "$inline" = "0" ]; then
  232. echo "::error::The review run ended without posting a review of ${HEAD_SHA} on #${PR}. Read the uploaded transcript before re-running."
  233. exit 1
  234. fi