when claude creates a PR, assign me as a reviewer #382
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
| # Claude Code GitHub Action (agent mode) | |
| # | |
| # Responds to "@claude" mentions in issues, issue comments, PR review | |
| # comments, and PR reviews. Requires the CLAUDE_CODE_OAUTH_TOKEN secret. | |
| # | |
| # This workflow runs the action in AGENT mode (a `prompt:` is set below). | |
| # Agent mode does NOT run the action's built-in branch-setup / git-push / | |
| # response-comment machinery that tag mode provides, so this workflow | |
| # reproduces it with explicit pre/post steps: | |
| # - "Set up branch for issue trigger" creates claude/issue-N-<ts>. | |
| # - "Push branch and open draft PR for issue trigger" pushes it + opens | |
| # a draft PR, so an @claude mention on an *issue* yields a PR. | |
| # - "Post Claude's response if no code was committed" surfaces prose | |
| # replies (questions/reviews) that agent mode would otherwise discard. | |
| # - "Dispatch claude-code-review.yml on @claude review" routes review | |
| # requests to the dedicated reviewer instead of self-reviewing. | |
| # - "Skip if this comment was already handled by an active run" dedups a | |
| # late comment that a still-running session already absorbed (see below). | |
| # Ported from d-morrison/rme (#788/#794/#805); adapted to qwt's lighter | |
| # setup (DESCRIPTION-based deps, no renv; no submodule-token URL rewrite). | |
| # | |
| # The `prompt:` also has Claude poll for late-arriving @claude comments | |
| # (issue/PR comment + PR review endpoints) before declaring done, so a | |
| # follow-up posted mid-run is handled in-session rather than dropped | |
| # (issue #73). The concurrency block serializes runs, so a comment posted | |
| # while a session is active gets absorbed by that session via polling — but | |
| # that comment ALSO queued its own run. To stop the queued run from | |
| # re-handling it, Claude lists the comments it absorbed in a | |
| # `<!-- claude-absorbed: id ... -->` marker, a post-step reacts 🚀 to each | |
| # (with GITHUB_TOKEN), and the "Skip if this comment was already handled" step | |
| # makes a run bail when its triggering comment already carries that reaction. | |
| # (Review-submission triggers have no reactions endpoint, so that one case can | |
| # still double up; it degrades to the pre-dedup behavior.) | |
| # | |
| # Project guidance for Claude lives in CLAUDE.md at the repo root. | |
| name: Claude Code | |
| on: | |
| issue_comment: | |
| types: [created] | |
| pull_request_review_comment: | |
| types: [created] | |
| issues: | |
| # `assigned` is intentionally NOT triggered: the actor-gate below checks | |
| # `github.event.issue.author_association`, which describes the issue | |
| # *author* — not the assigner. A collaborator assigning themselves to an | |
| # outsider's issue would fail the gate. If you need a collaborator to | |
| # invoke @claude on an outsider's issue, leave a comment instead. | |
| types: [opened] | |
| pull_request_review: | |
| types: [submitted] | |
| # Serialize @claude sessions per issue/PR so rapid mentions don't fire | |
| # competing runs that race to push the same branch. `cancel-in-progress: | |
| # false` queues a new mention behind the running session rather than | |
| # killing it. `|| github.run_id` is a defensive fallback so an unexpected | |
| # event with no issue/PR number doesn't collapse the group to `claude-` | |
| # and serialize every session globally. | |
| concurrency: | |
| group: claude-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }} | |
| cancel-in-progress: false | |
| jobs: | |
| claude: | |
| # Only trigger for trusted authors (OWNER / MEMBER / COLLABORATOR). | |
| # Without this, anyone who can comment on a public issue or PR could | |
| # trigger an LLM run with write-scoped GITHUB_TOKEN permissions. | |
| if: | | |
| (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude') && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)) || | |
| (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude') && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)) || | |
| (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude') && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.review.author_association)) || | |
| (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')) && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.issue.author_association)) | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write # so Claude can push a branch | |
| pull-requests: write # so Claude can open / edit PRs | |
| issues: write # so Claude can comment on issues | |
| id-token: write | |
| actions: write # read CI results on PRs AND dispatch | |
| # claude-code-review.yml via `gh workflow run` | |
| # (workflow_dispatch needs actions: write — with | |
| # only `read`, the dispatch 403s and the review | |
| # routing silently fails) | |
| steps: | |
| # Dedup: a late @claude comment that an already-running session | |
| # absorbed (via the polling step in the prompt) still spawned its own | |
| # run, now queued behind the active one by the concurrency group. The | |
| # active session marks each comment it absorbs with a github-actions | |
| # 🚀 reaction; this step makes the queued run skip itself when its | |
| # triggering comment already carries that marker, so the comment isn't | |
| # handled twice. Every later step is gated on `skip != 'true'`. | |
| # | |
| # Only comment events can carry a reaction, so this runs for | |
| # `issue_comment` / `pull_request_review_comment` only; for | |
| # `issues:opened` and `pull_request_review` the step is skipped, the | |
| # output is empty (`!= 'true'`), and the run proceeds normally. | |
| # | |
| # The marker must be from github-actions[bot] — only this repo's | |
| # GITHUB_TOKEN can author that reaction, so a random user can't | |
| # suppress a run by hand-reacting 🚀. On any API error we default to | |
| # NOT skipping (fail open), since a wrongful skip would drop the | |
| # request — the exact failure mode issue #73 exists to prevent. | |
| - name: Skip if this comment was already handled by an active run | |
| id: dedup | |
| # Fail open: a shell-level error here must not fail the job (that | |
| # would block the run instead of degrading to the pre-dedup | |
| # duplicate-run behavior). The `|| echo 0` handles gh-api errors; | |
| # this covers anything else. | |
| continue-on-error: true | |
| if: github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| EVENT_NAME: ${{ github.event_name }} | |
| COMMENT_ID: ${{ github.event.comment.id }} | |
| REPO: ${{ github.repository }} | |
| run: | | |
| if [ "$EVENT_NAME" = "pull_request_review_comment" ]; then | |
| ENDPOINT="repos/$REPO/pulls/comments/$COMMENT_ID/reactions" | |
| else | |
| ENDPOINT="repos/$REPO/issues/comments/$COMMENT_ID/reactions" | |
| fi | |
| MARKED=$(gh api "$ENDPOINT" \ | |
| --jq '[.[] | select(.content == "rocket" and .user.login == "github-actions[bot]")] | length' \ | |
| 2>/dev/null || echo 0) | |
| if [ "$MARKED" != "0" ]; then | |
| echo "Comment $COMMENT_ID already carries the 🚀 handled-marker; skipping this duplicate run." | |
| echo "skip=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "skip=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| # Post a visible acknowledgment BEFORE the multi-minute R/Quarto | |
| # setup begins, so the user knows the @claude mention was received. | |
| # `continue-on-error` keeps a transient comment-API hiccup from | |
| # failing the whole run. Posted by github-actions[bot], so it does | |
| # not re-trigger this workflow (the if: gate keys on `@claude`). | |
| - name: Acknowledge @claude mention | |
| if: steps.dedup.outputs.skip != 'true' | |
| continue-on-error: true | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| NUM: ${{ github.event.issue.number || github.event.pull_request.number }} | |
| RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} | |
| run: | | |
| gh issue comment "$NUM" --repo "${{ github.repository }}" \ | |
| --body ":eyes: Picked up by [workflow run #${{ github.run_id }}]($RUN_URL). R/Quarto setup runs first; Claude itself responds after that." | |
| - name: Checkout repository | |
| if: steps.dedup.outputs.skip != 'true' | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 1 | |
| submodules: recursive | |
| - name: Set up Quarto | |
| if: steps.dedup.outputs.skip != 'true' | |
| uses: quarto-dev/quarto-actions/setup@v2 | |
| with: | |
| tinytex: true | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - uses: r-lib/actions/setup-r@v2 | |
| if: steps.dedup.outputs.skip != 'true' | |
| with: | |
| use-public-rspm: true | |
| - uses: r-lib/actions/setup-r-dependencies@v2 | |
| if: steps.dedup.outputs.skip != 'true' | |
| with: | |
| packages: | | |
| any::knitr | |
| any::rmarkdown | |
| any::tibble | |
| # Record the PR's head SHA before Claude runs (PR triggers only), so | |
| # the post-steps can tell whether Claude pushed new commits. | |
| - name: Capture PR head SHA before Claude | |
| id: head_before | |
| if: | | |
| steps.dedup.outputs.skip != 'true' && | |
| (github.event.pull_request.number || github.event.issue.pull_request) | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| PR_NUMBER="${{ github.event.pull_request.number || github.event.issue.number }}" | |
| SHA=$(gh api "repos/${{ github.repository }}/pulls/$PR_NUMBER" --jq '.head.sha') | |
| echo "sha=$SHA" >> "$GITHUB_OUTPUT" | |
| # Create a feature branch for issue triggers so Claude's commits land | |
| # somewhere durable. Agent mode skips the action's built-in | |
| # setup-branch step, so without this an @claude mention on an issue | |
| # would edit files on the ephemeral checkout and lose them at runner | |
| # cleanup. The "Push branch and open draft PR" post-step pushes this | |
| # branch and opens the PR. Only fires for issue / issue-comment-on- | |
| # issue events (PR triggers commit to the PR's own branch). | |
| - name: Set up branch for issue trigger | |
| id: issue_branch | |
| if: | | |
| steps.dedup.outputs.skip != 'true' && | |
| ( | |
| github.event_name == 'issues' || | |
| (github.event_name == 'issue_comment' && !github.event.issue.pull_request) | |
| ) | |
| env: | |
| ISSUE_NUMBER: ${{ github.event.issue.number }} | |
| run: | | |
| BRANCH="claude/issue-${ISSUE_NUMBER}-$(date -u +%Y%m%d-%H%M%S)" | |
| git checkout -b "$BRANCH" | |
| echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" | |
| echo "starting_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" | |
| echo "Created and switched to $BRANCH" | |
| - name: Run Claude Code | |
| id: claude | |
| if: steps.dedup.outputs.skip != 'true' | |
| uses: anthropics/claude-code-action@v1 | |
| # GH_TOKEN authenticates the `gh` commands granted in claude_args | |
| # (gh pr create/edit/view); without it `gh` prompts and fails in CI. | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| with: | |
| claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} | |
| # Setting `prompt:` puts the action in AGENT mode (see header). | |
| # The triggering comment/issue body is already supplied to Claude | |
| # by the action's default prompt; this only adds the workflow's | |
| # own operating instructions. | |
| prompt: | | |
| You were triggered by an @claude mention in a | |
| ${{ github.event_name }} event. Address the request in the | |
| triggering comment / issue / review. | |
| Git & PR handling (a workflow step does the pushing/PR-opening, | |
| not you): | |
| - **Issue triggers**: a fresh `claude/issue-N-<timestamp>` | |
| branch has already been created and checked out for you. Make | |
| your edits and `git commit` them. Do NOT create another branch | |
| and do NOT run `gh pr create` — a post-step pushes this branch | |
| and opens a draft PR linking back to the issue. | |
| - **PR triggers**: commit your changes; they belong on the PR's | |
| branch. A post-step dispatches a code review when the head SHA | |
| moves. | |
| If your reply is **prose** (answering a question, a | |
| recommendation, a review) rather than a code change, write your | |
| final message as the reply you want posted on the thread — a | |
| post-step posts it as a comment when you didn't commit code. When | |
| the trigger was an inline review comment on the diff (a | |
| `pull_request_review_comment`), that reply is posted as a threaded | |
| reply to that exact comment — keep it focused on the line(s) that | |
| comment is about. | |
| For an explicit `@claude review` request, do NOT post your own | |
| review; a post-step dispatches the dedicated code-review | |
| workflow instead. | |
| **Before declaring the task complete, check for additional | |
| @claude requests that arrived after the triggering event**, so a | |
| follow-up posted while you were working gets handled in this same | |
| session instead of being dropped (the per-PR/issue concurrency | |
| group serializes runs, but a long session can still miss a | |
| comment posted mid-run). | |
| This run was triggered by a `${{ github.event_name }}` event. | |
| Pre-resolved context (don't re-derive these): | |
| - PR context? `${{ (github.event.pull_request.number || github.event.issue.pull_request) && 'yes' || 'no' }}` | |
| (`issue_comment` fires for both issues and PRs; this flag | |
| disambiguates without guessing.) | |
| - Entity number: `${{ github.event.issue.number || github.event.pull_request.number }}` | |
| - Triggering timestamp: `${{ github.event.comment.created_at || github.event.review.submitted_at || github.event.issue.created_at }}` | |
| (`comment.created_at` for the comment events, `submitted_at` | |
| for `pull_request_review`, `issue.created_at` for | |
| `issues:opened` — this workflow does not trigger on | |
| `issues:assigned`, so no coarser fallback is needed.) | |
| 1. Fetch the endpoint(s) that apply to your trigger type. The | |
| URLs are NOT quoted — the allowed-tools pattern | |
| `Bash(gh api repos/<repo>/:*)` only matches the unquoted form, | |
| and a leading quote (`gh api 'repos/...`) makes the call get | |
| denied: | |
| ``` | |
| gh api repos/${{ github.repository }}/issues/${{ github.event.issue.number || github.event.pull_request.number }}/comments --paginate | |
| gh api repos/${{ github.repository }}/pulls/${{ github.event.issue.number || github.event.pull_request.number }}/comments --paginate # PRs only | |
| gh api repos/${{ github.repository }}/pulls/${{ github.event.issue.number || github.event.pull_request.number }}/reviews --paginate # PRs only | |
| ``` | |
| For **issue** triggers poll only `/issues/N/comments`; the | |
| `/pulls/...` endpoints don't apply. `/issues/N/comments` holds | |
| PR/issue timeline comments; `/pulls/N/comments` holds inline | |
| review-thread comments; `/pulls/N/reviews` holds review | |
| submissions (where `@claude` lives for a | |
| `pull_request_review` trigger). The issue/PR body itself is | |
| already supplied to you above — no need to re-fetch it. | |
| 2. Keep entries where ALL hold: the relevant timestamp | |
| (`created_at` for comments, `submitted_at` for reviews) is | |
| strictly after the triggering timestamp above; the text | |
| (`body`) contains `@claude`; and `user.type` is not `"Bot"` | |
| (excludes claude[bot], github-actions[bot], etc. so the loop | |
| can't be driven by a bot that learns to say `@claude`). | |
| 3. If any match, address each in chronological order, then repeat | |
| from step 1. Stop when none remain, or after at most **5** | |
| polling iterations — whichever comes first. If you hit the cap | |
| with comments still arriving, note it in your final message | |
| and stop; the next queued run picks up from there. | |
| 4. Every late comment you address in step 3 also queued its OWN | |
| run of this workflow (one fires per @claude comment). To stop | |
| those duplicate runs from re-handling what you just did, **end | |
| your final message with a one-line marker listing the numeric | |
| `id`s of the comments you absorbed by polling** (the `id` field | |
| from the polling response — NOT the comment that triggered | |
| you): | |
| ``` | |
| <!-- claude-absorbed: 123456 789012 --> | |
| ``` | |
| Space-separate the ids; emit the marker only if you absorbed at | |
| least one. A post-step reacts 🚀 to each id with the workflow | |
| token (which can actually write the reaction), and the dedup | |
| pre-step in each duplicate run then sees the 🚀 and skips | |
| itself. Do NOT try to add the reaction yourself — just emit the | |
| marker; the post-step is what makes it reliable. (Review | |
| submissions from `/pulls/N/reviews` have no reactions endpoint, | |
| so a late review submission can't be marked and its run just | |
| proceeds.) | |
| # Allowed git/gh/quarto/Rscript commands. `git push` is narrowed to | |
| # `origin` and destructive flag/refspec variants are denied so the | |
| # write-scoped token can't rewrite or delete refs. `Rscript -e` is | |
| # restricted to specific namespaces. `quarto render`/`check` are | |
| # allowed so Claude can confirm the site builds before pushing — | |
| # accepting that quarto (like devtools::check/pkgdown::build) runs | |
| # project R code that can `system()`; the real containment is the | |
| # trusted-author gate in the job `if:` above. | |
| # | |
| # `Bash(gh api repos/${{ github.repository }}/:*)` backs the | |
| # late-comment polling step in the prompt above (fetching | |
| # `/issues/N/comments`, `/pulls/N/comments`, `/pulls/N/reviews`). | |
| # The `:*` (qwt's house prefix-wildcard, not a bare `/*` glob) | |
| # matches `gh api repos/<this-repo>/` followed by any path — so it | |
| # doesn't depend on whether `*` crosses `/`, and it's scoped to | |
| # THIS repo's API surface (excludes cross-repo / cross-org calls). | |
| # | |
| # Because the prefix is anchored at `gh api repos/`, the flag-first | |
| # write form (`gh api -X POST repos/...`) does NOT match this allow | |
| # rule and is denied. The two `gh api -X` / `--method` entries in | |
| # disallowedTools below also deny that form explicitly. Claude | |
| # uses `gh api repos/<repo>/...` only for READS here (the polling | |
| # GETs); the dedup 🚀 reaction is written by a post-step with | |
| # GITHUB_TOKEN, not by Claude (see #95 — Claude's sandboxed reaction | |
| # call couldn't get the write through). A URL-first write (`gh api | |
| # repos/<repo>/... -f field=val`) would still match this allow rule, | |
| # so the real bound on writes is the GITHUB_TOKEN scopes plus the | |
| # trusted-author gate in the job `if:` above. | |
| claude_args: | | |
| --allowedTools "Bash(Rscript -e 'lintr::lint*'),Bash(Rscript -e 'devtools::check*'),Bash(Rscript -e 'devtools::document*'),Bash(Rscript -e 'pkgdown::build*'),Bash(quarto render:*),Bash(quarto check:*),Bash(git diff:*),Bash(git log:*),Bash(git status:*),Bash(git show:*),Bash(git checkout:*),Bash(git switch:*),Bash(git branch:*),Bash(git add:*),Bash(git commit:*),Bash(git push origin:*),Bash(git push -u origin:*),Bash(gh api repos/${{ github.repository }}/:*),Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh pr create:*),Bash(gh pr edit:*),Bash(gh issue view:*),Read,Glob,Grep,Edit,MultiEdit,Write" --disallowedTools "Bash(git push --force:*),Bash(git push -f:*),Bash(git push --delete:*),Bash(git push -d:*),Bash(git push --mirror:*),Bash(git push --tags:*),Bash(git push --all:*),Bash(git push origin +*),Bash(git push -u origin +*),Bash(gh api -X:*),Bash(gh api --method:*)" | |
| # Fetch the PR's head SHA once, post-Claude, for the two steps that | |
| # both need it (the prose-post COMMITTED check and the re-request | |
| # step). PR-context only; mirrors the before-step above. | |
| - name: Capture PR head SHA after Claude | |
| id: head_after | |
| if: | | |
| always() && | |
| steps.dedup.outputs.skip != 'true' && | |
| (github.event.pull_request.number || github.event.issue.pull_request) | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| PR_NUMBER="${{ github.event.pull_request.number || github.event.issue.number }}" | |
| SHA=$(gh api "repos/${{ github.repository }}/pulls/$PR_NUMBER" --jq '.head.sha') | |
| echo "sha=$SHA" >> "$GITHUB_OUTPUT" | |
| # Mark the comments Claude absorbed via polling with a 🚀 reaction so | |
| # the duplicate run each of them queued skips itself (the dedup pre-step | |
| # looks for this reaction). Claude emits the absorbed ids in a | |
| # `<!-- claude-absorbed: id ... -->` marker; we react here with | |
| # GITHUB_TOKEN, which — unlike Claude's sandboxed `gh` calls — can | |
| # reliably POST the reaction. Fail-open: if the marker is absent or a | |
| # reaction fails, the duplicate run simply proceeds (never a drop). #95 | |
| - name: React to comments Claude absorbed via polling | |
| if: | | |
| always() && | |
| steps.dedup.outputs.skip != 'true' && | |
| steps.claude.outcome == 'success' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| REPO: ${{ github.repository }} | |
| EXECUTION_FILE: ${{ steps.claude.outputs.execution_file }} | |
| run: | | |
| FILE="${EXECUTION_FILE:-/home/runner/work/_temp/claude-execution-output.json}" | |
| [ -f "$FILE" ] || { echo "No execution file; nothing to mark."; exit 0; } | |
| TEXT=$(jq -rs ' | |
| flatten(1) | |
| | [.[] | select(type == "object" and .type == "assistant")] | last | |
| | (.message.content // []) | map(select(.type == "text") | .text) | join("\n") | |
| ' < "$FILE") | |
| # Pull ids out of the `<!-- claude-absorbed: ... -->` marker. | |
| IDS=$(printf '%s' "$TEXT" | grep -oE '<!-- *claude-absorbed:[0-9 ]*-->' | grep -oE '[0-9]+' | sort -u) | |
| [ -z "$IDS" ] && { echo "No claude-absorbed marker; nothing to mark."; exit 0; } | |
| for id in $IDS; do | |
| # A comment id is either an issue comment or a PR review comment; | |
| # try the issue endpoint first, fall back to the pulls endpoint. | |
| if gh api -X POST "repos/$REPO/issues/comments/$id/reactions" -f content=rocket >/dev/null 2>&1; then | |
| echo "reacted 🚀 to issue comment $id" | |
| elif gh api -X POST "repos/$REPO/pulls/comments/$id/reactions" -f content=rocket >/dev/null 2>&1; then | |
| echo "reacted 🚀 to PR review comment $id" | |
| else | |
| echo "::warning::could not react to comment $id (deleted, or not a comment id)" | |
| fi | |
| done | |
| # Surface Claude's final prose reply when it didn't commit code. | |
| # Agent mode discards the response otherwise. Skipped on `@claude | |
| # review` (with PR context) because the dispatch step below routes | |
| # that to the dedicated reviewer — posting here too would double up. | |
| # The PR-context guard keeps `@claude review` on a plain issue from | |
| # falling into the skip (no PR to dispatch a review for). | |
| - name: Post Claude's response if no code was committed | |
| if: | | |
| always() && | |
| steps.dedup.outputs.skip != 'true' && | |
| steps.claude.outcome == 'success' && | |
| !( | |
| ( | |
| contains(github.event.comment.body, '@claude review') || | |
| contains(github.event.review.body, '@claude review') | |
| ) && | |
| (github.event.pull_request.number || github.event.issue.pull_request) | |
| ) | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| ENTITY_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }} | |
| RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} | |
| EXECUTION_FILE: ${{ steps.claude.outputs.execution_file }} | |
| ISSUE_BRANCH_STARTING_SHA: ${{ steps.issue_branch.outputs.starting_sha }} | |
| PR_HEAD_BEFORE: ${{ steps.head_before.outputs.sha }} | |
| PR_HEAD_AFTER: ${{ steps.head_after.outputs.sha }} | |
| run: | | |
| # Did Claude commit anything? Issue triggers: local HEAD vs the | |
| # pre-step's recorded tip. PR triggers: PR head SHA before vs | |
| # after. Either guard requires a non-empty "after" so a failed | |
| # SHA fetch doesn't get misread as "committed" (which would | |
| # wrongly suppress the prose post). | |
| COMMITTED=false | |
| if [ -n "$ISSUE_BRANCH_STARTING_SHA" ]; then | |
| [ "$(git rev-parse HEAD)" != "$ISSUE_BRANCH_STARTING_SHA" ] && COMMITTED=true | |
| fi | |
| if [ -n "$PR_HEAD_BEFORE" ] && [ -n "$PR_HEAD_AFTER" ] && [ "$PR_HEAD_AFTER" != "$PR_HEAD_BEFORE" ]; then | |
| COMMITTED=true | |
| fi | |
| if [ "$COMMITTED" = "true" ]; then | |
| echo "Claude committed code; commits are the deliverable, skipping response-text post." | |
| exit 0 | |
| fi | |
| FILE="${EXECUTION_FILE:-/home/runner/work/_temp/claude-execution-output.json}" | |
| if [ ! -f "$FILE" ]; then | |
| echo "::warning::No Claude execution output at $FILE; nothing to post." | |
| exit 0 | |
| fi | |
| # execution_file is a single JSON array of event objects. flatten(1) | |
| # collapses the slurp wrap so we iterate event objects whether the | |
| # file is one array (real), NDJSON, or has a stray array element; | |
| # the type=="object" guard skips non-objects before `.type`. | |
| ASSISTANT_COUNT=$(jq -rs 'flatten(1) | [.[] | select(type == "object" and .type == "assistant")] | length' < "$FILE") | |
| if [ "$ASSISTANT_COUNT" = "0" ]; then | |
| echo "::warning::No assistant-typed events in $FILE — action output schema may have changed. Nothing to post." | |
| exit 0 | |
| fi | |
| RESPONSE=$(jq -rs ' | |
| flatten(1) | |
| | [.[] | select(type == "object" and .type == "assistant")] | |
| | last | |
| | (.message.content // []) | |
| | map(select(.type == "text") | .text) | |
| | join("\n\n") | |
| ' < "$FILE") | |
| if [ -z "$RESPONSE" ] || [ "$RESPONSE" = "null" ]; then | |
| echo "::warning::Assistant events present but no text content; nothing to post." | |
| exit 0 | |
| fi | |
| # GitHub comment bodies cap at 65,536 bytes; truncate by bytes | |
| # (wc -c / head -c, not bash ${#} which counts characters). Pipe | |
| # the byte-cut through `iconv -c` to drop a trailing partial | |
| # multibyte char, so head -c can't leave invalid UTF-8 that gh | |
| # would reject or render as a replacement glyph. | |
| MAX_LEN=60000 | |
| BYTE_LEN=$(printf '%s' "$RESPONSE" | wc -c) | |
| if [ "$BYTE_LEN" -gt "$MAX_LEN" ]; then | |
| RESPONSE="$(printf '%s' "$RESPONSE" | head -c "$MAX_LEN" | iconv -f UTF-8 -t UTF-8 -c) | |
| … (response truncated at ${MAX_LEN} bytes; full text in the [workflow run](${RUN_URL}))" | |
| fi | |
| BODY="$(printf '%s\n\n<sub>— posted by @claude post-step from [workflow run](%s)</sub>\n' "$RESPONSE" "$RUN_URL")" | |
| # When the trigger was an inline review comment, reply IN-THREAD to | |
| # that comment (pulls/.../comments/<id>/replies) so the conversation | |
| # stays anchored to the diff line instead of a detached top-level | |
| # comment. Fall back to a top-level comment if the reply API fails | |
| # (e.g. the parent review comment was deleted). | |
| if [ "${{ github.event_name }}" = "pull_request_review_comment" ]; then | |
| if jq -n --arg b "$BODY" '{body: $b}' \ | |
| | gh api --method POST "repos/${{ github.repository }}/pulls/$ENTITY_NUMBER/comments/${{ github.event.comment.id }}/replies" --input - >/dev/null; then | |
| exit 0 | |
| fi | |
| echo "::warning::In-thread reply failed; falling back to a top-level comment." | |
| fi | |
| gh issue comment "$ENTITY_NUMBER" --repo "${{ github.repository }}" --body "$BODY" \ | |
| || echo "::warning::Could not post Claude's response back to the source thread." | |
| # Route an explicit `@claude review` request to the dedicated reviewer | |
| # workflow rather than letting the agent self-review (which would | |
| # double up with claude-code-review.yml's own push-triggered run). | |
| # No outcome guard: a review dispatch doesn't depend on the agent's | |
| # output, so fire it even if the agent step was skipped/cancelled. | |
| - name: Dispatch claude-code-review.yml on @claude review comment | |
| if: | | |
| always() && | |
| steps.dedup.outputs.skip != 'true' && | |
| (github.event.pull_request.number || github.event.issue.pull_request) && | |
| ( | |
| contains(github.event.comment.body, '@claude review') || | |
| contains(github.event.review.body, '@claude review') | |
| ) | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| PR_NUMBER="${{ github.event.pull_request.number || github.event.issue.number }}" | |
| echo "Dispatching claude-code-review.yml for PR #$PR_NUMBER (@claude review comment)." | |
| gh workflow run claude-code-review.yml -f pr_number="$PR_NUMBER" \ | |
| || echo "::warning::Could not dispatch claude-code-review.yml; review will not auto-run." | |
| # If an `@claude review` arrived as a LATE comment (posted after the | |
| # trigger and absorbed by this session's polling), the dedup marks it so | |
| # its OWN run skips — losing the review dispatch that run would have | |
| # done. Re-scan for late `@claude review` requests and dispatch here. | |
| # The review workflow's per-PR concurrency dedupes any redundant | |
| # dispatch, so this is safe even when the trigger comment was itself a | |
| # review request (that case is handled by the step above; TRIGGER_TS is | |
| # strict-greater, so the trigger comment isn't double-counted here). #90 | |
| - name: Dispatch review for a late @claude review comment | |
| if: | | |
| always() && | |
| steps.dedup.outputs.skip != 'true' && | |
| (github.event.pull_request.number || github.event.issue.pull_request) | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| REPO: ${{ github.repository }} | |
| PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} | |
| TRIGGER_TS: ${{ github.event.comment.created_at || github.event.review.submitted_at || github.event.issue.created_at }} | |
| run: | | |
| late=$( | |
| { gh api "repos/$REPO/issues/$PR_NUMBER/comments" --paginate --jq '.[]' 2>/dev/null | |
| gh api "repos/$REPO/pulls/$PR_NUMBER/comments" --paginate --jq '.[]' 2>/dev/null | |
| gh api "repos/$REPO/pulls/$PR_NUMBER/reviews" --paginate --jq '.[]' 2>/dev/null | |
| } | jq -s --arg ts "$TRIGGER_TS" \ | |
| '[.[] | select((((.created_at // .submitted_at) // "") > $ts) and ((.body // "") | test("@claude review")) and ((.user.type // "") != "Bot"))] | length' ) | |
| if [ "${late:-0}" -gt 0 ]; then | |
| echo "Found $late late @claude review request(s) newer than $TRIGGER_TS; dispatching review." | |
| gh workflow run claude-code-review.yml -f pr_number="$PR_NUMBER" \ | |
| || echo "::warning::Could not dispatch claude-code-review.yml for the late review request." | |
| else | |
| echo "No late @claude review requests." | |
| fi | |
| # When Claude pushed new commits to a PR, dispatch a fresh code | |
| # review on the new diff. (Unlike rme, this does not re-request a | |
| # human reviewer via the requested_reviewers API — qwt has no | |
| # standing-reviewer convention; add that here if one is adopted.) | |
| - name: Dispatch code review if Claude pushed commits | |
| if: | | |
| always() && | |
| steps.dedup.outputs.skip != 'true' && | |
| (github.event.pull_request.number || github.event.issue.pull_request) && | |
| steps.head_before.outputs.sha != '' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| SHA_BEFORE: ${{ steps.head_before.outputs.sha }} | |
| SHA_AFTER: ${{ steps.head_after.outputs.sha }} | |
| run: | | |
| PR_NUMBER="${{ github.event.pull_request.number || github.event.issue.number }}" | |
| echo "before=$SHA_BEFORE after=$SHA_AFTER" | |
| if [ -n "$SHA_AFTER" ] && [ "$SHA_AFTER" != "$SHA_BEFORE" ]; then | |
| echo "Claude pushed new commits; dispatching code review." | |
| gh workflow run claude-code-review.yml -f pr_number="$PR_NUMBER" \ | |
| || echo "::warning::Could not dispatch claude-code-review.yml." | |
| else | |
| echo "No new commits on PR head; skipping review dispatch." | |
| fi | |
| # Pair to "Set up branch for issue trigger": push the branch Claude | |
| # committed to and open a draft PR. Runs on always() so partial work | |
| # is preserved even if the Claude step failed. | |
| # | |
| # The push uses an explicit token-bearing URL rather than relying on | |
| # the credentials actions/checkout persists. The claude-code-action | |
| # step that runs before this rewrites the repo's git auth config when | |
| # it sets up its own credentials, leaving the main repo with no | |
| # `http.https://github.com/.extraheader` — so a plain `git push origin` | |
| # falls back to empty credentials and fails with "Password | |
| # authentication is not supported" (run 26650131764). GITHUB_TOKEN has | |
| # `contents: write` from the permissions block above, so it can push; | |
| # note GitHub deliberately does NOT fire downstream workflows on a push | |
| # authenticated by GITHUB_TOKEN, which is why the review is dispatched | |
| # explicitly via `gh workflow run` below rather than left to a trigger. | |
| - name: Push branch and open draft PR for issue trigger | |
| if: | | |
| always() && | |
| steps.dedup.outputs.skip != 'true' && | |
| steps.issue_branch.outputs.branch != '' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| BRANCH: ${{ steps.issue_branch.outputs.branch }} | |
| STARTING_SHA: ${{ steps.issue_branch.outputs.starting_sha }} | |
| ISSUE_NUMBER: ${{ github.event.issue.number }} | |
| ISSUE_TITLE: ${{ github.event.issue.title }} | |
| RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} | |
| run: | | |
| # Sweep changes Claude staged or left untracked but didn't commit, so the HEAD-unchanged check below doesn't drop them. | |
| if [ -n "$(git status --porcelain)" ]; then | |
| git config user.name "claude[bot]" | |
| git config user.email "claude[bot]@users.noreply.github.com" | |
| git add -A | |
| git commit -m "chore: auto-commit residual staged/untracked changes from @claude session" \ | |
| || { echo "::error::auto-commit of residual @claude changes failed"; exit 1; } | |
| fi | |
| CURRENT_SHA=$(git rev-parse HEAD) | |
| if [ "$CURRENT_SHA" = "$STARTING_SHA" ]; then | |
| echo "No new commits on $BRANCH — Claude produced no changes; skipping push/PR." | |
| exit 0 | |
| fi | |
| echo "Pushing $BRANCH ($STARTING_SHA -> $CURRENT_SHA)" | |
| git push "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" "$BRANCH" | |
| EXISTING=$(gh pr list --head "$BRANCH" --state open --json url --jq '.[0].url // empty') | |
| if [ -n "$EXISTING" ]; then | |
| echo "PR already exists for $BRANCH: $EXISTING" | |
| PR_URL="$EXISTING" | |
| PR_VERB="pushed new commits to existing draft PR" | |
| else | |
| # "Addresses #N" (not "Closes/Fixes #N") is deliberate: this is a | |
| # draft of Claude's attempt and may not fully resolve the issue, | |
| # so we don't want merging it to auto-close the issue. A human can | |
| # switch to "Closes" on the PR if the work is in fact complete. | |
| PR_BODY=$(printf 'Draft PR opened by `@claude` to address #%s.\n\nTriggered by [workflow run](%s).\n\nAddresses #%s.\n' \ | |
| "$ISSUE_NUMBER" "$RUN_URL" "$ISSUE_NUMBER") | |
| if [ -z "$ISSUE_TITLE" ]; then | |
| PR_TITLE="Claude response to issue #${ISSUE_NUMBER}" | |
| else | |
| PR_TITLE="$ISSUE_TITLE" | |
| fi | |
| PR_URL=$(gh pr create \ | |
| --base "${{ github.event.repository.default_branch }}" \ | |
| --head "$BRANCH" \ | |
| --draft \ | |
| --title "$PR_TITLE" \ | |
| --body "$PR_BODY") | |
| echo "Opened draft PR: $PR_URL" | |
| PR_VERB="opened draft PR" | |
| # Issue #105: request the repo owner as a reviewer. Separate from | |
| # `gh pr create` so a failure (e.g. owner is an org) degrades to a | |
| # warning rather than failing PR creation. | |
| gh pr edit "$PR_URL" --add-reviewer "${{ github.repository_owner }}" \ | |
| || echo "::warning::Could not request ${{ github.repository_owner }} as a reviewer on $PR_URL." | |
| fi | |
| # Dispatch a review on the new commits, for both the freshly-opened | |
| # and the already-exists (idempotent re-run) paths. The review's | |
| # own concurrency group dedupes a redundant dispatch. | |
| gh workflow run claude-code-review.yml -f pr_number="${PR_URL##*/}" \ | |
| || echo "::warning::Could not dispatch claude-code-review.yml." | |
| gh issue comment "$ISSUE_NUMBER" --repo "${{ github.repository }}" \ | |
| --body "Pushed Claude's commits to \`${BRANCH}\` and ${PR_VERB}: ${PR_URL}" \ | |
| || echo "::warning::Could not post PR link as issue comment." |