From 2b97cf6b100b4b165642e794a36ef89f9b8e0e86 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:30:16 -0700 Subject: [PATCH 1/2] ci: coderabbit-threads status counts unanswered CodeRabbit threads (#2605 rule) A non-required commit status on the PR head. It counts CodeRabbit review threads that are unresolved, not outdated, have no in-thread reply from anyone else, and whose discussion_r link no PR comment or review body quotes. Fails above 0 and lists the links in the run summary. It runs on PR pushes, reviews, review comments and PR comments. It is a status, not a check, because comment-triggered runs attach to the default branch. It never checks out PR code. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V7T2fgquLRaqaYiGLC6Dey --- .github/workflows/coderabbit-threads.yml | 74 ++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 .github/workflows/coderabbit-threads.yml diff --git a/.github/workflows/coderabbit-threads.yml b/.github/workflows/coderabbit-threads.yml new file mode 100644 index 0000000000..8c2d62832e --- /dev/null +++ b/.github/workflows/coderabbit-threads.yml @@ -0,0 +1,74 @@ +# CodeRabbit threads: every review thread CodeRabbit opens gets an answer +# before a gate ask (Sam, 2026-10-24, #2605). An answer is a reply inside the +# thread, or a PR comment that quotes the thread's `discussion_r` link +# (seats reply that way when they cannot reply in-thread). Resolved and +# outdated threads don't count. +# +# Reported as the commit status `coderabbit-threads` on the PR head, not as +# this workflow's own check: a run started by a comment attaches to the +# default branch and would never show on the PR. NOT a required check. It +# never checks out PR code, so the write token on comment events only reaches +# the statuses API. +name: CodeRabbit threads + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + pull_request_review: + pull_request_review_comment: + issue_comment: + types: [created, edited] + +permissions: + contents: read + pull-requests: read + statuses: write + +concurrency: + group: coderabbit-threads-${{ github.event.pull_request.number || github.event.issue.number }} + cancel-in-progress: true + +jobs: + count: + if: github.event_name != 'issue_comment' || github.event.issue.pull_request + runs-on: ubuntu-latest + timeout-minutes: 3 + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number || github.event.issue.number }} + REPO: ${{ github.repository }} + steps: + - name: Count unanswered CodeRabbit threads + run: | + set -euo pipefail + owner="${REPO%/*}"; name="${REPO#*/}" + head=$(gh api "repos/$REPO/pulls/$PR" -q .head.sha) + # 100 threads x 50 comments covers every PR on this repo so far; a + # page beyond that is reported, not silently dropped. + gh api graphql -F owner="$owner" -F name="$name" -F pr="$PR" -f query=' + query($owner:String!,$name:String!,$pr:Int!){repository(owner:$owner,name:$name){pullRequest(number:$pr){ + reviewThreads(first:100){pageInfo{hasNextPage} nodes{isResolved isOutdated + comments(first:50){nodes{author{login} databaseId url}}}}}}}' > threads.json + # Quotes count from PR comments and review bodies, by anyone. + { gh api --paginate "repos/$REPO/issues/$PR/comments" -q '.[].body' + gh api --paginate "repos/$REPO/pulls/$PR/reviews" -q '.[].body'; } > said.txt || true + jq -r '.data.repository.pullRequest.reviewThreads.nodes[] + | select(.isResolved|not) | select(.isOutdated|not) + | select(.comments.nodes[0].author.login | test("^coderabbitai")) + | select([.comments.nodes[1:][] | select(.author.login | test("^coderabbitai")|not)] | length == 0) + | "\(.comments.nodes[0].databaseId) \(.comments.nodes[0].url)"' threads.json > open.txt + : > unanswered.txt + while read -r id url; do + grep -q "discussion_r$id" said.txt || echo "$url" >> unanswered.txt + done < open.txt + n=$(wc -l < unanswered.txt | tr -d ' ') + more=$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage' threads.json) + if [ "$n" = 0 ]; then state=success; desc="All CodeRabbit threads answered" + else state=failure; desc="$n unanswered CodeRabbit thread(s): reply in-thread or quote the link"; fi + [ "$more" = true ] && desc="$desc (over 100 threads: first page only)" + echo "### $desc" >> "$GITHUB_STEP_SUMMARY" + sed 's/^/- /' unanswered.txt >> "$GITHUB_STEP_SUMMARY" + cat unanswered.txt + gh api -X POST "repos/$REPO/statuses/$head" -f state="$state" -f context=coderabbit-threads \ + -f description="${desc:0:140}" -f target_url="$GITHUB_SERVER_URL/$REPO/actions/runs/$GITHUB_RUN_ID" > /dev/null \ + || echo "::warning::could not post the status (a fork PR's token is read-only); the count is above" From 874dc4acf36af22d592cb3642b4f4bfd4913309a Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:45:44 -0700 Subject: [PATCH 2/2] =?UTF-8?q?fix(ci):=20coderabbit-threads=20=E2=80=94?= =?UTF-8?q?=20sable's=20HOLD=20at=202b97cf6b=20+=20CodeRabbit's=20deleted-?= =?UTF-8?q?comment=20case?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Review bodies no longer count as answers: a HOLD citing an unanswered thread quotes its link, and that must leave it unanswered. - Fork PRs exit before counting or posting, decided from the PR's head.repo, not from a token error. - Thread pages are read with --paginate; a thread whose replies run past 100 with no visible reply reports 'too long to read fully' (failure), never success. - issue_comment and pull_request_review_comment also fire on deleted. Fixture-tested with a fake gh (10 cases incl. sable's three failures) and read-only against #2601/#2589/#2607. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V7T2fgquLRaqaYiGLC6Dey --- .github/workflows/coderabbit-threads.yml | 66 ++++++++++++++---------- 1 file changed, 39 insertions(+), 27 deletions(-) diff --git a/.github/workflows/coderabbit-threads.yml b/.github/workflows/coderabbit-threads.yml index 8c2d62832e..8abbd5c223 100644 --- a/.github/workflows/coderabbit-threads.yml +++ b/.github/workflows/coderabbit-threads.yml @@ -1,8 +1,9 @@ # CodeRabbit threads: every review thread CodeRabbit opens gets an answer # before a gate ask (Sam, 2026-10-24, #2605). An answer is a reply inside the # thread, or a PR comment that quotes the thread's `discussion_r` link -# (seats reply that way when they cannot reply in-thread). Resolved and -# outdated threads don't count. +# (seats reply that way when they cannot reply in-thread). A review body +# never counts, because a HOLD cites the unanswered link. Resolved and +# outdated threads don't count. Fork PRs are skipped entirely. # # Reported as the commit status `coderabbit-threads` on the PR head, not as # this workflow's own check: a run started by a comment attaches to the @@ -15,9 +16,12 @@ on: pull_request: types: [opened, synchronize, reopened, ready_for_review] pull_request_review: + # `deleted` too: removing the only reply or quoting comment must re-fail + # the status rather than leave a stale success on the head. pull_request_review_comment: + types: [created, edited, deleted] issue_comment: - types: [created, edited] + types: [created, edited, deleted] permissions: contents: read @@ -42,33 +46,41 @@ jobs: run: | set -euo pipefail owner="${REPO%/*}"; name="${REPO#*/}" - head=$(gh api "repos/$REPO/pulls/$PR" -q .head.sha) - # 100 threads x 50 comments covers every PR on this repo so far; a - # page beyond that is reported, not silently dropped. - gh api graphql -F owner="$owner" -F name="$name" -F pr="$PR" -f query=' - query($owner:String!,$name:String!,$pr:Int!){repository(owner:$owner,name:$name){pullRequest(number:$pr){ - reviewThreads(first:100){pageInfo{hasNextPage} nodes{isResolved isOutdated - comments(first:50){nodes{author{login} databaseId url}}}}}}}' > threads.json - # Quotes count from PR comments and review bodies, by anyone. - { gh api --paginate "repos/$REPO/issues/$PR/comments" -q '.[].body' - gh api --paginate "repos/$REPO/pulls/$PR/reviews" -q '.[].body'; } > said.txt || true - jq -r '.data.repository.pullRequest.reviewThreads.nodes[] + gh api "repos/$REPO/pulls/$PR" > pr.json + # Fork PRs get no action at all (a comment event runs with a write + # token on the default branch, so don't rely on a token error). + if [ "$(jq -r .head.repo.full_name pr.json)" != "$REPO" ]; then + echo "Fork PR: not counted, no status posted."; exit 0 + fi + head=$(jq -r .head.sha pr.json) + # Every thread page; replies are read 100 deep per thread, and a + # thread with more than that and no visible reply is counted as + # INCOMPLETE rather than answered or unanswered. + gh api graphql --paginate --slurp -F owner="$owner" -F name="$name" -F pr="$PR" -f query=' + query($owner:String!,$name:String!,$pr:Int!,$endCursor:String){repository(owner:$owner,name:$name){pullRequest(number:$pr){ + reviewThreads(first:100,after:$endCursor){pageInfo{hasNextPage endCursor} nodes{isResolved isOutdated + comments(first:100){pageInfo{hasNextPage} nodes{author{login} databaseId url}}}}}}}' \ + | jq '[.[].data.repository.pullRequest.reviewThreads.nodes[]]' > threads.json + # An answer is an in-thread reply or a PR COMMENT quoting the link. + # Review bodies don't count: a HOLD citing an unanswered thread + # quotes its link, and that must leave it unanswered. + gh api --paginate "repos/$REPO/issues/$PR/comments" -q '.[].body' > said.txt + jq -r '.[] | select(.isResolved|not) | select(.isOutdated|not) | select(.comments.nodes[0].author.login | test("^coderabbitai")) | select([.comments.nodes[1:][] | select(.author.login | test("^coderabbitai")|not)] | length == 0) - | "\(.comments.nodes[0].databaseId) \(.comments.nodes[0].url)"' threads.json > open.txt - : > unanswered.txt - while read -r id url; do - grep -q "discussion_r$id" said.txt || echo "$url" >> unanswered.txt + | "\(.comments.nodes[0].databaseId) \(.comments.pageInfo.hasNextPage) \(.comments.nodes[0].url)"' threads.json > open.txt + : > unanswered.txt; : > incomplete.txt + while read -r id more url; do + grep -q "discussion_r$id" said.txt && continue + if [ "$more" = true ]; then echo "$url" >> incomplete.txt; else echo "$url" >> unanswered.txt; fi done < open.txt - n=$(wc -l < unanswered.txt | tr -d ' ') - more=$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage' threads.json) - if [ "$n" = 0 ]; then state=success; desc="All CodeRabbit threads answered" - else state=failure; desc="$n unanswered CodeRabbit thread(s): reply in-thread or quote the link"; fi - [ "$more" = true ] && desc="$desc (over 100 threads: first page only)" + n=$(wc -l < unanswered.txt | tr -d ' '); m=$(wc -l < incomplete.txt | tr -d ' ') + if [ "$n" != 0 ]; then state=failure; desc="$n unanswered CodeRabbit thread(s): reply in-thread or quote the link in a PR comment" + elif [ "$m" != 0 ]; then state=failure; desc="$m CodeRabbit thread(s) too long to read fully: answer them in a PR comment" + else state=success; desc="All CodeRabbit threads answered"; fi echo "### $desc" >> "$GITHUB_STEP_SUMMARY" - sed 's/^/- /' unanswered.txt >> "$GITHUB_STEP_SUMMARY" - cat unanswered.txt + sed 's/^/- /' unanswered.txt incomplete.txt >> "$GITHUB_STEP_SUMMARY" + cat unanswered.txt incomplete.txt gh api -X POST "repos/$REPO/statuses/$head" -f state="$state" -f context=coderabbit-threads \ - -f description="${desc:0:140}" -f target_url="$GITHUB_SERVER_URL/$REPO/actions/runs/$GITHUB_RUN_ID" > /dev/null \ - || echo "::warning::could not post the status (a fork PR's token is read-only); the count is above" + -f description="${desc:0:140}" -f target_url="$GITHUB_SERVER_URL/$REPO/actions/runs/$GITHUB_RUN_ID" > /dev/null