spacehub

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

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