1414# replies (questions/reviews) that agent mode would otherwise discard.
1515# - "Dispatch claude-code-review.yml on @claude review" routes review
1616# requests to the dedicated reviewer instead of self-reviewing.
17+ # - "Skip if this comment was already handled by an active run" dedups a
18+ # late comment that a still-running session already absorbed (see below).
1719# Ported from d-morrison/rme (#788/#794/#805); adapted to qwt's lighter
1820# setup (DESCRIPTION-based deps, no renv; no submodule-token URL rewrite).
1921#
2022# The `prompt:` also has Claude poll for late-arriving @claude comments
2123# (issue/PR comment + PR review endpoints) before declaring done, so a
2224# follow-up posted mid-run is handled in-session rather than dropped
23- # (issue #73). The concurrency block serializes runs, but a queued run can
24- # still re-handle a comment the active run already absorbed.
25+ # (issue #73). The concurrency block serializes runs, so a comment posted
26+ # while a session is active gets absorbed by that session via polling — but
27+ # that comment ALSO queued its own run. To stop the queued run from
28+ # re-handling it, the session marks each absorbed comment with a
29+ # github-actions 🚀 reaction, and the "Skip if this comment was already
30+ # handled" step makes a run bail when its triggering comment already carries
31+ # that marker. (Review-submission triggers have no reactions endpoint, so
32+ # that one case can still double up; it degrades to the pre-dedup behavior.)
2533#
2634# Project guidance for Claude lives in CLAUDE.md at the repo root.
2735
@@ -75,12 +83,60 @@ jobs:
7583 # routing silently fails)
7684
7785 steps :
86+ # Dedup: a late @claude comment that an already-running session
87+ # absorbed (via the polling step in the prompt) still spawned its own
88+ # run, now queued behind the active one by the concurrency group. The
89+ # active session marks each comment it absorbs with a github-actions
90+ # 🚀 reaction; this step makes the queued run skip itself when its
91+ # triggering comment already carries that marker, so the comment isn't
92+ # handled twice. Every later step is gated on `skip != 'true'`.
93+ #
94+ # Only comment events can carry a reaction, so this runs for
95+ # `issue_comment` / `pull_request_review_comment` only; for
96+ # `issues:opened` and `pull_request_review` the step is skipped, the
97+ # output is empty (`!= 'true'`), and the run proceeds normally.
98+ #
99+ # The marker must be from github-actions[bot] — only this repo's
100+ # GITHUB_TOKEN can author that reaction, so a random user can't
101+ # suppress a run by hand-reacting 🚀. On any API error we default to
102+ # NOT skipping (fail open), since a wrongful skip would drop the
103+ # request — the exact failure mode issue #73 exists to prevent.
104+ - name : Skip if this comment was already handled by an active run
105+ id : dedup
106+ # Fail open: a shell-level error here must not fail the job (that
107+ # would block the run instead of degrading to the pre-dedup
108+ # duplicate-run behavior). The `|| echo 0` handles gh-api errors;
109+ # this covers anything else.
110+ continue-on-error : true
111+ if : github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment'
112+ env :
113+ GH_TOKEN : ${{ secrets.GITHUB_TOKEN }}
114+ EVENT_NAME : ${{ github.event_name }}
115+ COMMENT_ID : ${{ github.event.comment.id }}
116+ REPO : ${{ github.repository }}
117+ run : |
118+ if [ "$EVENT_NAME" = "pull_request_review_comment" ]; then
119+ ENDPOINT="repos/$REPO/pulls/comments/$COMMENT_ID/reactions"
120+ else
121+ ENDPOINT="repos/$REPO/issues/comments/$COMMENT_ID/reactions"
122+ fi
123+ MARKED=$(gh api "$ENDPOINT" \
124+ --jq '[.[] | select(.content == "rocket" and .user.login == "github-actions[bot]")] | length' \
125+ 2>/dev/null || echo 0)
126+ if [ "$MARKED" != "0" ]; then
127+ echo "Comment $COMMENT_ID already carries the 🚀 handled-marker; skipping this duplicate run."
128+ echo "skip=true" >> "$GITHUB_OUTPUT"
129+ else
130+ echo "skip=false" >> "$GITHUB_OUTPUT"
131+ fi
132+
78133 # Post a visible acknowledgment BEFORE the multi-minute R/Quarto
79134 # setup begins, so the user knows the @claude mention was received.
80135 # `continue-on-error` keeps a transient comment-API hiccup from
81136 # failing the whole run. Posted by github-actions[bot], so it does
82137 # not re-trigger this workflow (the if: gate keys on `@claude`).
83138 - name : Acknowledge @claude mention
139+ if : steps.dedup.outputs.skip != 'true'
84140 continue-on-error : true
85141 env :
86142 GH_TOKEN : ${{ secrets.GITHUB_TOKEN }}
@@ -91,23 +147,27 @@ jobs:
91147 --body ":eyes: Picked up by [workflow run #${{ github.run_id }}]($RUN_URL). R/Quarto setup runs first; Claude itself responds after that."
92148
93149 - name : Checkout repository
150+ if : steps.dedup.outputs.skip != 'true'
94151 uses : actions/checkout@v4
95152 with :
96153 fetch-depth : 1
97154 submodules : recursive
98155
99156 - name : Set up Quarto
157+ if : steps.dedup.outputs.skip != 'true'
100158 uses : quarto-dev/quarto-actions/setup@v2
101159 with :
102160 tinytex : true
103161 env :
104162 GH_TOKEN : ${{ secrets.GITHUB_TOKEN }}
105163
106164 - uses : r-lib/actions/setup-r@v2
165+ if : steps.dedup.outputs.skip != 'true'
107166 with :
108167 use-public-rspm : true
109168
110169 - uses : r-lib/actions/setup-r-dependencies@v2
170+ if : steps.dedup.outputs.skip != 'true'
111171 with :
112172 packages : |
113173 any::knitr
@@ -118,7 +178,9 @@ jobs:
118178 # the post-steps can tell whether Claude pushed new commits.
119179 - name : Capture PR head SHA before Claude
120180 id : head_before
121- if : github.event.pull_request.number || github.event.issue.pull_request
181+ if : |
182+ steps.dedup.outputs.skip != 'true' &&
183+ (github.event.pull_request.number || github.event.issue.pull_request)
122184 env :
123185 GH_TOKEN : ${{ secrets.GITHUB_TOKEN }}
124186 run : |
@@ -136,8 +198,11 @@ jobs:
136198 - name : Set up branch for issue trigger
137199 id : issue_branch
138200 if : |
139- github.event_name == 'issues' ||
140- (github.event_name == 'issue_comment' && !github.event.issue.pull_request)
201+ steps.dedup.outputs.skip != 'true' &&
202+ (
203+ github.event_name == 'issues' ||
204+ (github.event_name == 'issue_comment' && !github.event.issue.pull_request)
205+ )
141206 env :
142207 ISSUE_NUMBER : ${{ github.event.issue.number }}
143208 run : |
@@ -149,6 +214,7 @@ jobs:
149214
150215 - name : Run Claude Code
151216 id : claude
217+ if : steps.dedup.outputs.skip != 'true'
152218 uses : anthropics/claude-code-action@v1
153219 # GH_TOKEN authenticates the `gh` commands granted in claude_args
154220 # (gh pr create/edit/view); without it `gh` prompts and fails in CI.
@@ -240,6 +306,23 @@ jobs:
240306 with comments still arriving, note it in your final message
241307 and stop; the next queued run picks up from there.
242308
309+ 4. Every late comment you address in step 3 also queued its OWN
310+ run of this workflow (one fires per @claude comment). To stop
311+ that duplicate run from re-handling what you just did, mark
312+ each absorbed comment with a 🚀 reaction — a pre-step in the
313+ duplicate run sees the marker and skips itself. Use the `id`
314+ from the polling response, and the endpoint matching where the
315+ comment came from:
316+ ```
317+ gh api repos/${{ github.repository }}/issues/comments/<id>/reactions -f content=rocket # a /issues/N/comments entry
318+ gh api repos/${{ github.repository }}/pulls/comments/<id>/reactions -f content=rocket # a /pulls/N/comments entry
319+ ```
320+ (Do NOT mark the comment/review that originally triggered YOU
321+ — only the late ones you picked up by polling. Review
322+ submissions from `/pulls/N/reviews` have no reactions
323+ endpoint, so leave those unmarked; their duplicate run just
324+ proceeds as before.)
325+
243326 # Allowed git/gh/quarto/Rscript commands. `git push` is narrowed to
244327 # `origin` and destructive flag/refspec variants are denied so the
245328 # write-scoped token can't rewrite or delete refs. `Rscript -e` is
@@ -260,12 +343,15 @@ jobs:
260343 # Because the prefix is anchored at `gh api repos/`, the flag-first
261344 # write form (`gh api -X POST repos/...`) does NOT match this allow
262345 # rule and is denied. The two `gh api -X` / `--method` entries in
263- # disallowedTools below also deny that form explicitly. What this
264- # still can't pattern-block is a URL-first write that appends a
265- # method/field flag AFTER the URL (`gh api repos/<repo>/... -X
266- # POST`), since allowed-tools can't filter by HTTP method or by
267- # flags past the prefix — there, the real bound is the
268- # GITHUB_TOKEN scopes plus the trusted-author gate in the job `if:`.
346+ # disallowedTools below also deny that form explicitly. The
347+ # URL-first write form (`gh api repos/<repo>/... -f field=val`,
348+ # which `gh` sends as POST) DOES match and is intentionally relied
349+ # on by the dedup marker in the prompt above (`gh api
350+ # repos/<repo>/issues|pulls/comments/<id>/reactions -f
351+ # content=rocket`). Allowed-tools can't filter by HTTP method or by
352+ # flags past the prefix, so any other URL-first write to this repo
353+ # is reachable too — the real bound there is the GITHUB_TOKEN scopes
354+ # plus the trusted-author gate in the job `if:` above.
269355 claude_args : |
270356 --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:*)" --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:*)"
271357
@@ -274,7 +360,10 @@ jobs:
274360 # step). PR-context only; mirrors the before-step above.
275361 - name : Capture PR head SHA after Claude
276362 id : head_after
277- if : always() && (github.event.pull_request.number || github.event.issue.pull_request)
363+ if : |
364+ always() &&
365+ steps.dedup.outputs.skip != 'true' &&
366+ (github.event.pull_request.number || github.event.issue.pull_request)
278367 env :
279368 GH_TOKEN : ${{ secrets.GITHUB_TOKEN }}
280369 run : |
@@ -291,6 +380,7 @@ jobs:
291380 - name : Post Claude's response if no code was committed
292381 if : |
293382 always() &&
383+ steps.dedup.outputs.skip != 'true' &&
294384 steps.claude.outcome == 'success' &&
295385 !(
296386 (
@@ -390,6 +480,7 @@ jobs:
390480 - name : Dispatch claude-code-review.yml on @claude review comment
391481 if : |
392482 always() &&
483+ steps.dedup.outputs.skip != 'true' &&
393484 (github.event.pull_request.number || github.event.issue.pull_request) &&
394485 (
395486 contains(github.event.comment.body, '@claude review') ||
@@ -408,7 +499,11 @@ jobs:
408499 # human reviewer via the requested_reviewers API — qwt has no
409500 # standing-reviewer convention; add that here if one is adopted.)
410501 - name : Dispatch code review if Claude pushed commits
411- if : always() && (github.event.pull_request.number || github.event.issue.pull_request) && steps.head_before.outputs.sha != ''
502+ if : |
503+ always() &&
504+ steps.dedup.outputs.skip != 'true' &&
505+ (github.event.pull_request.number || github.event.issue.pull_request) &&
506+ steps.head_before.outputs.sha != ''
412507 env :
413508 GH_TOKEN : ${{ secrets.GITHUB_TOKEN }}
414509 SHA_BEFORE : ${{ steps.head_before.outputs.sha }}
@@ -432,6 +527,7 @@ jobs:
432527 - name : Push branch and open draft PR for issue trigger
433528 if : |
434529 always() &&
530+ steps.dedup.outputs.skip != 'true' &&
435531 steps.issue_branch.outputs.branch != ''
436532 env :
437533 GH_TOKEN : ${{ secrets.GITHUB_TOKEN }}
0 commit comments