# 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 from anyone but CodeRabbit, the only form CodeRabbit reads. A PR # comment never counts, not even one quoting the thread's link (D199, # 2026-10-30): seats had turned those into letters CodeRabbit never sees. A # review body never counts either. 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, so the result always lands on the head commit. # NOT a required check. It never checks out PR code, so the write token only # reaches the statuses API. name: CodeRabbit threads on: pull_request: types: [opened, synchronize, reopened, ready_for_review] pull_request_review: # `deleted` too: removing the only reply must re-fail the status rather # than leave a stale success on the head. pull_request_review_comment: types: [created, edited, deleted] permissions: contents: read pull-requests: read statuses: write # No `concurrency:` on purpose (TASK-472). Runs started by a push, review or # review comment attach their `count` check to the PR head, and a run that a # concurrency group cancels, or a pending one it replaces, stays on the head as # a CANCELLED check that reads as red after the threads are answered. Each run # reads the threads fresh and finishes in seconds, so order is settled at post # time instead: a run skips posting when the head's status already came from a # newer run (a higher run id started later and read newer thread state). jobs: count: runs-on: ubuntu-latest timeout-minutes: 3 env: GH_TOKEN: ${{ github.token }} PR: ${{ github.event.pull_request.number }} REPO: ${{ github.repository }} steps: - name: Count unanswered CodeRabbit threads run: | set -euo pipefail owner="${REPO%/*}"; name="${REPO#*/}" gh api "repos/$REPO/pulls/$PR" > pr.json # Fork PRs get no action at all (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 from anyone but CodeRabbit. PR # comments and review bodies are not read at all. A deleted account # comes back as `author: null`; `// ""` keeps jq from exiting before # the status posts, and counts that reply as an answer. 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.pageInfo.hasNextPage) \(.comments.nodes[0].url)"' threads.json > open.txt : > unanswered.txt; : > incomplete.txt while read -r _id more url; do if [ "$more" = true ]; then echo "$url" >> incomplete.txt; else echo "$url" >> unanswered.txt; fi done < open.txt 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 inside each thread" elif [ "$m" != 0 ]; then state=failure; desc="$m CodeRabbit thread(s) too long to read fully: check them by hand" else state=success; desc="All CodeRabbit threads answered"; fi echo "### $desc" >> "$GITHUB_STEP_SUMMARY" sed 's/^/- /' unanswered.txt incomplete.txt >> "$GITHUB_STEP_SUMMARY" cat unanswered.txt incomplete.txt # Newest run wins: the status's target_url carries the run id that posted it. posted=$(gh api "repos/$REPO/commits/$head/status" \ -q '[.statuses[] | select(.context == "coderabbit-threads" and .target_url != null) | .target_url | capture("/runs/(?[0-9]+)").id | tonumber] | max // 0') if [ "$posted" -gt "$GITHUB_RUN_ID" ]; then echo "Run $posted already posted a newer result on $head; not overwriting it."; exit 0 fi 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