import type { FeedItem, RepoStats } from "./types"; const REPO = process.env.GITHUB_REPO ?? "Hmbown/CodeWhale"; const GH = "https://api.github.com"; const MIN_KNOWN_CONTRIBUTORS = 141; function isProductionBuild(): boolean { return process.env.NEXT_PHASE === "phase-production-build"; } function headers(token?: string): HeadersInit { const h: Record = { Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", "User-Agent": "codewhale-web", }; if (token) h.Authorization = `Bearer ${token}`; return h; } export async function fetchRepoStats(token?: string): Promise { // Live repository chrome is optional. Static generation must stay // deterministic and offline; deployed requests and ISR refreshes populate // the current values after the build. if (isProductionBuild()) { return { stars: 0, forks: 0, openIssues: 0, openPulls: 0, contributors: MIN_KNOWN_CONTRIBUTORS, fetchedAt: new Date().toISOString(), }; } const [repoRes, contribRes, releaseRes] = await Promise.all([ fetch(`${GH}/repos/${REPO}`, { headers: headers(token), next: { revalidate: 1800 } }), fetch(`${GH}/repos/${REPO}/contributors?per_page=1&anon=true`, { headers: headers(token), next: { revalidate: 3600 }, }), fetch(`${GH}/repos/${REPO}/releases/latest`, { headers: headers(token), next: { revalidate: 3600 } }), ]); const repo = repoRes.ok ? await repoRes.json().catch(() => null) : null; const stars = numberField(repo, "stargazers_count"); const forks = numberField(repo, "forks_count"); const repoOpenCount = numberField(repo, "open_issues_count"); const contributors = await contributorCount(contribRes); // Open PRs: cheapest path is the search API. const prRes = await fetch( `${GH}/search/issues?q=${encodeURIComponent(`repo:${REPO} is:pr is:open`)}&per_page=1`, { headers: headers(token), next: { revalidate: 1800 } } ); const prJson = prRes.ok ? ((await prRes.json().catch(() => null)) as { total_count?: number } | null) : null; const openPulls = typeof prJson?.total_count === "number" ? prJson.total_count : 0; const openIssues = Math.max(0, repoOpenCount - openPulls); let latestRelease: RepoStats["latestRelease"]; if (releaseRes.ok) { const r = (await releaseRes.json()) as { tag_name: string; published_at: string; html_url: string }; latestRelease = { tag: r.tag_name, publishedAt: r.published_at, url: r.html_url }; } return { stars, forks, openIssues, openPulls, contributors, latestRelease, fetchedAt: new Date().toISOString(), }; } function numberField(body: unknown, key: string): number { if (!body || typeof body !== "object") return 0; const value = (body as Record)[key]; return typeof value === "number" && Number.isFinite(value) ? value : 0; } async function contributorCount(res: Response): Promise { if (!res.ok) return MIN_KNOWN_CONTRIBUTORS; const fromLink = lastPageFromLink(res.headers.get("link")); if (fromLink) return Math.max(fromLink, MIN_KNOWN_CONTRIBUTORS); const body = await res.json().catch(() => null); if (Array.isArray(body)) return Math.max(body.length, MIN_KNOWN_CONTRIBUTORS); return MIN_KNOWN_CONTRIBUTORS; } export function lastPageFromLink(link: string | null): number | undefined { if (!link) return undefined; for (const part of link.split(",")) { const [rawUrl, rawRel] = part.split(";").map((segment) => segment.trim()); if (rawRel !== 'rel="last"') continue; const match = rawUrl.match(/^<(.+)>$/); if (!match) continue; const page = new URL(match[1]).searchParams.get("page"); const parsed = page ? Number.parseInt(page, 10) : NaN; if (Number.isFinite(parsed) && parsed > 0) return parsed; } return undefined; } interface RawIssue { number: number; title: string; html_url: string; state: "open" | "closed"; user: { login: string; avatar_url: string }; created_at: string; updated_at: string; closed_at?: string | null; comments: number; labels: { name: string; color: string }[]; pull_request?: unknown; draft?: boolean; body?: string | null; /** * GitHub's relationship verdict for the author, present on both list * endpoints. "FIRST_TIME_CONTRIBUTOR" is the only value we read. */ author_association?: string; } interface RawRelease { tag_name: string; name?: string | null; html_url: string; created_at: string; published_at?: string | null; draft?: boolean; prerelease?: boolean; author?: { login: string; avatar_url: string } | null; } /** How many releases to pull. The tail is noise; the ticker sorts by date. */ const RELEASE_WINDOW = 5; /** How recent a release must be to keep a reserved slot in a busy feed. */ const RELEASE_PIN_WINDOW_MS = 60 * 24 * 60 * 60 * 1000; function firstTimer(association?: string): boolean { return association === "FIRST_TIME_CONTRIBUTOR"; } /** * GitHub marks app accounts with a `[bot]` suffix on the login — its own * verdict, not our inference. The wire exists to put the people behind the * repository on the front page; dependency bumps and automated closes spend * slots that belong to them, so bot-authored issues and pulls stay off. A * published release is news no matter who pushed the button, so it keeps its * slot — with a bot publisher's byline dropped instead * (`author === ""` renders no by-line in components/ticker.tsx). */ function isBot(login: string): boolean { return login.endsWith("[bot]"); } /** * The repository's recent life: issues, pull requests, and releases. * * Three cached GitHub calls, no per-item follow-ups. Merge state, the * author's handle, and GitHub's first-time-contributor verdict all arrive in * the list payloads we already fetch, so naming a newcomer on the homepage * costs nothing extra. Releases change rarely and cache for an hour; * unauthenticated that is ~13 requests/hour against GitHub's 60/hour/IP. */ export async function fetchFeed(token?: string, limit = 30): Promise { if (isProductionBuild()) return []; const [issuesRes, pullsRes, releasesRes] = await Promise.all([ fetch( `${GH}/repos/${REPO}/issues?state=all&per_page=${limit}&sort=updated&direction=desc`, { headers: headers(token), next: { revalidate: 600 } } ), fetch( `${GH}/repos/${REPO}/pulls?state=all&per_page=${limit}&sort=updated&direction=desc`, { headers: headers(token), next: { revalidate: 600 } } ), fetch(`${GH}/repos/${REPO}/releases?per_page=${RELEASE_WINDOW}`, { headers: headers(token), next: { revalidate: 3600 }, }), ]); const issues = await responseArray(issuesRes); const pulls = await responseArray(pullsRes); const releases = await responseArray(releasesRes); const items: FeedItem[] = []; for (const it of issues) { if (it.pull_request) continue; // GH issues endpoint returns PRs too if (isBot(it.user.login)) continue; // automated maintenance, not contributor life items.push({ kind: "issue", number: it.number, title: it.title, url: it.html_url, state: it.state, author: it.user.login, authorAvatar: it.user.avatar_url, createdAt: it.created_at, updatedAt: it.updated_at, eventAt: (it.state === "closed" ? it.closed_at : it.created_at) ?? it.created_at, comments: it.comments, labels: it.labels?.map((l) => ({ name: l.name, color: l.color })) ?? [], body: it.body ?? undefined, firstTimeContributor: firstTimer(it.author_association), }); } for (const pr of pulls) { if (isBot(pr.user.login)) continue; // automated maintenance, not contributor life let state: FeedItem["state"] = pr.state; let eventAt = pr.created_at; if (pr.merged_at) { state = "merged"; eventAt = pr.merged_at; } else if (pr.draft) { state = "draft"; } else if (pr.state === "closed") { eventAt = pr.closed_at ?? pr.updated_at; } items.push({ kind: "pull", number: pr.number, title: pr.title, url: pr.html_url, state, author: pr.user.login, authorAvatar: pr.user.avatar_url, createdAt: pr.created_at, updatedAt: pr.updated_at, eventAt, comments: pr.comments, labels: pr.labels?.map((l) => ({ name: l.name, color: l.color })) ?? [], body: pr.body ?? undefined, firstTimeContributor: firstTimer(pr.author_association), }); } for (const rel of releases) { if (rel.draft) continue; // an unpublished draft is not news const publishedAt = rel.published_at ?? rel.created_at; // A bot-published release keeps its slot but not its byline. const publisher = rel.author && !isBot(rel.author.login) ? rel.author.login : ""; items.push({ kind: "release", number: 0, tag: rel.tag_name, title: rel.name?.trim() || rel.tag_name, url: rel.html_url, state: "published", author: publisher, authorAvatar: publisher ? rel.author?.avatar_url ?? "" : "", createdAt: rel.created_at, updatedAt: publishedAt, eventAt: publishedAt, comments: 0, labels: [], }); } const ordered = items.sort((a, b) => +new Date(b.updatedAt) - +new Date(a.updatedAt)); const kept = ordered.slice(0, limit); // A release is the one event a busy week can bury: twenty issue comments // will push last week's tag out of a pure recency window. Keep the newest // published release in view — but only a recent one, and always carrying its // real date, so a quiet quarter reads as a quiet quarter instead of pinning // a two-year-old tag beside today's merges. const newestRelease = ordered.find((i) => i.kind === "release"); const pinnable = newestRelease && Date.now() - +new Date(newestRelease.eventAt ?? newestRelease.updatedAt) < RELEASE_PIN_WINDOW_MS; if (pinnable && kept.length === limit && !kept.some((i) => i.kind === "release")) { kept[kept.length - 1] = newestRelease; } return kept; } async function responseArray(res: Response): Promise { if (!res.ok) return []; const body = await res.json().catch(() => null); return Array.isArray(body) ? (body as T[]) : []; } /** Compact star-count label, e.g. 39312 → "39.3k". */ export function formatStars(n: number): string { if (n >= 1000) { return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`; } return String(n); } /** * An age expressed the way `Intl.RelativeTimeFormat` wants it: a negative * count and a unit. Past ages are negative; anything under a minute (and any * unparseable or future date) is `0 seconds`, which `numeric: "auto"` renders * as the locale's own "now". * * This exists so a surface can print an age in the reader's language without * a hand-translated abbreviation table per locale — CLDR already has one, and * the masthead already formats its date the same way off `chrome.dateLocale`. */ export interface RelativeAge { value: number; unit: "second" | "minute" | "hour" | "day" | "month" | "year"; } export function relativeAge(iso: string): RelativeAge { const then = +new Date(iso); if (!Number.isFinite(then)) return { value: 0, unit: "second" }; const mins = Math.round((Date.now() - then) / 60000); if (mins < 1) return { value: 0, unit: "second" }; if (mins < 60) return { value: -mins, unit: "minute" }; const hrs = Math.round(mins / 60); if (hrs < 24) return { value: -hrs, unit: "hour" }; const days = Math.round(hrs / 24); if (days < 30) return { value: -days, unit: "day" }; const months = Math.round(days / 30); if (months < 12) return { value: -months, unit: "month" }; return { value: -Math.round(months / 12), unit: "year" }; } const AGE_SUFFIX: Record = { second: "", minute: "m", hour: "h", day: "d", month: "mo", year: "y", }; /** Compact English age, e.g. "5m", "3h", "2y". Same thresholds as `relativeAge`. */ export function relativeTime(iso: string): string { const age = relativeAge(iso); if (age.unit === "second") return "just now"; return `${Math.abs(age.value)}${AGE_SUFFIX[age.unit]}`; }