返回 CodeWhale
github.ts
根目录 / web / lib / github.ts
1 import type { FeedItem, RepoStats } from "./types";
2
3 const REPO = process.env.GITHUB_REPO ?? "Hmbown/CodeWhale";
4 const GH = "https://api.github.com";
5 const MIN_KNOWN_CONTRIBUTORS = 141;
6
7 function headers(token?: string): HeadersInit {
8 const h: Record<string, string> = {
9 Accept: "application/vnd.github+json",
10 "X-GitHub-Api-Version": "2022-11-28",
11 "User-Agent": "codewhale-web",
12 };
13 if (token) h.Authorization = `Bearer ${token}`;
14 return h;
15 }
16
17 export async function fetchRepoStats(token?: string): Promise<RepoStats> {
18 const [repoRes, contribRes, releaseRes] = await Promise.all([
19 fetch(`${GH}/repos/${REPO}`, { headers: headers(token), next: { revalidate: 1800 } }),
20 fetch(`${GH}/repos/${REPO}/contributors?per_page=1&anon=true`, {
21 headers: headers(token),
22 next: { revalidate: 3600 },
23 }),
24 fetch(`${GH}/repos/${REPO}/releases/latest`, { headers: headers(token), next: { revalidate: 3600 } }),
25 ]);
26
27 const repo = repoRes.ok ? await repoRes.json().catch(() => null) : null;
28 const stars = numberField(repo, "stargazers_count");
29 const forks = numberField(repo, "forks_count");
30 const repoOpenCount = numberField(repo, "open_issues_count");
31
32 const contributors = await contributorCount(contribRes);
33
34 // Open PRs: cheapest path is the search API.
35 const prRes = await fetch(
36 `${GH}/search/issues?q=${encodeURIComponent(`repo:${REPO} is:pr is:open`)}&per_page=1`,
37 { headers: headers(token), next: { revalidate: 1800 } }
38 );
39 const prJson = prRes.ok ? ((await prRes.json().catch(() => null)) as { total_count?: number } | null) : null;
40 const openPulls = typeof prJson?.total_count === "number" ? prJson.total_count : 0;
41 const openIssues = Math.max(0, repoOpenCount - openPulls);
42
43 let latestRelease: RepoStats["latestRelease"];
44 if (releaseRes.ok) {
45 const r = (await releaseRes.json()) as { tag_name: string; published_at: string; html_url: string };
46 latestRelease = { tag: r.tag_name, publishedAt: r.published_at, url: r.html_url };
47 }
48
49 return {
50 stars,
51 forks,
52 openIssues,
53 openPulls,
54 contributors,
55 latestRelease,
56 fetchedAt: new Date().toISOString(),
57 };
58 }
59
60 function numberField(body: unknown, key: string): number {
61 if (!body || typeof body !== "object") return 0;
62 const value = (body as Record<string, unknown>)[key];
63 return typeof value === "number" && Number.isFinite(value) ? value : 0;
64 }
65
66 async function contributorCount(res: Response): Promise<number> {
67 if (!res.ok) return MIN_KNOWN_CONTRIBUTORS;
68
69 const fromLink = lastPageFromLink(res.headers.get("link"));
70 if (fromLink) return Math.max(fromLink, MIN_KNOWN_CONTRIBUTORS);
71
72 const body = await res.json().catch(() => null);
73 if (Array.isArray(body)) return Math.max(body.length, MIN_KNOWN_CONTRIBUTORS);
74
75 return MIN_KNOWN_CONTRIBUTORS;
76 }
77
78 export function lastPageFromLink(link: string | null): number | undefined {
79 if (!link) return undefined;
80
81 for (const part of link.split(",")) {
82 const [rawUrl, rawRel] = part.split(";").map((segment) => segment.trim());
83 if (rawRel !== 'rel="last"') continue;
84
85 const match = rawUrl.match(/^<(.+)>$/);
86 if (!match) continue;
87
88 const page = new URL(match[1]).searchParams.get("page");
89 const parsed = page ? Number.parseInt(page, 10) : NaN;
90 if (Number.isFinite(parsed) && parsed > 0) return parsed;
91 }
92
93 return undefined;
94 }
95
96 interface RawIssue {
97 number: number;
98 title: string;
99 html_url: string;
100 state: "open" | "closed";
101 user: { login: string; avatar_url: string };
102 created_at: string;
103 updated_at: string;
104 closed_at?: string | null;
105 comments: number;
106 labels: { name: string; color: string }[];
107 pull_request?: unknown;
108 draft?: boolean;
109 body?: string | null;
110 /**
111 * GitHub's relationship verdict for the author, present on both list
112 * endpoints. "FIRST_TIME_CONTRIBUTOR" is the only value we read.
113 */
114 author_association?: string;
115 }
116
117 interface RawRelease {
118 tag_name: string;
119 name?: string | null;
120 html_url: string;
121 created_at: string;
122 published_at?: string | null;
123 draft?: boolean;
124 prerelease?: boolean;
125 author?: { login: string; avatar_url: string } | null;
126 }
127
128 /** How many releases to pull. The tail is noise; the ticker sorts by date. */
129 const RELEASE_WINDOW = 5;
130
131 /** How recent a release must be to keep a reserved slot in a busy feed. */
132 const RELEASE_PIN_WINDOW_MS = 60 * 24 * 60 * 60 * 1000;
133
134 function firstTimer(association?: string): boolean {
135 return association === "FIRST_TIME_CONTRIBUTOR";
136 }
137
138 /**
139 * GitHub marks app accounts with a `[bot]` suffix on the login — its own
140 * verdict, not our inference. The wire exists to put the people behind the
141 * repository on the front page; dependency bumps and automated closes spend
142 * slots that belong to them, so bot-authored issues and pulls stay off. A
143 * published release is news no matter who pushed the button, so it keeps its
144 * slot — with a bot publisher's byline dropped instead
145 * (`author === ""` renders no by-line in components/ticker.tsx).
146 */
147 function isBot(login: string): boolean {
148 return login.endsWith("[bot]");
149 }
150
151 /**
152 * The repository's recent life: issues, pull requests, and releases.
153 *
154 * Three cached GitHub calls, no per-item follow-ups. Merge state, the
155 * author's handle, and GitHub's first-time-contributor verdict all arrive in
156 * the list payloads we already fetch, so naming a newcomer on the homepage
157 * costs nothing extra. Releases change rarely and cache for an hour;
158 * unauthenticated that is ~13 requests/hour against GitHub's 60/hour/IP.
159 */
160 export async function fetchFeed(token?: string, limit = 30): Promise<FeedItem[]> {
161 const [issuesRes, pullsRes, releasesRes] = await Promise.all([
162 fetch(
163 `${GH}/repos/${REPO}/issues?state=all&per_page=${limit}&sort=updated&direction=desc`,
164 { headers: headers(token), next: { revalidate: 600 } }
165 ),
166 fetch(
167 `${GH}/repos/${REPO}/pulls?state=all&per_page=${limit}&sort=updated&direction=desc`,
168 { headers: headers(token), next: { revalidate: 600 } }
169 ),
170 fetch(`${GH}/repos/${REPO}/releases?per_page=${RELEASE_WINDOW}`, {
171 headers: headers(token),
172 next: { revalidate: 3600 },
173 }),
174 ]);
175
176 const issues = await responseArray<RawIssue>(issuesRes);
177 const pulls = await responseArray<RawIssue & { merged_at?: string | null }>(pullsRes);
178 const releases = await responseArray<RawRelease>(releasesRes);
179
180 const items: FeedItem[] = [];
181
182 for (const it of issues) {
183 if (it.pull_request) continue; // GH issues endpoint returns PRs too
184 if (isBot(it.user.login)) continue; // automated maintenance, not contributor life
185 items.push({
186 kind: "issue",
187 number: it.number,
188 title: it.title,
189 url: it.html_url,
190 state: it.state,
191 author: it.user.login,
192 authorAvatar: it.user.avatar_url,
193 createdAt: it.created_at,
194 updatedAt: it.updated_at,
195 eventAt: (it.state === "closed" ? it.closed_at : it.created_at) ?? it.created_at,
196 comments: it.comments,
197 labels: it.labels?.map((l) => ({ name: l.name, color: l.color })) ?? [],
198 body: it.body ?? undefined,
199 firstTimeContributor: firstTimer(it.author_association),
200 });
201 }
202
203 for (const pr of pulls) {
204 if (isBot(pr.user.login)) continue; // automated maintenance, not contributor life
205 let state: FeedItem["state"] = pr.state;
206 let eventAt = pr.created_at;
207 if (pr.merged_at) {
208 state = "merged";
209 eventAt = pr.merged_at;
210 } else if (pr.draft) {
211 state = "draft";
212 } else if (pr.state === "closed") {
213 eventAt = pr.closed_at ?? pr.updated_at;
214 }
215 items.push({
216 kind: "pull",
217 number: pr.number,
218 title: pr.title,
219 url: pr.html_url,
220 state,
221 author: pr.user.login,
222 authorAvatar: pr.user.avatar_url,
223 createdAt: pr.created_at,
224 updatedAt: pr.updated_at,
225 eventAt,
226 comments: pr.comments,
227 labels: pr.labels?.map((l) => ({ name: l.name, color: l.color })) ?? [],
228 body: pr.body ?? undefined,
229 firstTimeContributor: firstTimer(pr.author_association),
230 });
231 }
232
233 for (const rel of releases) {
234 if (rel.draft) continue; // an unpublished draft is not news
235 const publishedAt = rel.published_at ?? rel.created_at;
236 // A bot-published release keeps its slot but not its byline.
237 const publisher =
238 rel.author && !isBot(rel.author.login) ? rel.author.login : "";
239 items.push({
240 kind: "release",
241 number: 0,
242 tag: rel.tag_name,
243 title: rel.name?.trim() || rel.tag_name,
244 url: rel.html_url,
245 state: "published",
246 author: publisher,
247 authorAvatar: publisher ? rel.author?.avatar_url ?? "" : "",
248 createdAt: rel.created_at,
249 updatedAt: publishedAt,
250 eventAt: publishedAt,
251 comments: 0,
252 labels: [],
253 });
254 }
255
256 const ordered = items.sort((a, b) => +new Date(b.updatedAt) - +new Date(a.updatedAt));
257 const kept = ordered.slice(0, limit);
258
259 // A release is the one event a busy week can bury: twenty issue comments
260 // will push last week's tag out of a pure recency window. Keep the newest
261 // published release in view — but only a recent one, and always carrying its
262 // real date, so a quiet quarter reads as a quiet quarter instead of pinning
263 // a two-year-old tag beside today's merges.
264 const newestRelease = ordered.find((i) => i.kind === "release");
265 const pinnable =
266 newestRelease &&
267 Date.now() - +new Date(newestRelease.eventAt ?? newestRelease.updatedAt) <
268 RELEASE_PIN_WINDOW_MS;
269 if (pinnable && kept.length === limit && !kept.some((i) => i.kind === "release")) {
270 kept[kept.length - 1] = newestRelease;
271 }
272
273 return kept;
274 }
275
276 async function responseArray<T>(res: Response): Promise<T[]> {
277 if (!res.ok) return [];
278 const body = await res.json().catch(() => null);
279 return Array.isArray(body) ? (body as T[]) : [];
280 }
281
282 /** Compact star-count label, e.g. 39312 → "39.3k". */
283 export function formatStars(n: number): string {
284 if (n >= 1000) {
285 return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`;
286 }
287 return String(n);
288 }
289
290 /**
291 * An age expressed the way `Intl.RelativeTimeFormat` wants it: a negative
292 * count and a unit. Past ages are negative; anything under a minute (and any
293 * unparseable or future date) is `0 seconds`, which `numeric: "auto"` renders
294 * as the locale's own "now".
295 *
296 * This exists so a surface can print an age in the reader's language without
297 * a hand-translated abbreviation table per locale — CLDR already has one, and
298 * the masthead already formats its date the same way off `chrome.dateLocale`.
299 */
300 export interface RelativeAge {
301 value: number;
302 unit: "second" | "minute" | "hour" | "day" | "month" | "year";
303 }
304
305 export function relativeAge(iso: string): RelativeAge {
306 const then = +new Date(iso);
307 if (!Number.isFinite(then)) return { value: 0, unit: "second" };
308
309 const mins = Math.round((Date.now() - then) / 60000);
310 if (mins < 1) return { value: 0, unit: "second" };
311 if (mins < 60) return { value: -mins, unit: "minute" };
312 const hrs = Math.round(mins / 60);
313 if (hrs < 24) return { value: -hrs, unit: "hour" };
314 const days = Math.round(hrs / 24);
315 if (days < 30) return { value: -days, unit: "day" };
316 const months = Math.round(days / 30);
317 if (months < 12) return { value: -months, unit: "month" };
318 return { value: -Math.round(months / 12), unit: "year" };
319 }
320
321 const AGE_SUFFIX: Record<RelativeAge["unit"], string> = {
322 second: "",
323 minute: "m",
324 hour: "h",
325 day: "d",
326 month: "mo",
327 year: "y",
328 };
329
330 /** Compact English age, e.g. "5m", "3h", "2y". Same thresholds as `relativeAge`. */
331 export function relativeTime(iso: string): string {
332 const age = relativeAge(iso);
333 if (age.unit === "second") return "just now";
334 return `${Math.abs(age.value)}${AGE_SUFFIX[age.unit]}`;
335 }
336
336 lines TYPESCRIPT