Skip to content

docs(plans): pod playbooks, a team's procedure as a pod workflow (TASK-274) #1952

docs(plans): pod playbooks, a team's procedure as a pod workflow (TASK-274)

docs(plans): pod playbooks, a team's procedure as a pod workflow (TASK-274) #1952

# 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<id>` link
# (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
# 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:
# `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, 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:
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#*/}"
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.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 ' '); 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 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/(?<id>[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