surshar / claude-code
Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.
Built by Anthropic · github.com/anthropics
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.
| @@ -1,0 +1,31 @@ | ||
| 1 | +name: "Daily Issue Sweep" | |
| 2 | + | |
| 3 | +on: | |
| 4 | + schedule: | |
| 5 | + - cron: "0 10 * * *" # 2am Pacific | |
| 6 | + workflow_dispatch: | |
| 7 | + | |
| 8 | +permissions: | |
| 9 | + issues: write | |
| 10 | + | |
| 11 | +concurrency: | |
| 12 | + group: daily-issue-sweep | |
| 13 | + | |
| 14 | +jobs: | |
| 15 | + sweep: | |
| 16 | + runs-on: ubuntu-latest | |
| 17 | + steps: | |
| 18 | + - name: Checkout repository | |
| 19 | + uses: actions/checkout@v4 | |
| 20 | + | |
| 21 | + - name: Setup Bun | |
| 22 | + uses: oven-sh/setup-bun@v2 | |
| 23 | + with: | |
| 24 | + bun-version: latest | |
| 25 | + | |
| 26 | + - name: Enforce lifecycle timeouts | |
| 27 | + run: bun run scripts/sweep.ts | |
| 28 | + env: | |
| 29 | + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| 30 | + GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} | |
| 31 | + GITHUB_REPOSITORY_NAME: ${{ github.event.repository.name }} |
| @@ -1,0 +1,106 @@ | ||
| 1 | +#!/usr/bin/env bun | |
| 2 | + | |
| 3 | +// -- | |
| 4 | + | |
| 5 | +const NEW_ISSUE = "https://github.com/anthropics/claude-code/issues/new/choose"; | |
| 6 | +const DRY_RUN = process.argv.includes("--dry-run"); | |
| 7 | + | |
| 8 | +const lifecycle = [ | |
| 9 | + { label: "needs-repro", days: 7 }, | |
| 10 | + { label: "needs-info", days: 7 }, | |
| 11 | + { label: "needs-votes", days: 30 }, | |
| 12 | + { label: "stale", days: 30 }, | |
| 13 | +]; | |
| 14 | + | |
| 15 | +const closeMessages: Record<string, string> = { | |
| 16 | + "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.`, | |
| 17 | + "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.`, | |
| 18 | + "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.`, | |
| 19 | + stale: `Closing due to inactivity.\n\nIf this is still a problem, please [open a new issue](${NEW_ISSUE}) with up-to-date information.`, | |
| 20 | +}; | |
| 21 | + | |
| 22 | +// -- | |
| 23 | + | |
| 24 | +async function githubRequest<T>( | |
| 25 | + endpoint: string, | |
| 26 | + method = "GET", | |
| 27 | + body?: unknown | |
| 28 | +): Promise<T> { | |
| 29 | + const token = process.env.GITHUB_TOKEN; | |
| 30 | + if (!token) throw new Error("GITHUB_TOKEN required"); | |
| 31 | + | |
| 32 | + const response = await fetch(`https://api.github.com${endpoint}`, { | |
| 33 | + method, | |
| 34 | + headers: { | |
| 35 | + Authorization: `Bearer ${token}`, | |
| 36 | + Accept: "application/vnd.github.v3+json", | |
| 37 | + "User-Agent": "sweep", | |
| 38 | + ...(body && { "Content-Type": "application/json" }), | |
| 39 | + }, | |
| 40 | + ...(body && { body: JSON.stringify(body) }), | |
| 41 | + }); | |
| 42 | + | |
| 43 | + if (!response.ok) { | |
| 44 | + if (response.status === 404) return {} as T; | |
| 45 | + const text = await response.text(); | |
| 46 | + throw new Error(`GitHub API ${response.status}: ${text}`); | |
| 47 | + } | |
| 48 | + | |
| 49 | + return response.json(); | |
| 50 | +} | |
| 51 | + | |
| 52 | +// -- | |
| 53 | + | |
| 54 | +async function main() { | |
| 55 | + const owner = process.env.GITHUB_REPOSITORY_OWNER; | |
| 56 | + const repo = process.env.GITHUB_REPOSITORY_NAME; | |
| 57 | + if (!owner || !repo) | |
| 58 | + throw new Error("GITHUB_REPOSITORY_OWNER and GITHUB_REPOSITORY_NAME required"); | |
| 59 | + | |
| 60 | + if (DRY_RUN) console.log("DRY RUN — no issues will be closed\n"); | |
| 61 | + | |
| 62 | + let closed = 0; | |
| 63 | + | |
| 64 | + for (const { label, days } of lifecycle) { | |
| 65 | + const cutoff = new Date(); | |
| 66 | + cutoff.setDate(cutoff.getDate() - days); | |
| 67 | + console.log(`\n=== ${label} (${days}d timeout) ===`); | |
| 68 | + | |
| 69 | + for (let page = 1; page <= 10; page++) { | |
| 70 | + const issues = await githubRequest<any[]>( | |
| 71 | + `/repos/${owner}/${repo}/issues?state=open&labels=${label}&sort=updated&direction=asc&per_page=100&page=${page}` | |
| 72 | + ); | |
| 73 | + if (issues.length === 0) break; | |
| 74 | + | |
| 75 | + for (const issue of issues) { | |
| 76 | + if (issue.pull_request) continue; | |
| 77 | + const base = `/repos/${owner}/${repo}/issues/${issue.number}`; | |
| 78 | + | |
| 79 | + const events = await githubRequest<any[]>(`${base}/events?per_page=100`); | |
| 80 | + | |
| 81 | + const labeledAt = events | |
| 82 | + .filter((e) => e.event === "labeled" && e.label?.name === label) | |
| 83 | + .map((e) => new Date(e.created_at)) | |
| 84 | + .pop(); | |
| 85 | + | |
| 86 | + if (!labeledAt || labeledAt > cutoff) continue; | |
| 87 | + | |
| 88 | + if (DRY_RUN) { | |
| 89 | + const age = Math.floor((Date.now() - labeledAt.getTime()) / 86400000); | |
| 90 | + console.log(`#${issue.number}: would close (${label}, ${age}d old) — ${issue.title}`); | |
| 91 | + } else { | |
| 92 | + await githubRequest(`${base}/comments`, "POST", { body: closeMessages[label] }); | |
| 93 | + await githubRequest(base, "PATCH", { state: "closed", state_reason: "not_planned" }); | |
| 94 | + console.log(`#${issue.number}: closed (${label})`); | |
| 95 | + } | |
| 96 | + closed++; | |
| 97 | + } | |
| 98 | + } | |
| 99 | + } | |
| 100 | + | |
| 101 | + console.log(`\nDone: ${closed} ${DRY_RUN ? "would be closed" : "closed"}`); | |
| 102 | +} | |
| 103 | + | |
| 104 | +main().catch(console.error); | |
| 105 | + | |
| 106 | +export {}; |