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
| 1 | #!/usr/bin/env bun |
| 2 | |
| 3 | import { lifecycle, STALE_UPVOTE_THRESHOLD } from "./issue-lifecycle.ts"; |
| 4 | |
| 5 | // -- |
| 6 | |
| 7 | const NEW_ISSUE = "https://github.com/anthropics/claude-code/issues/new/choose"; |
| 8 | const DRY_RUN = process.argv.includes("--dry-run"); |
| 9 | |
| 10 | const CLOSE_MESSAGE = (reason: string) => |
| 11 | `Closing for now — ${reason}. Please [open a new issue](${NEW_ISSUE}) if this is still relevant.`; |
| 12 | |
| 13 | // -- |
| 14 | |
| 15 | async function githubRequest<T>( |
| 16 | endpoint: string, |
| 17 | method = "GET", |
| 18 | body?: unknown |
| 19 | ): Promise<T> { |
| 20 | const token = process.env.GITHUB_TOKEN; |
| 21 | if (!token) throw new Error("GITHUB_TOKEN required"); |
| 22 | |
| 23 | const response = await fetch(`https://api.github.com${endpoint}`, { |
| 24 | method, |
| 25 | headers: { |
| 26 | Authorization: `Bearer ${token}`, |
| 27 | Accept: "application/vnd.github.v3+json", |
| 28 | "User-Agent": "sweep", |
| 29 | ...(body && { "Content-Type": "application/json" }), |
| 30 | }, |
| 31 | ...(body && { body: JSON.stringify(body) }), |
| 32 | }); |
| 33 | |
| 34 | if (!response.ok) { |
| 35 | if (response.status === 404) return {} as T; |
| 36 | const text = await response.text(); |
| 37 | throw new Error(`GitHub API ${response.status}: ${text}`); |
| 38 | } |
| 39 | |
| 40 | return response.json(); |
| 41 | } |
| 42 | |
| 43 | // -- |
| 44 | |
| 45 | async function markStale(owner: string, repo: string) { |
| 46 | const staleDays = lifecycle.find((l) => l.label === "stale")!.days; |
| 47 | const cutoff = new Date(); |
| 48 | cutoff.setDate(cutoff.getDate() - staleDays); |
| 49 | |
| 50 | let labeled = 0; |
| 51 | |
| 52 | console.log(`\n=== marking stale (${staleDays}d inactive) ===`); |
| 53 | |
| 54 | for (let page = 1; page <= 10; page++) { |
| 55 | const issues = await githubRequest<any[]>( |
| 56 | `/repos/${owner}/${repo}/issues?state=open&sort=updated&direction=asc&per_page=100&page=${page}` |
| 57 | ); |
| 58 | if (issues.length === 0) break; |
| 59 | |
| 60 | for (const issue of issues) { |
| 61 | if (issue.pull_request) continue; |
| 62 | if (issue.locked) continue; |
| 63 | if (issue.assignees?.length > 0) continue; |
| 64 | |
| 65 | const updatedAt = new Date(issue.updated_at); |
| 66 | if (updatedAt > cutoff) return labeled; |
| 67 | |
| 68 | const alreadyStale = issue.labels?.some( |
| 69 | (l: any) => l.name === "stale" || l.name === "autoclose" |
| 70 | ); |
| 71 | if (alreadyStale) continue; |
| 72 | |
| 73 | const isEnhancement = issue.labels?.some( |
| 74 | (l: any) => l.name === "enhancement" |
| 75 | ); |
| 76 | const thumbsUp = issue.reactions?.["+1"] ?? 0; |
| 77 | if (isEnhancement && thumbsUp >= STALE_UPVOTE_THRESHOLD) continue; |
| 78 | |
| 79 | const base = `/repos/${owner}/${repo}/issues/${issue.number}`; |
| 80 | |
| 81 | if (DRY_RUN) { |
| 82 | const age = Math.floor((Date.now() - updatedAt.getTime()) / 86400000); |
| 83 | console.log(`#${issue.number}: would label stale (${age}d inactive) — ${issue.title}`); |
| 84 | } else { |
| 85 | await githubRequest(`${base}/labels`, "POST", { labels: ["stale"] }); |
| 86 | console.log(`#${issue.number}: labeled stale — ${issue.title}`); |
| 87 | } |
| 88 | labeled++; |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | return labeled; |
| 93 | } |
| 94 | |
| 95 | async function closeExpired(owner: string, repo: string) { |
| 96 | let closed = 0; |
| 97 | |
| 98 | for (const { label, days, reason } of lifecycle) { |
| 99 | const cutoff = new Date(); |
| 100 | cutoff.setDate(cutoff.getDate() - days); |
| 101 | console.log(`\n=== ${label} (${days}d timeout) ===`); |
| 102 | |
| 103 | for (let page = 1; page <= 10; page++) { |
| 104 | const issues = await githubRequest<any[]>( |
| 105 | `/repos/${owner}/${repo}/issues?state=open&labels=${label}&sort=updated&direction=asc&per_page=100&page=${page}` |
| 106 | ); |
| 107 | if (issues.length === 0) break; |
| 108 | |
| 109 | for (const issue of issues) { |
| 110 | if (issue.pull_request) continue; |
| 111 | if (issue.locked) continue; |
| 112 | const base = `/repos/${owner}/${repo}/issues/${issue.number}`; |
| 113 | |
| 114 | const events = await githubRequest<any[]>(`${base}/events?per_page=100`); |
| 115 | |
| 116 | const labeledAt = events |
| 117 | .filter((e) => e.event === "labeled" && e.label?.name === label) |
| 118 | .map((e) => new Date(e.created_at)) |
| 119 | .pop(); |
| 120 | |
| 121 | if (!labeledAt || labeledAt > cutoff) continue; |
| 122 | |
| 123 | if (DRY_RUN) { |
| 124 | const age = Math.floor((Date.now() - labeledAt.getTime()) / 86400000); |
| 125 | console.log(`#${issue.number}: would close (${label}, ${age}d old) — ${issue.title}`); |
| 126 | } else { |
| 127 | await githubRequest(`${base}/comments`, "POST", { body: CLOSE_MESSAGE(reason) }); |
| 128 | await githubRequest(base, "PATCH", { state: "closed", state_reason: "not_planned" }); |
| 129 | console.log(`#${issue.number}: closed (${label})`); |
| 130 | } |
| 131 | closed++; |
| 132 | } |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | return closed; |
| 137 | } |
| 138 | |
| 139 | // -- |
| 140 | |
| 141 | const owner = process.env.GITHUB_REPOSITORY_OWNER; |
| 142 | const repo = process.env.GITHUB_REPOSITORY_NAME; |
| 143 | if (!owner || !repo) |
| 144 | throw new Error("GITHUB_REPOSITORY_OWNER and GITHUB_REPOSITORY_NAME required"); |
| 145 | |
| 146 | if (DRY_RUN) console.log("DRY RUN — no changes will be made\n"); |
| 147 | |
| 148 | const labeled = await markStale(owner, repo); |
| 149 | const closed = await closeExpired(owner, repo); |
| 150 | |
| 151 | console.log(`\nDone: ${labeled} ${DRY_RUN ? "would be labeled" : "labeled"} stale, ${closed} ${DRY_RUN ? "would be closed" : "closed"}`); |