#!/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 STALE_DAYS = 14; const STALE_UPVOTE_THRESHOLD = 10; const CLOSE_MESSAGE = (reason: string) => `Closing for now — ${reason}. Please [open a new issue](${NEW_ISSUE}) if this is still relevant.`; const lifecycle = [ { label: "invalid", days: 3, reason: "this doesn't appear to be about Claude Code" }, { label: "needs-repro", days: 7, reason: "we still need reproduction steps to investigate" }, { label: "needs-info", days: 7, reason: "we still need a bit more information to move forward" }, { label: "stale", days: 14, reason: "inactive for too long" }, { label: "autoclose", days: 14, reason: "inactive for too long" }, ]; // -- 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 markStale(owner: string, repo: string) { const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - STALE_DAYS); let labeled = 0; console.log(`\n=== marking stale (${STALE_DAYS}d inactive) ===`); for (let page = 1; page <= 10; page++) { const issues = await githubRequest( `/repos/${owner}/${repo}/issues?state=open&sort=updated&direction=asc&per_page=100&page=${page}` ); if (issues.length === 0) break; for (const issue of issues) { if (issue.pull_request) continue; if (issue.locked) continue; if (issue.assignees?.length > 0) continue; const updatedAt = new Date(issue.updated_at); if (updatedAt > cutoff) return labeled; const alreadyStale = issue.labels?.some( (l: any) => l.name === "stale" || l.name === "autoclose" ); if (alreadyStale) continue; const isEnhancement = issue.labels?.some( (l: any) => l.name === "enhancement" ); const thumbsUp = issue.reactions?.["+1"] ?? 0; if (isEnhancement && thumbsUp >= STALE_UPVOTE_THRESHOLD) continue; const base = `/repos/${owner}/${repo}/issues/${issue.number}`; if (DRY_RUN) { const age = Math.floor((Date.now() - updatedAt.getTime()) / 86400000); console.log(`#${issue.number}: would label stale (${age}d inactive) — ${issue.title}`); } else { await githubRequest(`${base}/labels`, "POST", { labels: ["stale"] }); console.log(`#${issue.number}: labeled stale — ${issue.title}`); } labeled++; } } return labeled; } async function closeExpired(owner: string, repo: string) { let closed = 0; for (const { label, days, reason } 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; if (issue.locked) 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: CLOSE_MESSAGE(reason) }); await githubRequest(base, "PATCH", { state: "closed", state_reason: "not_planned" }); console.log(`#${issue.number}: closed (${label})`); } closed++; } } } return closed; } // -- 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 changes will be made\n"); const labeled = await markStale(owner, repo); const closed = await closeExpired(owner, repo); console.log(`\nDone: ${labeled} ${DRY_RUN ? "would be labeled" : "labeled"} stale, ${closed} ${DRY_RUN ? "would be closed" : "closed"}`);