From a89de005d718d5e4e11f26960b3c4a50dc8efa6d Mon Sep 17 00:00:00 2001 From: giswqs Date: Mon, 26 Oct 2026 22:42:55 -0400 Subject: [PATCH 1/2] fix(ci): resolve autofix threads with the maintainer token GITHUB_TOKEN can reply to a review thread but not resolve it ("Resource not accessible by integration"), so every fixed thread stayed open, and the next round staged it again and answered it a second time as "already addressed". Resolve with AUTOMERGE_TOKEN and fail the step when a resolve fails, instead of warning. Also skip threads whose last comment is an autofix reply: a declined or unclear thread comes back only once a reviewer answers it. --- .github/workflows/claude-autofix.yml | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/claude-autofix.yml b/.github/workflows/claude-autofix.yml index f820c9350..ebf8f251e 100644 --- a/.github/workflows/claude-autofix.yml +++ b/.github/workflows/claude-autofix.yml @@ -158,9 +158,12 @@ jobs: jq -s ' def trusted: (.author.__typename == "Bot") or (.authorAssociation | IN("OWNER", "MEMBER", "COLLABORATOR")); + # A thread whose last comment is an autofix reply was answered in an earlier round; + # it comes back once a reviewer replies to it. [ .[] | select(.isResolved | not) | select(.comments.nodes[0] | trusted) + | select(.comments.nodes[-1].body | startswith("**Claude autofix (") | not) | { id, path, line: (.line // .originalLine), outdated: .isOutdated, comments: [ .comments.nodes[] | select(trusted) | { author: (.author.login // "ghost"), body } ] } ] @@ -430,11 +433,14 @@ jobs: echo "pushed=true" >> "$GITHUB_OUTPUT" # Replies go out only for threads that were staged, whatever ids the report holds. A thread - # Claude calls fixed is resolved only when a fix commit was pushed. + # Claude calls fixed is resolved only when a fix commit was pushed. Resolving takes the + # maintainer's token: GITHUB_TOKEN can reply to a thread but not resolve it ("Resource not + # accessible by integration"). - name: Reply to and resolve the threads if: needs.fix.outputs.threads != '0' env: GH_TOKEN: ${{ github.token }} + RESOLVE_TOKEN: ${{ secrets.AUTOMERGE_TOKEN }} DIR: ${{ runner.temp }}/autofix LANDED: ${{ needs.fix.outputs.fixed == 'true' && steps.push.outputs.pushed == 'true' }} run: | @@ -459,12 +465,14 @@ jobs: } }' --silent || echo "::warning::Couldn't reply to $id." if [ "$status" = fixed ]; then - gh api graphql -f thread="$id" -f query=' + GH_TOKEN="$RESOLVE_TOKEN" gh api graphql -f thread="$id" -f query=' mutation($thread: ID!) { resolveReviewThread(input: {threadId: $thread}) { thread { id } } - }' --silent || echo "::warning::Couldn't resolve $id." + }' --silent || { echo "::error::Couldn't resolve $id."; touch "$RUNNER_TEMP/unresolved"; } fi done + # A fixed thread left open is staged again next round, and Claude answers it twice. + test ! -e "$RUNNER_TEMP/unresolved" - name: Summarise env: From ec3ffb11bae562b980bca5ce2ccd874947010ac4 Mon Sep 17 00:00:00 2001 From: giswqs Date: Mon, 26 Oct 2026 22:45:48 -0400 Subject: [PATCH 2/2] Address Claude review feedback - Skip a thread only when its last comment is the bot's own autofix reply, so a passer-by can't silence a thread by imitating one. - Post the summary whenever the push went through, even if a reply or resolve failed after it. - Resolve in a step of its own, so only those calls see the maintainer token. - Note why the threads to resolve go through a file. --- .github/workflows/claude-autofix.yml | 41 ++++++++++++++++++---------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/.github/workflows/claude-autofix.yml b/.github/workflows/claude-autofix.yml index ebf8f251e..24c747076 100644 --- a/.github/workflows/claude-autofix.yml +++ b/.github/workflows/claude-autofix.yml @@ -163,7 +163,8 @@ jobs: [ .[] | select(.isResolved | not) | select(.comments.nodes[0] | trusted) - | select(.comments.nodes[-1].body | startswith("**Claude autofix (") | not) + | select(.comments.nodes[-1] + | (.author.__typename == "Bot" and (.body | startswith("**Claude autofix ("))) | not) | { id, path, line: (.line // .originalLine), outdated: .isOutdated, comments: [ .comments.nodes[] | select(trusted) | { author: (.author.login // "ghost"), body } ] } ] @@ -433,18 +434,16 @@ jobs: echo "pushed=true" >> "$GITHUB_OUTPUT" # Replies go out only for threads that were staged, whatever ids the report holds. A thread - # Claude calls fixed is resolved only when a fix commit was pushed. Resolving takes the - # maintainer's token: GITHUB_TOKEN can reply to a thread but not resolve it ("Resource not - # accessible by integration"). - - name: Reply to and resolve the threads + # Claude calls fixed is resolved only when a fix commit was pushed. + - name: Reply to the threads if: needs.fix.outputs.threads != '0' env: GH_TOKEN: ${{ github.token }} - RESOLVE_TOKEN: ${{ secrets.AUTOMERGE_TOKEN }} DIR: ${{ runner.temp }}/autofix LANDED: ${{ needs.fix.outputs.fixed == 'true' && steps.push.outputs.pushed == 'true' }} run: | set -euo pipefail + : > "$RUNNER_TEMP/to-resolve" jq -c --slurpfile t "$DIR/threads.json" \ '($t[0] | map(.id)) as $ids | .[] | select(.id as $i | $ids | index($i)) | select(.status | IN("fixed", "declined", "unclear"))' \ @@ -464,17 +463,31 @@ jobs: comment { id } } }' --silent || echo "::warning::Couldn't reply to $id." - if [ "$status" = fixed ]; then - GH_TOKEN="$RESOLVE_TOKEN" gh api graphql -f thread="$id" -f query=' - mutation($thread: ID!) { - resolveReviewThread(input: {threadId: $thread}) { thread { id } } - }' --silent || { echo "::error::Couldn't resolve $id."; touch "$RUNNER_TEMP/unresolved"; } - fi + # The loop runs in a pipe's subshell, so the threads to resolve go to a file. + if [ "$status" = fixed ]; then echo "$id" >> "$RUNNER_TEMP/to-resolve"; fi done - # A fixed thread left open is staged again next round, and Claude answers it twice. - test ! -e "$RUNNER_TEMP/unresolved" + # Its own step so only these calls see the maintainer's token, which resolving takes: + # GITHUB_TOKEN can reply to a thread but not resolve it ("Resource not accessible by + # integration"). A fixed thread left open is staged again next round and answered twice, so + # a failed resolve fails the step. + - name: Resolve the fixed threads + if: needs.fix.outputs.threads != '0' + env: + GH_TOKEN: ${{ secrets.AUTOMERGE_TOKEN }} + run: | + failed=0 + while read -r id; do + gh api graphql -f thread="$id" -f query=' + mutation($thread: ID!) { + resolveReviewThread(input: {threadId: $thread}) { thread { id } } + }' --silent || { echo "::error::Couldn't resolve $id."; failed=1; } + done < "$RUNNER_TEMP/to-resolve" + exit "$failed" + + # Posted once the push went through, even when a reply or a resolve failed after it. - name: Summarise + if: ${{ !cancelled() && steps.push.outcome == 'success' }} env: GH_TOKEN: ${{ github.token }} PR: ${{ github.event.pull_request.html_url }}