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
151 lines · 4.8 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 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
95async 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
141const owner = process.env.GITHUB_REPOSITORY_OWNER;
142const repo = process.env.GITHUB_REPOSITORY_NAME;
143if (!owner || !repo)
144 throw new Error("GITHUB_REPOSITORY_OWNER and GITHUB_REPOSITORY_NAME required");
145
146if (DRY_RUN) console.log("DRY RUN — no changes will be made\n");
147
148const labeled = await markStale(owner, repo);
149const closed = await closeExpired(owner, repo);
150
151console.log(`\nDone: ${labeled} ${DRY_RUN ? "would be labeled" : "labeled"} stale, ${closed} ${DRY_RUN ? "would be closed" : "closed"}`);