coderabbit-rate-limit-retry #823
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: coderabbit-rate-limit-retry | |
| on: | |
| pull_request_target: | |
| types: [opened, synchronize, reopened] | |
| schedule: | |
| - cron: '*/20 * * * *' | |
| workflow_dispatch: | |
| permissions: | |
| checks: write | |
| contents: read | |
| pull-requests: write | |
| issues: write | |
| jobs: | |
| retrigger: | |
| name: retrigger-coderabbit-on-rate-limit | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Re-request CodeRabbit when backlog is high and check is stale | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const STALE_MINUTES = 20; | |
| const BYPASS_LABEL = "ci:coderabbit-bypass"; | |
| const GATE_CHECK_NAME = "CodeRabbit Gate"; | |
| const MARKER = "<!-- codex:coderabbit-rate-limit-retry -->"; | |
| const nowMs = Date.now(); | |
| async function listOpenPRs() { | |
| const all = await github.paginate(github.rest.pulls.list, { | |
| owner, | |
| repo, | |
| state: "open", | |
| per_page: 100, | |
| }); | |
| return all; | |
| } | |
| async function getCodeRabbitState(prNumber) { | |
| const checks = await github.graphql( | |
| `query($owner:String!,$repo:String!,$number:Int!){ | |
| repository(owner:$owner,name:$repo){ | |
| pullRequest(number:$number){ | |
| commits(last:1){ | |
| nodes{ | |
| commit{ | |
| statusCheckRollup{ | |
| contexts(first:50){ | |
| nodes{ | |
| __typename | |
| ... on CheckRun { | |
| name | |
| conclusion | |
| status | |
| completedAt | |
| } | |
| ... on StatusContext { | |
| context | |
| state | |
| createdAt | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| }`, | |
| { owner, repo, number: prNumber }, | |
| ); | |
| const nodes = checks.repository.pullRequest.commits.nodes[0]?.commit?.statusCheckRollup?.contexts?.nodes || []; | |
| for (const n of nodes) { | |
| if (n.__typename === "CheckRun" && n.name === "CodeRabbit") { | |
| return { | |
| state: (n.conclusion || n.status || "UNKNOWN").toUpperCase(), | |
| at: n.completedAt ? new Date(n.completedAt).getTime() : nowMs, | |
| }; | |
| } | |
| if (n.__typename === "StatusContext" && n.context === "CodeRabbit") { | |
| return { | |
| state: (n.state || "UNKNOWN").toUpperCase(), | |
| at: n.createdAt ? new Date(n.createdAt).getTime() : nowMs, | |
| }; | |
| } | |
| } | |
| return { state: "MISSING", at: nowMs }; | |
| } | |
| async function hasRecentRetryComment(prNumber) { | |
| const comments = await github.paginate(github.rest.issues.listComments, { | |
| owner, | |
| repo, | |
| issue_number: prNumber, | |
| per_page: 100, | |
| }); | |
| const latest = comments | |
| .filter((c) => c.user?.login === "github-actions[bot]" && c.body?.includes(MARKER)) | |
| .sort((a, b) => new Date(b.created_at) - new Date(a.created_at))[0]; | |
| if (!latest) return false; | |
| const ageMin = (nowMs - new Date(latest.created_at).getTime()) / 60000; | |
| return ageMin < STALE_MINUTES; | |
| } | |
| async function ensureBypassLabelExists() { | |
| try { | |
| await github.rest.issues.getLabel({ | |
| owner, | |
| repo, | |
| name: BYPASS_LABEL, | |
| }); | |
| } catch (error) { | |
| if (error.status !== 404) throw error; | |
| await github.rest.issues.createLabel({ | |
| owner, | |
| repo, | |
| name: BYPASS_LABEL, | |
| color: "B60205", | |
| description: "Temporary bypass for CodeRabbit rate-limit under high PR backlog.", | |
| }); | |
| } | |
| } | |
| async function hasLabel(prNumber, name) { | |
| const labels = await github.paginate(github.rest.issues.listLabelsOnIssue, { | |
| owner, | |
| repo, | |
| issue_number: prNumber, | |
| per_page: 100, | |
| }); | |
| return labels.some((l) => l.name === name); | |
| } | |
| async function setBypassLabel(prNumber, enable) { | |
| const present = await hasLabel(prNumber, BYPASS_LABEL); | |
| if (enable && !present) { | |
| await github.rest.issues.addLabels({ | |
| owner, | |
| repo, | |
| issue_number: prNumber, | |
| labels: [BYPASS_LABEL], | |
| }); | |
| core.notice(`PR #${prNumber}: applied label '${BYPASS_LABEL}'.`); | |
| } | |
| if (!enable && present) { | |
| await github.rest.issues.removeLabel({ | |
| owner, | |
| repo, | |
| issue_number: prNumber, | |
| name: BYPASS_LABEL, | |
| }); | |
| core.notice(`PR #${prNumber}: removed label '${BYPASS_LABEL}'.`); | |
| } | |
| } | |
| async function publishGate(pr, pass, summary) { | |
| await github.rest.checks.create({ | |
| owner, | |
| repo, | |
| name: GATE_CHECK_NAME, | |
| head_sha: pr.head.sha, | |
| status: "completed", | |
| conclusion: pass ? "success" : "failure", | |
| output: { | |
| title: pass ? "CodeRabbit gate passed" : "CodeRabbit gate blocked", | |
| summary, | |
| }, | |
| }); | |
| } | |
| async function processPR(pr) { | |
| const state = await getCodeRabbitState(pr.number); | |
| const ageMin = (nowMs - state.at) / 60000; | |
| const stateOk = state.state === "SUCCESS" || state.state === "NEUTRAL"; | |
| const stale = ageMin >= STALE_MINUTES; | |
| const bypassEligible = stale && !stateOk; | |
| await setBypassLabel(pr.number, bypassEligible); | |
| if (bypassEligible && !(await hasRecentRetryComment(pr.number))) { | |
| const body = [ | |
| MARKER, | |
| "@coderabbitai full review", | |
| "", | |
| `Automated retrigger: CodeRabbit state=${state.state}, age=${ageMin.toFixed(1)}m (stale after ${STALE_MINUTES}m).`, | |
| ].join("\n"); | |
| await github.rest.issues.createComment({ | |
| owner, | |
| repo, | |
| issue_number: pr.number, | |
| body, | |
| }); | |
| core.notice(`PR #${pr.number}: posted CodeRabbit retrigger comment.`); | |
| } | |
| const gatePass = stateOk || bypassEligible; | |
| const summary = [ | |
| `CodeRabbit state: ${state.state}`, | |
| `Age minutes: ${ageMin.toFixed(1)}`, | |
| `Stale threshold: ${STALE_MINUTES}m`, | |
| `Bypass eligible: ${bypassEligible}`, | |
| ].join("\n"); | |
| await publishGate(pr, gatePass, summary); | |
| } | |
| const openPRs = await listOpenPRs(); | |
| core.info(`Open PR count: ${openPRs.length}`); | |
| await ensureBypassLabelExists(); | |
| const targetPRs = context.eventName === "pull_request_target" | |
| ? openPRs.filter((p) => p.number === context.payload.pull_request.number) | |
| : openPRs; | |
| for (const pr of targetPRs) { | |
| await processPR(pr); | |
| } |