fix(rabbit): handle CR action acks + edited rate-limit comments; schedule via GH Action (#1425)

This commit is contained in:
Steven Enamakel
2026-05-09 14:18:13 -07:00
committed by GitHub
parent f1b60e4b51
commit 29fabb234a
2 changed files with 109 additions and 20 deletions
+58
View File
@@ -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[@]}"
+51 -20
View File
@@ -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,
};
}