From a17040212cb5347411ac85349279034aef6a4dc0 Mon Sep 17 00:00:00 2001 From: Chris Lloyd Date: Wed, 11 Feb 2026 22:34:23 -0800 Subject: [PATCH] Add daily sweep to enforce issue lifecycle label timeouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a simple, mechanical daily sweep that closes issues with lifecycle labels past their timeout: - needs-repro: 7 days - needs-info: 7 days - needs-votes: 30 days - stale: 30 days The sweep checks when the label was last applied via the events API, and closes the issue if the timeout has elapsed. No AI, no comment checking — if the label is still there past its timeout, close it. Removing a label (by a triager, slash command, or future AI retriage) is what prevents closure. Each close message directs the reporter to open a new issue rather than engaging with the closed one. The script supports --dry-run for local testing: GITHUB_TOKEN=$(gh auth token) \ GITHUB_REPOSITORY_OWNER=anthropics \ GITHUB_REPOSITORY_NAME=claude-code \ bun run scripts/sweep.ts --dry-run ## Test plan Ran --dry-run against anthropics/claude-code. Correctly identified 3 issues past their timeouts (1 needs-repro at 12d, 2 needs-info at 14d and 26d). No false positives. --- .github/workflows/sweep.yml | 31 +++++++++++ scripts/sweep.ts | 106 ++++++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 .github/workflows/sweep.yml create mode 100644 scripts/sweep.ts diff --git a/.github/workflows/sweep.yml b/.github/workflows/sweep.yml new file mode 100644 index 00000000..771974fe --- /dev/null +++ b/.github/workflows/sweep.yml @@ -0,0 +1,31 @@ +name: "Daily Issue Sweep" + +on: + schedule: + - cron: "0 10 * * *" # 2am Pacific + workflow_dispatch: + +permissions: + issues: write + +concurrency: + group: daily-issue-sweep + +jobs: + sweep: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Enforce lifecycle timeouts + run: bun run scripts/sweep.ts + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} + GITHUB_REPOSITORY_NAME: ${{ github.event.repository.name }} diff --git a/scripts/sweep.ts b/scripts/sweep.ts new file mode 100644 index 00000000..73058e8f --- /dev/null +++ b/scripts/sweep.ts @@ -0,0 +1,106 @@ +#!/usr/bin/env bun + +// -- + +const NEW_ISSUE = "https://github.com/anthropics/claude-code/issues/new/choose"; +const DRY_RUN = process.argv.includes("--dry-run"); + +const lifecycle = [ + { label: "needs-repro", days: 7 }, + { label: "needs-info", days: 7 }, + { label: "needs-votes", days: 30 }, + { label: "stale", days: 30 }, +]; + +const closeMessages: Record = { + "needs-repro": `Closing — we weren't able to get the reproduction steps needed to investigate.\n\nIf this is still a problem, please [open a new issue](${NEW_ISSUE}) with steps to reproduce.`, + "needs-info": `Closing — we didn't receive the information needed to move forward.\n\nIf this is still a problem, please [open a new issue](${NEW_ISSUE}) with the requested details.`, + "needs-votes": `Closing this feature request — it didn't get enough community support to prioritize.\n\nIf you'd still like to see this, please [open a new feature request](${NEW_ISSUE}) with more context about the use case.`, + stale: `Closing due to inactivity.\n\nIf this is still a problem, please [open a new issue](${NEW_ISSUE}) with up-to-date information.`, +}; + +// -- + +async function githubRequest( + endpoint: string, + method = "GET", + body?: unknown +): Promise { + const token = process.env.GITHUB_TOKEN; + if (!token) throw new Error("GITHUB_TOKEN required"); + + const response = await fetch(`https://api.github.com${endpoint}`, { + method, + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github.v3+json", + "User-Agent": "sweep", + ...(body && { "Content-Type": "application/json" }), + }, + ...(body && { body: JSON.stringify(body) }), + }); + + if (!response.ok) { + if (response.status === 404) return {} as T; + const text = await response.text(); + throw new Error(`GitHub API ${response.status}: ${text}`); + } + + return response.json(); +} + +// -- + +async function main() { + const owner = process.env.GITHUB_REPOSITORY_OWNER; + const repo = process.env.GITHUB_REPOSITORY_NAME; + if (!owner || !repo) + throw new Error("GITHUB_REPOSITORY_OWNER and GITHUB_REPOSITORY_NAME required"); + + if (DRY_RUN) console.log("DRY RUN — no issues will be closed\n"); + + let closed = 0; + + for (const { label, days } of lifecycle) { + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - days); + console.log(`\n=== ${label} (${days}d timeout) ===`); + + for (let page = 1; page <= 10; page++) { + const issues = await githubRequest( + `/repos/${owner}/${repo}/issues?state=open&labels=${label}&sort=updated&direction=asc&per_page=100&page=${page}` + ); + if (issues.length === 0) break; + + for (const issue of issues) { + if (issue.pull_request) continue; + const base = `/repos/${owner}/${repo}/issues/${issue.number}`; + + const events = await githubRequest(`${base}/events?per_page=100`); + + const labeledAt = events + .filter((e) => e.event === "labeled" && e.label?.name === label) + .map((e) => new Date(e.created_at)) + .pop(); + + if (!labeledAt || labeledAt > cutoff) continue; + + if (DRY_RUN) { + const age = Math.floor((Date.now() - labeledAt.getTime()) / 86400000); + console.log(`#${issue.number}: would close (${label}, ${age}d old) — ${issue.title}`); + } else { + await githubRequest(`${base}/comments`, "POST", { body: closeMessages[label] }); + await githubRequest(base, "PATCH", { state: "closed", state_reason: "not_planned" }); + console.log(`#${issue.number}: closed (${label})`); + } + closed++; + } + } + } + + console.log(`\nDone: ${closed} ${DRY_RUN ? "would be closed" : "closed"}`); +} + +main().catch(console.error); + +export {};