Skip to content

when claude creates a PR, assign me as a reviewer #382

when claude creates a PR, assign me as a reviewer

when claude creates a PR, assign me as a reviewer #382

Workflow file for this run

# 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."