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