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
Add daily sweep to enforce issue lifecycle label timeouts
| @@ -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 {}; |