From acb6549c71645eb372fe683d2e49a01370a888bd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 23:27:09 +0000 Subject: [PATCH 1/6] =?UTF-8?q?ci:=20stale-PR=20automation=20=E2=80=94=20b?= =?UTF-8?q?estaxbot=20closer=20+=20generic=20stale=20(#275)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two zero-Claude workflows mirroring oven-sh/bun: a daily closer for bestaxbot PRs inactive 90+ days (strips loop labels before closing, per the on-slop.yml pattern), and actions/stale (pinned to v9.1.0 by commit SHA) marking PRs stale at 30 days and closing 14 days later, with a neverstale exemption label. Issues are deliberately excluded from stale processing. Docs: label table + CLAUDE.md note. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Pohc8xLkdx4gwXkW3xd7up --- .../workflows/close-stale-bestaxbot-prs.yml | 55 ++++++++++++++++++ .github/workflows/stale.yml | 57 +++++++++++++++++++ CLAUDE.md | 2 + .../guides/getting-started/ai-development.md | 2 + 4 files changed, 116 insertions(+) create mode 100644 .github/workflows/close-stale-bestaxbot-prs.yml create mode 100644 .github/workflows/stale.yml diff --git a/.github/workflows/close-stale-bestaxbot-prs.yml b/.github/workflows/close-stale-bestaxbot-prs.yml new file mode 100644 index 000000000..a2f45d5d5 --- /dev/null +++ b/.github/workflows/close-stale-bestaxbot-prs.yml @@ -0,0 +1,55 @@ +name: Close Stale bestaxbot PRs + +# Closes bot-authored (bestaxbot) PRs untouched for 90 days. Mirrors +# oven-sh/bun's close-stale-robobun-prs.yml. Zero Claude usage — plain +# shell + gh, so it can never spend AI budget or act on untrusted content. +# +# Loop labels (ai-loop / ai-loop-paused / needs-human-review) are stripped +# BEFORE the close (same pattern as on-slop.yml) so the PR exits the AI +# loop's state machine cleanly instead of lingering as a closed-but-labeled +# zombie the watchdog might still try to sweep. + +on: + schedule: + # 03:41 UTC daily — deliberately offset from the loop watchdog's + # `23 */2 * * *` so the closer never races a sweep re-dispatch. + - cron: '41 3 * * *' + workflow_dispatch: + +# No workflow-level grants; the single job takes exactly what it needs. +permissions: {} + +jobs: + close: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + pull-requests: write # comment, edit labels, close + issues: write # issue-comment API for the PR conversation + steps: + - name: Close bestaxbot PRs inactive for 90+ days + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + CUTOFF=$(date -u -d '90 days ago' +%Y-%m-%dT%H:%M:%SZ) + echo "Closing bestaxbot PRs not updated since $CUTOFF" + + gh pr list --repo "$REPO" --author bestaxbot --state open \ + --json number,updatedAt \ + | jq -r --arg cutoff "$CUTOFF" \ + '.[] | select(.updatedAt < $cutoff) | .number' \ + | while read -r PR; do + echo "Closing stale PR #$PR" + gh pr comment "$PR" --repo "$REPO" --body "Closing this PR after 90 days of inactivity. If it's still wanted, reopen it — or restart the loop per the instructions in the PR's \`ai-loop-state\` status comment." + + # Strip loop labels FIRST so the PR leaves the loop's state + # machine cleanly; ignore any that are not present. + gh pr edit "$PR" --repo "$REPO" \ + --remove-label ai-loop \ + --remove-label ai-loop-paused \ + --remove-label needs-human-review || true + + gh pr close "$PR" --repo "$REPO" + done diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 000000000..a583b7a6c --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,57 @@ +name: Stale + +# Marks PRs with no activity for 30 days as `stale`, then closes them 14 +# days later if the inactivity continues. Mirrors oven-sh/bun's stale.yaml. +# Zero Claude usage — just actions/stale. +# +# Escape hatch: a maintainer applies the `neverstale` label to exempt a PR +# permanently. Any activity (commit, comment, review) also removes `stale` +# and resets the clock. +# +# Labels: `stale` is auto-created by actions/stale on first application +# (suggested color #ededed). `neverstale` (#c2e0c6) is human-applied and +# created at rollout. +# +# Issues are deliberately NOT processed (a deviation from bun, whose issue +# side keys off a needs-repro/waiting-for-author triage process this repo +# doesn't run). + +on: + schedule: + # 04:17 UTC daily — a distinct minute from both the loop watchdog + # (`23 */2 * * *`) and the bestaxbot closer (`41 3 * * *`). + - cron: '17 4 * * *' + workflow_dispatch: + +# No workflow-level grants; the single job takes exactly what it needs. +permissions: {} + +jobs: + stale: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + pull-requests: write # label, comment, close PRs + issues: write # actions/stale needs both even with issues disabled + steps: + - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0 + with: + days-before-pr-stale: 30 + days-before-pr-close: 14 + stale-pr-label: stale + exempt-pr-labels: neverstale + remove-stale-when-updated: true + stale-pr-message: > + This PR has had no activity for 30 days and has been marked + `stale`. It will close in 14 days unless activity resumes — any + commit, comment, or review removes the label and resets the + clock. If this PR should never be swept, ask a maintainer to + apply the `neverstale` label. + close-pr-message: > + Closing after 14 further days of inactivity. If this is still + wanted, reopen it or open a fresh PR — and consider asking a + maintainer for the `neverstale` label to exempt it from stale + automation. + # Issues disabled — see the header comment. + days-before-issue-stale: -1 + days-before-issue-close: -1 diff --git a/CLAUDE.md b/CLAUDE.md index 29ee78905..e4a80f73d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,3 +92,5 @@ deep review on it (once ever per PR). Kill switches: remove `ai-loop` (per PR) o variable `AI_LOOP_ENABLED=false` (whole system). The `` PR comment is machine-managed — never reformat its first line. The loop refuses PRs that touch `.github/**` or the jest/commitlint/release/pnpm-workspace configs. +Stale automation: bestaxbot PRs close after 90 days of inactivity; all PRs go `stale` at 30 days +and close 14 days later; `neverstale` exempts a PR. diff --git a/docs/docs/guides/getting-started/ai-development.md b/docs/docs/guides/getting-started/ai-development.md index 04930a671..14aad5de0 100644 --- a/docs/docs/guides/getting-started/ai-development.md +++ b/docs/docs/guides/getting-started/ai-development.md @@ -54,6 +54,8 @@ PRs authored by the loop move through a small label lifecycle: | `needs-human-review` | PRs | The loop converged (or hit a disagreement) — awaiting maintainer review/merge | | `ai-loop-paused` | PRs | The loop hit its iteration cap or a guardrail — a maintainer must intervene | | `deep-review` | PRs | Opt-in: a triage+ user applies it to run the one-shot Claude deep review on any PR (once ever per PR) | +| `stale` | PRs | Auto-applied after 30 days of inactivity; closes 14 days later unless activity resumes | +| `neverstale` | PRs | Exempts a PR from stale automation | The loop itself: Claude implements the issue and opens the PR → CodeRabbit reviews it and a second, independent Claude review (a stronger model than the implementer) does a deep pass → From f49942a78a0e0141ba256d6f46996049c9dd58df Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 23:39:56 +0000 Subject: [PATCH 2/6] ci: exempt neverstale-labeled PRs from the bestaxbot stale closer (#275) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The neverstale label documents "exempts a PR from stale automation" — that must include the 90-day bot sweep, not just the generic stale.yml layer. Fetch labels in gh pr list and filter them out in jq. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Pohc8xLkdx4gwXkW3xd7up --- .github/workflows/close-stale-bestaxbot-prs.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/close-stale-bestaxbot-prs.yml b/.github/workflows/close-stale-bestaxbot-prs.yml index a2f45d5d5..14d51f356 100644 --- a/.github/workflows/close-stale-bestaxbot-prs.yml +++ b/.github/workflows/close-stale-bestaxbot-prs.yml @@ -4,6 +4,10 @@ name: Close Stale bestaxbot PRs # oven-sh/bun's close-stale-robobun-prs.yml. Zero Claude usage — plain # shell + gh, so it can never spend AI budget or act on untrusted content. # +# PRs labeled `neverstale` are exempt from this closer too — the label means +# "exempt from stale automation", and that covers the 90-day bot sweep as +# well as the generic 30/14-day stale.yml layer. +# # Loop labels (ai-loop / ai-loop-paused / needs-human-review) are stripped # BEFORE the close (same pattern as on-slop.yml) so the PR exits the AI # loop's state machine cleanly instead of lingering as a closed-but-labeled @@ -37,9 +41,9 @@ jobs: echo "Closing bestaxbot PRs not updated since $CUTOFF" gh pr list --repo "$REPO" --author bestaxbot --state open \ - --json number,updatedAt \ + --json number,updatedAt,labels \ | jq -r --arg cutoff "$CUTOFF" \ - '.[] | select(.updatedAt < $cutoff) | .number' \ + '.[] | select(.updatedAt < $cutoff) | select(([.labels[].name] | index("neverstale")) | not) | .number' \ | while read -r PR; do echo "Closing stale PR #$PR" gh pr comment "$PR" --repo "$REPO" --body "Closing this PR after 90 days of inactivity. If it's still wanted, reopen it — or restart the loop per the instructions in the PR's \`ai-loop-state\` status comment." From 973ce0e665ce3a8a78a3dbda085b48882d958b29 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 02:03:49 +0000 Subject: [PATCH 3/6] ci: exempt claude-assisted PRs from the 30/14 stale sweep; widen the 90-day closer (#275) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the deep-review Major on PR #279: the generic stale.yml closed every PR at day 44, so the dedicated 90-day bestaxbot closer could never fire and the documented 90-day bot leash was inaccurate. actions/stale has no author exemption, so the fix keys off the claude-assisted label that auto-label-claude-prs.yml (PR #276) applies: - stale.yml adds claude-assisted to exempt-pr-labels, so AI-assisted PRs skip the 30/14 sweep. - close-stale-bestaxbot-prs.yml now sweeps the union of author:bestaxbot and label:claude-assisted (deduped), so labeled human-authored Claude-assisted PRs don't escape both layers, and pre-labeler bot PRs are still covered by the author query. - CLAUDE.md and the docs label table describe the two-layer split. Until #276 merges and labels new bot PRs, unlabeled bestaxbot PRs remain under the generic 30/14 sweep — an acceptable transition state. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0142miDJt2AX9QUw5fNJt2xu --- .../workflows/close-stale-bestaxbot-prs.yml | 42 +++++++++++++------ .github/workflows/stale.yml | 8 +++- CLAUDE.md | 5 ++- .../guides/getting-started/ai-development.md | 18 ++++---- 4 files changed, 48 insertions(+), 25 deletions(-) diff --git a/.github/workflows/close-stale-bestaxbot-prs.yml b/.github/workflows/close-stale-bestaxbot-prs.yml index 14d51f356..2ba49139b 100644 --- a/.github/workflows/close-stale-bestaxbot-prs.yml +++ b/.github/workflows/close-stale-bestaxbot-prs.yml @@ -1,12 +1,21 @@ -name: Close Stale bestaxbot PRs +name: Close Stale Claude-Assisted PRs -# Closes bot-authored (bestaxbot) PRs untouched for 90 days. Mirrors -# oven-sh/bun's close-stale-robobun-prs.yml. Zero Claude usage — plain -# shell + gh, so it can never spend AI budget or act on untrusted content. +# Closes Claude-assisted PRs (bestaxbot-authored or labeled +# `claude-assisted`) untouched for 90 days. Mirrors oven-sh/bun's +# close-stale-robobun-prs.yml. Zero Claude usage — plain shell + gh, so it +# can never spend AI budget or act on untrusted content. +# +# This is the ONLY closer for these PRs: stale.yml exempts the +# `claude-assisted` label from its generic 30/14-day sweep, so AI-assisted +# PRs get the longer leash (they routinely sit waiting on human review). +# The sweep is the union of author:bestaxbot and label:claude-assisted — +# the label (auto-applied by auto-label-claude-prs.yml) also covers +# human-authored PRs with Claude attribution, and the author query covers +# bot PRs opened before the labeler existed. # # PRs labeled `neverstale` are exempt from this closer too — the label means -# "exempt from stale automation", and that covers the 90-day bot sweep as -# well as the generic 30/14-day stale.yml layer. +# "exempt from stale automation", and that covers the 90-day sweep as well +# as the generic 30/14-day stale.yml layer. # # Loop labels (ai-loop / ai-loop-paused / needs-human-review) are stripped # BEFORE the close (same pattern as on-slop.yml) so the PR exits the AI @@ -31,22 +40,29 @@ jobs: pull-requests: write # comment, edit labels, close issues: write # issue-comment API for the PR conversation steps: - - name: Close bestaxbot PRs inactive for 90+ days + - name: Close Claude-assisted PRs inactive for 90+ days env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} run: | set -euo pipefail CUTOFF=$(date -u -d '90 days ago' +%Y-%m-%dT%H:%M:%SZ) - echo "Closing bestaxbot PRs not updated since $CUTOFF" + echo "Closing Claude-assisted PRs not updated since $CUTOFF" - gh pr list --repo "$REPO" --author bestaxbot --state open \ - --json number,updatedAt,labels \ - | jq -r --arg cutoff "$CUTOFF" \ - '.[] | select(.updatedAt < $cutoff) | select(([.labels[].name] | index("neverstale")) | not) | .number' \ + # Union of the two selectors, deduped by PR number. A label with + # no matches (or that doesn't exist yet) yields an empty list from + # `gh pr list`, not an error. + { + gh pr list --repo "$REPO" --author bestaxbot --state open \ + --json number,updatedAt,labels + gh pr list --repo "$REPO" --label claude-assisted --state open \ + --json number,updatedAt,labels + } \ + | jq -rs --arg cutoff "$CUTOFF" \ + 'add | unique_by(.number) | .[] | select(.updatedAt < $cutoff) | select(([.labels[].name] | index("neverstale")) | not) | .number' \ | while read -r PR; do echo "Closing stale PR #$PR" - gh pr comment "$PR" --repo "$REPO" --body "Closing this PR after 90 days of inactivity. If it's still wanted, reopen it — or restart the loop per the instructions in the PR's \`ai-loop-state\` status comment." + gh pr comment "$PR" --repo "$REPO" --body "Closing this PR after 90 days of inactivity. If it's still wanted, reopen it — for AI-loop PRs, you can instead restart the loop per the instructions in the PR's \`ai-loop-state\` status comment." # Strip loop labels FIRST so the PR leaves the loop's state # machine cleanly; ignore any that are not present. diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index a583b7a6c..cca7f72e0 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -8,6 +8,12 @@ name: Stale # permanently. Any activity (commit, comment, review) also removes `stale` # and resets the clock. # +# Claude-assisted PRs (the `claude-assisted` label, auto-applied by +# auto-label-claude-prs.yml) are exempt from this sweep: they get the longer +# 90-day leash enforced by close-stale-bestaxbot-prs.yml instead, since they +# routinely sit waiting on human review. Without this exemption the 30/14 +# sweep would close them at day 44 and the 90-day closer could never fire. +# # Labels: `stale` is auto-created by actions/stale on first application # (suggested color #ededed). `neverstale` (#c2e0c6) is human-applied and # created at rollout. @@ -39,7 +45,7 @@ jobs: days-before-pr-stale: 30 days-before-pr-close: 14 stale-pr-label: stale - exempt-pr-labels: neverstale + exempt-pr-labels: neverstale,claude-assisted remove-stale-when-updated: true stale-pr-message: > This PR has had no activity for 30 days and has been marked diff --git a/CLAUDE.md b/CLAUDE.md index e4a80f73d..b275edb25 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,5 +92,6 @@ deep review on it (once ever per PR). Kill switches: remove `ai-loop` (per PR) o variable `AI_LOOP_ENABLED=false` (whole system). The `` PR comment is machine-managed — never reformat its first line. The loop refuses PRs that touch `.github/**` or the jest/commitlint/release/pnpm-workspace configs. -Stale automation: bestaxbot PRs close after 90 days of inactivity; all PRs go `stale` at 30 days -and close 14 days later; `neverstale` exempts a PR. +Stale automation: PRs go `stale` at 30 days and close 14 days later — except Claude-assisted +PRs (`claude-assisted` label or bestaxbot author), which skip that sweep and instead close +after 90 days of inactivity; `neverstale` exempts a PR from both layers. diff --git a/docs/docs/guides/getting-started/ai-development.md b/docs/docs/guides/getting-started/ai-development.md index 14aad5de0..2044d00bf 100644 --- a/docs/docs/guides/getting-started/ai-development.md +++ b/docs/docs/guides/getting-started/ai-development.md @@ -47,15 +47,15 @@ the review-time requirements (Storybook story for UI changes, docs page for API PRs authored by the loop move through a small label lifecycle: -| Label | Where | Meaning | -| -------------------- | ------ | ----------------------------------------------------------------------------------------------------- | -| `claude-fix` | issues | Maintainer-approved: Claude implements this issue and opens a PR | -| `ai-loop` | PRs | The PR is inside the autonomous review/fix loop | -| `needs-human-review` | PRs | The loop converged (or hit a disagreement) — awaiting maintainer review/merge | -| `ai-loop-paused` | PRs | The loop hit its iteration cap or a guardrail — a maintainer must intervene | -| `deep-review` | PRs | Opt-in: a triage+ user applies it to run the one-shot Claude deep review on any PR (once ever per PR) | -| `stale` | PRs | Auto-applied after 30 days of inactivity; closes 14 days later unless activity resumes | -| `neverstale` | PRs | Exempts a PR from stale automation | +| Label | Where | Meaning | +| -------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `claude-fix` | issues | Maintainer-approved: Claude implements this issue and opens a PR | +| `ai-loop` | PRs | The PR is inside the autonomous review/fix loop | +| `needs-human-review` | PRs | The loop converged (or hit a disagreement) — awaiting maintainer review/merge | +| `ai-loop-paused` | PRs | The loop hit its iteration cap or a guardrail — a maintainer must intervene | +| `deep-review` | PRs | Opt-in: a triage+ user applies it to run the one-shot Claude deep review on any PR (once ever per PR) | +| `stale` | PRs | Auto-applied after 30 days of inactivity; closes 14 days later unless activity resumes. Claude-assisted PRs skip this sweep — a separate closer sweeps them after 90 days instead | +| `neverstale` | PRs | Exempts a PR from all stale automation (both the 30/14-day sweep and the 90-day Claude-assisted closer) | The loop itself: Claude implements the issue and opens the PR → CodeRabbit reviews it and a second, independent Claude review (a stronger model than the implementer) does a deep pass → From 4f095a340eb4413fa94ff476e16ccdd79ba5a0f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 02:09:33 +0000 Subject: [PATCH 4/6] ci: backfill claude-assisted on bot PRs, revalidate before close, inclusive cutoff (#275) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the three CodeRabbit findings on the previous push: - A new first step labels any open bestaxbot PR missing claude-assisted, so pre-labeler bot PRs can't be closed at day 44 by stale.yml's generic sweep — this job's 03:41 cron fires before the 04:17 sweep, and the REST call auto-creates the label like auto-label-claude-prs.yml does. - The close loop re-fetches state/updatedAt/labels per PR right before commenting/closing, skipping anything updated, neverstale-labeled, or closed since the listing snapshot; fetch failures count as ineligible. - The cutoff comparison is now inclusive (<=) to match the documented "90+ days" threshold. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0142miDJt2AX9QUw5fNJt2xu --- .../workflows/close-stale-bestaxbot-prs.yml | 45 +++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/.github/workflows/close-stale-bestaxbot-prs.yml b/.github/workflows/close-stale-bestaxbot-prs.yml index 2ba49139b..521f3a7a4 100644 --- a/.github/workflows/close-stale-bestaxbot-prs.yml +++ b/.github/workflows/close-stale-bestaxbot-prs.yml @@ -11,7 +11,10 @@ name: Close Stale Claude-Assisted PRs # The sweep is the union of author:bestaxbot and label:claude-assisted — # the label (auto-applied by auto-label-claude-prs.yml) also covers # human-authored PRs with Claude attribution, and the author query covers -# bot PRs opened before the labeler existed. +# bot PRs opened before the labeler existed. A first step backfills the +# claude-assisted label onto any unlabeled bestaxbot PR so the generic +# sweep can't close pre-labeler bot PRs at day 44: this job's 03:41 cron +# fires before stale.yml's 04:17 sweep. # # PRs labeled `neverstale` are exempt from this closer too — the label means # "exempt from stale automation", and that covers the 90-day sweep as well @@ -40,6 +43,26 @@ jobs: pull-requests: write # comment, edit labels, close issues: write # issue-comment API for the PR conversation steps: + # Self-heal: bestaxbot PRs opened before auto-label-claude-prs.yml + # existed (or where it misfired) lack the claude-assisted label, so + # stale.yml's generic 30/14-day sweep would close them at day 44 before + # this 90-day closer ever saw them. Label them here; the REST call + # auto-creates the label on first use, same as the labeler. + - name: Backfill claude-assisted onto unlabeled bestaxbot PRs + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + gh pr list --repo "$REPO" --author bestaxbot --state open \ + --json number,labels \ + | jq -r '.[] | select(([.labels[].name] | index("claude-assisted")) | not) | .number' \ + | while read -r PR; do + echo "Labeling PR #$PR claude-assisted" + gh api -X POST "repos/$REPO/issues/$PR/labels" \ + -f "labels[]=claude-assisted" || true + done + - name: Close Claude-assisted PRs inactive for 90+ days env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -51,7 +74,7 @@ jobs: # Union of the two selectors, deduped by PR number. A label with # no matches (or that doesn't exist yet) yields an empty list from - # `gh pr list`, not an error. + # `gh pr list`, not an error. <= makes the 90-day cutoff inclusive. { gh pr list --repo "$REPO" --author bestaxbot --state open \ --json number,updatedAt,labels @@ -59,8 +82,24 @@ jobs: --json number,updatedAt,labels } \ | jq -rs --arg cutoff "$CUTOFF" \ - 'add | unique_by(.number) | .[] | select(.updatedAt < $cutoff) | select(([.labels[].name] | index("neverstale")) | not) | .number' \ + 'add | unique_by(.number) | .[] | select(.updatedAt <= $cutoff) | select(([.labels[].name] | index("neverstale")) | not) | .number' \ | while read -r PR; do + # Re-check eligibility immediately before acting: the listing + # snapshot ages while earlier PRs in the loop are processed, + # and a maintainer may have pushed activity or applied + # neverstale in the meantime. Any fetch failure counts as + # not-eligible — closing on stale data is worse than retrying + # tomorrow. + ELIGIBLE=$(gh pr view "$PR" --repo "$REPO" \ + --json state,updatedAt,labels \ + | jq -r --arg cutoff "$CUTOFF" \ + 'if .state == "OPEN" and .updatedAt <= $cutoff and (([.labels[].name] | index("neverstale")) | not) then "yes" else "no" end' \ + || echo no) + if [ "$ELIGIBLE" != "yes" ]; then + echo "Skipping PR #$PR — no longer eligible" + continue + fi + echo "Closing stale PR #$PR" gh pr comment "$PR" --repo "$REPO" --body "Closing this PR after 90 days of inactivity. If it's still wanted, reopen it — for AI-loop PRs, you can instead restart the loop per the instructions in the PR's \`ai-loop-state\` status comment." From 492bd4edc9f32d0e46278e2ca1026dffbe968dfd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 02:14:45 +0000 Subject: [PATCH 5/6] ci: raise gh pr list limits, stop swallowing label-strip failures (#275) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more CodeRabbit findings: gh pr list defaults to 30 results, which would silently skip older candidates in both the backfill and the close sweep once the repo has more than 30 matching PRs — all three calls now pass --limit 1000. And the || true on the loop-label strip masked real API/auth failures (gh already succeeds when a label is absent), letting a PR close with loop labels still attached; a genuine failure now aborts the run and the daily cron retries tomorrow. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0142miDJt2AX9QUw5fNJt2xu --- .github/workflows/close-stale-bestaxbot-prs.yml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/close-stale-bestaxbot-prs.yml b/.github/workflows/close-stale-bestaxbot-prs.yml index 521f3a7a4..3038bafa9 100644 --- a/.github/workflows/close-stale-bestaxbot-prs.yml +++ b/.github/workflows/close-stale-bestaxbot-prs.yml @@ -55,7 +55,7 @@ jobs: run: | set -euo pipefail gh pr list --repo "$REPO" --author bestaxbot --state open \ - --json number,labels \ + --limit 1000 --json number,labels \ | jq -r '.[] | select(([.labels[].name] | index("claude-assisted")) | not) | .number' \ | while read -r PR; do echo "Labeling PR #$PR claude-assisted" @@ -77,9 +77,9 @@ jobs: # `gh pr list`, not an error. <= makes the 90-day cutoff inclusive. { gh pr list --repo "$REPO" --author bestaxbot --state open \ - --json number,updatedAt,labels + --limit 1000 --json number,updatedAt,labels gh pr list --repo "$REPO" --label claude-assisted --state open \ - --json number,updatedAt,labels + --limit 1000 --json number,updatedAt,labels } \ | jq -rs --arg cutoff "$CUTOFF" \ 'add | unique_by(.number) | .[] | select(.updatedAt <= $cutoff) | select(([.labels[].name] | index("neverstale")) | not) | .number' \ @@ -104,11 +104,14 @@ jobs: gh pr comment "$PR" --repo "$REPO" --body "Closing this PR after 90 days of inactivity. If it's still wanted, reopen it — for AI-loop PRs, you can instead restart the loop per the instructions in the PR's \`ai-loop-state\` status comment." # Strip loop labels FIRST so the PR leaves the loop's state - # machine cleanly; ignore any that are not present. + # machine cleanly. gh succeeds when a label is absent, so a + # failure here is a real API/auth error — let it abort (set -e) + # rather than close the PR with loop labels still attached; + # the daily cron retries tomorrow. gh pr edit "$PR" --repo "$REPO" \ --remove-label ai-loop \ --remove-label ai-loop-paused \ - --remove-label needs-human-review || true + --remove-label needs-human-review gh pr close "$PR" --repo "$REPO" done From d4ffd23859caff65039dc30b768b53e071d34b17 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 11:40:55 +0000 Subject: [PATCH 6/6] ci: serialize the bestaxbot stale closer with a concurrency group Addresses the remaining CodeRabbit thread: a workflow_dispatch overlapping the daily cron could double-comment or race gh pr close into a set -e abort. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Pohc8xLkdx4gwXkW3xd7up --- .github/workflows/close-stale-bestaxbot-prs.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/close-stale-bestaxbot-prs.yml b/.github/workflows/close-stale-bestaxbot-prs.yml index 3038bafa9..789341766 100644 --- a/.github/workflows/close-stale-bestaxbot-prs.yml +++ b/.github/workflows/close-stale-bestaxbot-prs.yml @@ -35,6 +35,13 @@ on: # No workflow-level grants; the single job takes exactly what it needs. permissions: {} +# One sweep at a time: a manual workflow_dispatch overlapping the daily cron +# must not race a second closer over the same PRs (double comments, or a +# second `gh pr close` aborting under set -e). +concurrency: + group: close-stale-bestaxbot-prs + cancel-in-progress: false + jobs: close: runs-on: ubuntu-latest