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
168 lines · 5.4 KBRaw
1#!/usr/bin/env bun
2
3import { lifecycle, STALE_UPVOTE_THRESHOLD } from "./issue-lifecycle.ts";
4
5// --
6
7const NEW_ISSUE = "https://github.com/anthropics/claude-code/issues/new/choose";
8const DRY_RUN = process.argv.includes("--dry-run");
9
10const CLOSE_MESSAGE = (reason: string) =>
11 `Closing for now — ${reason}. Please [open a new issue](${NEW_ISSUE}) if this is still relevant.`;
12
13// --
14
15async 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
45async 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 thumbsUp = issue.reactions?.["+1"] ?? 0;
74 if (thumbsUp >= STALE_UPVOTE_THRESHOLD) continue;
75
76 const base = `/repos/${owner}/${repo}/issues/${issue.number}`;
77
78 if (DRY_RUN) {
79 const age = Math.floor((Date.now() - updatedAt.getTime()) / 86400000);
80 console.log(`#${issue.number}: would label stale (${age}d inactive) — ${issue.title}`);
81 } else {
82 await githubRequest(`${base}/labels`, "POST", { labels: ["stale"] });
83 console.log(`#${issue.number}: labeled stale — ${issue.title}`);
84 }
85 labeled++;
86 }
87 }
88
89 return labeled;
90}
91
92async function closeExpired(owner: string, repo: string) {
93 let closed = 0;
94
95 for (const { label, days, reason } of lifecycle) {
96 const cutoff = new Date();
97 cutoff.setDate(cutoff.getDate() - days);
98 console.log(`\n=== ${label} (${days}d timeout) ===`);
99
100 for (let page = 1; page <= 10; page++) {
101 const issues = await githubRequest<any[]>(
102 `/repos/${owner}/${repo}/issues?state=open&labels=${label}&sort=updated&direction=asc&per_page=100&page=${page}`
103 );
104 if (issues.length === 0) break;
105
106 for (const issue of issues) {
107 if (issue.pull_request) continue;
108 if (issue.locked) continue;
109
110 const thumbsUp = issue.reactions?.["+1"] ?? 0;
111 if (thumbsUp >= STALE_UPVOTE_THRESHOLD) continue;
112
113 const base = `/repos/${owner}/${repo}/issues/${issue.number}`;
114
115 const events = await githubRequest<any[]>(`${base}/events?per_page=100`);
116
117 const labeledAt = events
118 .filter((e) => e.event === "labeled" && e.label?.name === label)
119 .map((e) => new Date(e.created_at))
120 .pop();
121
122 if (!labeledAt || labeledAt > cutoff) continue;
123
124 // Skip if a non-bot user commented after the label was applied.
125 // The triage workflow should remove lifecycle labels on human
126 // activity, but check here too as a safety net.
127 const comments = await githubRequest<any[]>(
128 `${base}/comments?since=${labeledAt.toISOString()}&per_page=100`
129 );
130 const hasHumanComment = comments.some(
131 (c) => c.user && c.user.type !== "Bot"
132 );
133 if (hasHumanComment) {
134 console.log(
135 `#${issue.number}: skipping (human activity after ${label} label)`
136 );
137 continue;
138 }
139
140 if (DRY_RUN) {
141 const age = Math.floor((Date.now() - labeledAt.getTime()) / 86400000);
142 console.log(`#${issue.number}: would close (${label}, ${age}d old) — ${issue.title}`);
143 } else {
144 await githubRequest(`${base}/comments`, "POST", { body: CLOSE_MESSAGE(reason) });
145 await githubRequest(base, "PATCH", { state: "closed", state_reason: "not_planned" });
146 console.log(`#${issue.number}: closed (${label})`);
147 }
148 closed++;
149 }
150 }
151 }
152
153 return closed;
154}
155
156// --
157
158const owner = process.env.GITHUB_REPOSITORY_OWNER;
159const repo = process.env.GITHUB_REPOSITORY_NAME;
160if (!owner || !repo)
161 throw new Error("GITHUB_REPOSITORY_OWNER and GITHUB_REPOSITORY_NAME required");
162
163if (DRY_RUN) console.log("DRY RUN — no changes will be made\n");
164
165const labeled = await markStale(owner, repo);
166const closed = await closeExpired(owner, repo);
167
168console.log(`\nDone: ${labeled} ${DRY_RUN ? "would be labeled" : "labeled"} stale, ${closed} ${DRY_RUN ? "would be closed" : "closed"}`);