diff --git a/.github/workflows/close-stale-bestaxbot-prs.yml b/.github/workflows/close-stale-bestaxbot-prs.yml new file mode 100644 index 000000000..789341766 --- /dev/null +++ b/.github/workflows/close-stale-bestaxbot-prs.yml @@ -0,0 +1,124 @@ +name: Close Stale Claude-Assisted PRs + +# 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. 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 +# 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 +# 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: {} + +# 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 + timeout-minutes: 10 + permissions: + 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 \ + --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" + 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 }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + CUTOFF=$(date -u -d '90 days ago' +%Y-%m-%dT%H:%M:%SZ) + echo "Closing Claude-assisted PRs not updated since $CUTOFF" + + # 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. <= makes the 90-day cutoff inclusive. + { + gh pr list --repo "$REPO" --author bestaxbot --state open \ + --limit 1000 --json number,updatedAt,labels + gh pr list --repo "$REPO" --label claude-assisted --state open \ + --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' \ + | 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." + + # Strip loop labels FIRST so the PR leaves the loop's state + # 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 + + 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..cca7f72e0 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,63 @@ +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. +# +# 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. +# +# 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,claude-assisted + 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 12789b8a5..69279922d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,3 +96,6 @@ label. Kill switches: remove `ai-loop` (per PR) or set repo 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: 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 6e05dbe8e..7666c31b8 100644 --- a/docs/docs/guides/getting-started/ai-development.md +++ b/docs/docs/guides/getting-started/ai-development.md @@ -47,14 +47,16 @@ 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 Claude deep review on any PR (re-apply to re-run) | -| `claude-assisted` | PRs | Auto-applied provenance for AI-assisted PRs (bestaxbot or the Claude footer) | +| 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 Claude deep review on any PR (re-apply to re-run) | +| `claude-assisted` | PRs | Auto-applied provenance for AI-assisted PRs (bestaxbot or the Claude footer) | +| `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 →