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
158 lines · 5.1 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 if (issue.locked) continue;
119 const base = `/repos/${owner}/${repo}/issues/${issue.number}`;
120
121 const events = await githubRequest<any[]>(`${base}/events?per_page=100`);
122
123 const labeledAt = events
124 .filter((e) => e.event === "labeled" && e.label?.name === label)
125 .map((e) => new Date(e.created_at))
126 .pop();
127
128 if (!labeledAt || labeledAt > cutoff) continue;
129
130 if (DRY_RUN) {
131 const age = Math.floor((Date.now() - labeledAt.getTime()) / 86400000);
132 console.log(`#${issue.number}: would close (${label}, ${age}d old) — ${issue.title}`);
133 } else {
134 await githubRequest(`${base}/comments`, "POST", { body: CLOSE_MESSAGE(reason) });
135 await githubRequest(base, "PATCH", { state: "closed", state_reason: "not_planned" });
136 console.log(`#${issue.number}: closed (${label})`);
137 }
138 closed++;
139 }
140 }
141 }
142
143 return closed;
144}
145
146// --
147
148const owner = process.env.GITHUB_REPOSITORY_OWNER;
149const repo = process.env.GITHUB_REPOSITORY_NAME;
150if (!owner || !repo)
151 throw new Error("GITHUB_REPOSITORY_OWNER and GITHUB_REPOSITORY_NAME required");
152
153if (DRY_RUN) console.log("DRY RUN — no changes will be made\n");
154
155const labeled = await markStale(owner, repo);
156const closed = await closeExpired(owner, repo);
157
158console.log(`\nDone: ${labeled} ${DRY_RUN ? "would be labeled" : "labeled"} stale, ${closed} ${DRY_RUN ? "would be closed" : "closed"}`);