From 29fabb234a8470a163e68946eb3cb86cb0bd7f6e Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Sat, 9 May 2026 14:18:13 -0700 Subject: [PATCH] fix(rabbit): handle CR action acks + edited rate-limit comments; schedule via GH Action (#1425) --- .github/workflows/rabbit-retrigger.yml | 58 +++++++++++++++++++++ scripts/rabbit/cli.mjs | 71 ++++++++++++++++++-------- 2 files changed, 109 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/rabbit-retrigger.yml diff --git a/.github/workflows/rabbit-retrigger.yml b/.github/workflows/rabbit-retrigger.yml new file mode 100644 index 000000000..929bcbe23 --- /dev/null +++ b/.github/workflows/rabbit-retrigger.yml @@ -0,0 +1,58 @@ +--- +name: CodeRabbit Retrigger +# Periodically scans open PRs and posts `@coderabbitai review` on any whose +# rate-limit window has elapsed. Uses the same GitHub App credentials as +# release-production.yml so the comments are authored by the bot account. +on: + schedule: + # Every 20 minutes. CodeRabbit Pro reviews 5 PRs/hr, so this gives + # ~3 ticks per hour — enough to catch elapsed windows without thrashing. + - cron: "*/20 * * * *" + workflow_dispatch: + inputs: + max: + description: "Max retriggers this run (CR Pro = 5/hr)" + required: false + default: "5" + dry_run: + description: "Print what would be done; post nothing" + type: boolean + required: false + default: false +permissions: + contents: read + pull-requests: write + issues: write +concurrency: + group: rabbit-retrigger + cancel-in-progress: false +jobs: + retrigger: + name: Retrigger CodeRabbit + runs-on: ubuntu-22.04 + steps: + - name: Generate GitHub App token + id: app-token + uses: tibdex/github-app-token@v1 + with: + app_id: ${{ secrets.XGITHUB_APP_ID }} + private_key: ${{ secrets.XGITHUB_APP_PRIVATE_KEY }} + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 1 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24.x + - name: Run rabbit + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + RABBIT_REPO: ${{ github.repository }} + run: | + set -e + ARGS=(run --max "${{ inputs.max || '5' }}") + if [ "${{ inputs.dry_run }}" = "true" ]; then + ARGS+=(--dry-run) + fi + node scripts/rabbit/cli.mjs "${ARGS[@]}" diff --git a/scripts/rabbit/cli.mjs b/scripts/rabbit/cli.mjs index a2b2d0691..ee8074f73 100755 --- a/scripts/rabbit/cli.mjs +++ b/scripts/rabbit/cli.mjs @@ -14,8 +14,17 @@ import { execFileSync } from "node:child_process"; const RATE_LIMIT_MARKER = "rate limited by coderabbit.ai"; +// CR's review summaries carry this marker; rate-limit comments also include it +// alongside RATE_LIMIT_MARKER, so always check rate-limit first. +const REVIEW_SUMMARY_MARKER = "summarize by coderabbit.ai"; +// "Actions performed" acks (e.g. response to `@coderabbitai review`) carry this +// marker but no review content — they must NOT count as recovery. +const ACTION_ACK_MARKER = "auto-generated reply by CodeRabbit"; const RETRIGGER_BODY = "@coderabbitai review"; const CR_LOGINS = new Set(["coderabbitai", "coderabbitai[bot]"]); +// If we already posted `@coderabbitai review` but CR has only ack'd (or stayed +// silent) for this long, assume CR was secondarily rate-limited and retry. +const STALE_RETRIGGER_SEC = 10 * 60; function gh(args, { input } = {}) { return execFileSync("gh", args, { @@ -143,25 +152,43 @@ function analyzePr(comments, graceSec) { if (!latestRateLimit) return { status: "no-rate-limit" }; - // If CR has already posted a non-rate-limit comment AFTER the rate limit, - // it has effectively recovered — skip. - if ( - latestCr !== latestRateLimit && - new Date(latestCr.created_at) > new Date(latestRateLimit.created_at) && - !latestCr.body.includes(RATE_LIMIT_MARKER) - ) { - return { status: "review-since" }; - } - - // If anyone (us or otherwise) has already posted `@coderabbitai review` - // after the rate limit, don't double-trigger. - const retriggerSince = comments.find( + // CR has effectively recovered ONLY if a real review summary landed after + // the rate-limit. The `summarize by coderabbit.ai` marker uniquely identifies + // walkthrough/review comments; rate-limit comments include it too, so also + // require absence of the rate-limit marker. "Actions performed" acks carry + // the ACTION_ACK_MARKER instead and must not count as recovery. + // Anchor "since" comparisons to whichever is later: the rate-limit comment's + // creation or its last edit. CR edits the same comment to refresh the wait, + // so a comment created before the latest edit is no longer evidence of + // recovery. + const limitSinceTs = new Date( + latestRateLimit.updated_at || latestRateLimit.created_at, + ); + const realReviewSince = crComments.find( (c) => - new Date(c.created_at) > new Date(latestRateLimit.created_at) && + new Date(c.created_at) > limitSinceTs && + c.body.includes(REVIEW_SUMMARY_MARKER) && + !c.body.includes(RATE_LIMIT_MARKER), + ); + if (realReviewSince) return { status: "review-since" }; + + // If anyone has posted `@coderabbitai review` since the rate limit AND it's + // recent, don't double-trigger. If it's stale and CR still hasn't posted a + // real review, CR was likely silently rate-limited again — fall through and + // retrigger. + const retriggersSince = comments.filter( + (c) => + new Date(c.created_at) > limitSinceTs && c.body.trim().toLowerCase().startsWith(RETRIGGER_BODY), ); - if (retriggerSince) { - return { status: "already-retriggered", at: retriggerSince.created_at }; + const lastRetrigger = retriggersSince[retriggersSince.length - 1]; + if (lastRetrigger) { + const ageSec = + (Date.now() - new Date(lastRetrigger.created_at).getTime()) / 1000; + if (ageSec < STALE_RETRIGGER_SEC) { + return { status: "already-retriggered", at: lastRetrigger.created_at }; + } + // Stale retrigger with no real review since — fall through to retrigger. } const waitSec = parseWaitSeconds(latestRateLimit.body); @@ -169,22 +196,26 @@ function analyzePr(comments, graceSec) { return { status: "ready", reason: "unparseable wait — assuming elapsed" }; } + // CR edits the same rate-limit comment on each retry instead of posting a + // fresh one — it rewrites the wait timer and bumps `updated_at`. Anchor the + // expiry to the latest update, not the original post, otherwise stale waits + // always look elapsed and we trigger straight into a closed window. + const limitAnchor = latestRateLimit.updated_at || latestRateLimit.created_at; const expiresAt = - new Date(latestRateLimit.created_at).getTime() + - (waitSec + graceSec) * 1000; + new Date(limitAnchor).getTime() + (waitSec + graceSec) * 1000; const now = Date.now(); if (now < expiresAt) { return { status: "waiting", remainingSec: Math.ceil((expiresAt - now) / 1000), - ratedLimitedAt: latestRateLimit.created_at, + ratedLimitedAt: limitAnchor, }; } return { status: "ready", waitSec, - ratedLimitedAt: latestRateLimit.created_at, + ratedLimitedAt: limitAnchor, }; }