import { GITHUB_ACTIVITY_ACTIONS, type ActivityEvent, type ActivityProvider, type GitHubActivity, type GitHubActivityAction, type GitHubTarget, type ProviderContext, type PublicGitHubActivity, } from "../types"; import { asRecord, recordField, safeDate, stringField, type UnknownRecord } from "../validation"; export const GITHUB_USERNAME = "kualta"; const GITHUB_API = "https://api.github.com"; const GITHUB_API_VERSION = "2022-11-28"; const ALLOWED_ACTIONS = new Set(GITHUB_ACTIVITY_ACTIONS); function safeAction(payload: UnknownRecord | undefined): GitHubActivityAction | undefined { const action = stringField(payload, "action"); return action && ALLOWED_ACTIONS.has(action) ? (action as GitHubActivityAction) : undefined; } function safeGitHubUrl(value: unknown): string | undefined { if (typeof value !== "string") return undefined; try { const url = new URL(value); if (url.protocol !== "https:" || url.hostname !== "github.com") return undefined; return url.toString(); } catch { return undefined; } } function safeTitle(value: unknown): string | undefined { if (typeof value !== "string") return undefined; const title = value.trim(); return title ? title.slice(0, 200) : undefined; } function repositoryFromEvent(event: UnknownRecord): PublicGitHubActivity["repository"] { const name = stringField(recordField(event, "repo"), "name"); if (!name || !/^[^/\s]+\/[^/\s]+$/.test(name)) return undefined; return { name, url: `https://github.com/${name}`, }; } function targetFromResource(resource: UnknownRecord | undefined, titleKeys: string[]): GitHubTarget | undefined { const url = safeGitHubUrl(resource?.html_url); if (!url) return undefined; let title: string | undefined; for (const key of titleKeys) { title = safeTitle(resource?.[key]); if (title) break; } return title ? { title, url } : { url }; } function targetFromEvent( eventType: string, payload: UnknownRecord | undefined, repository: PublicGitHubActivity["repository"], ): GitHubTarget | undefined { switch (eventType) { case "CommitCommentEvent": return targetFromResource(recordField(payload, "comment"), ["body"]); case "DiscussionEvent": return targetFromResource(recordField(payload, "discussion"), ["title"]); case "ForkEvent": return targetFromResource(recordField(payload, "forkee"), ["full_name", "name"]); case "IssueCommentEvent": return ( targetFromResource(recordField(payload, "comment"), ["body"]) ?? targetFromResource(recordField(payload, "issue"), ["title"]) ); case "IssuesEvent": return targetFromResource(recordField(payload, "issue"), ["title"]); case "PullRequestEvent": case "PullRequestReviewEvent": case "PullRequestReviewCommentEvent": return targetFromResource(recordField(payload, "pull_request"), ["title"]); case "ReleaseEvent": return targetFromResource(recordField(payload, "release"), ["name", "tag_name"]); case "PushEvent": { const head = stringField(payload, "head"); if (!repository || !head || !/^[a-f0-9]{7,64}$/i.test(head)) return undefined; return { url: `${repository.url}/commit/${head}` }; } case "GollumEvent": { const pages = payload?.pages; if (!Array.isArray(pages)) return undefined; return targetFromResource(asRecord(pages[0]), ["title", "page_name"]); } default: return undefined; } } function refTypeFromPayload(payload: UnknownRecord | undefined): PublicGitHubActivity["refType"] { const refType = stringField(payload, "ref_type"); if (refType === "branch" || refType === "repository" || refType === "tag") { return refType; } return undefined; } async function hashIdentifier(value: string): Promise { const bytes = new TextEncoder().encode(value); const digest = await crypto.subtle.digest("SHA-256", bytes); const hash = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")) .join("") .slice(0, 24); return `github:${hash}`; } async function normalizeGitHubEvent(value: unknown): Promise { const event = asRecord(value); const rawId = stringField(event, "id"); const eventType = stringField(event, "type")?.slice(0, 80); const occurredAt = safeDate(event?.created_at); const isPublic = event?.public; if (!event || !rawId || !eventType || !occurredAt || typeof isPublic !== "boolean") { return undefined; } const payload = recordField(event, "payload"); const action = safeAction(payload); const id = await hashIdentifier(rawId); if (!isPublic) { return { id, source: "github", kind: "event", occurredAt, visibility: "private", eventType, ...(action ? { action } : {}), }; } const repository = repositoryFromEvent(event); const target = targetFromEvent(eventType, payload, repository); const refType = refTypeFromPayload(payload); return { id, source: "github", kind: "event", occurredAt, visibility: "public", eventType, ...(action ? { action } : {}), ...(repository ? { repository } : {}), ...(target ? { target } : {}), ...(refType ? { refType } : {}), }; } export async function normalizeGitHubEvents(value: unknown): Promise { if (!Array.isArray(value)) throw new Error("GitHub returned an invalid event list"); const events: GitHubActivity[] = []; for (const item of value) { const event = await normalizeGitHubEvent(item); if (!event) throw new Error("GitHub returned a malformed event"); events.push(event); } return events; } function githubHeaders(token: string): HeadersInit { return { Accept: "application/vnd.github+json", Authorization: `Bearer ${token}`, "User-Agent": "kualta.dev", "X-GitHub-Api-Version": GITHUB_API_VERSION, }; } async function fetchGitHubJson(context: ProviderContext, path: string, token: string): Promise { const response = await context.fetch(`${GITHUB_API}${path}`, { headers: githubHeaders(token), signal: context.signal, }); if (!response.ok) throw new Error(`GitHub request failed with status ${response.status}`); return response.json(); } const CONTRIBUTION_YEARS_QUERY = `query($login: String!) { user(login: $login) { contributionsCollection { contributionYears } } }`; const CONTRIBUTION_DAY = /^\d{4}-\d{2}-\d{2}$/; const MAX_CONTRIBUTIONS_PER_DAY = 10_000; const FIRST_GITHUB_YEAR = 2008; async function fetchGraphQL(context: ProviderContext, token: string, body: unknown): Promise { const response = await context.fetch(`${GITHUB_API}/graphql`, { method: "POST", headers: { ...githubHeaders(token), "content-type": "application/json" }, signal: context.signal, body: JSON.stringify(body), }); if (!response.ok) throw new Error(`GitHub request failed with status ${response.status}`); const parsed = asRecord(await response.json()); if (!parsed || (Array.isArray(parsed.errors) && parsed.errors.length)) { throw new Error("GitHub returned an invalid contributions response"); } return parsed; } /** the years GitHub actually holds contributions for, newest first */ async function fetchContributionYears(context: ProviderContext, token: string, now: Date): Promise { const body = await fetchGraphQL(context, token, { query: CONTRIBUTION_YEARS_QUERY, variables: { login: GITHUB_USERNAME }, }); const collection = recordField(recordField(recordField(body, "data"), "user"), "contributionsCollection"); const years = collection?.contributionYears; if (!Array.isArray(years)) throw new Error("GitHub returned an invalid contributions response"); const currentYear = now.getUTCFullYear(); const usable = years.filter( (year): year is number => typeof year === "number" && Number.isInteger(year) && year >= FIRST_GITHUB_YEAR && year <= currentYear, ); return [...new Set(usable)].sort((left, right) => right - left); } function calendarAttribute(tag: string, name: string): string | undefined { return new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`).exec(tag)?.slice(1).find(Boolean); } /** Pair each dated cell with its tooltip; intensity levels are not counts. */ export function normalizeContributionCalendar(html: string, year: number, now: Date): ActivityEvent[] { const counts = new Map(); for (const match of html.matchAll(/]*>([^<]*)<\/tool-tip>/g)) { const id = calendarAttribute(match[0], "for"); const total = /^(No|\d{1,3}(?:,\d{3})+|\d+) contributions? on\s/.exec(match[1].trim()); if (!id || !total) continue; const count = total[1] === "No" ? 0 : Number(total[1].replaceAll(",", "")); if (Number.isSafeInteger(count)) counts.set(id, count); } const days: ActivityEvent[] = []; const dates = new Set(); for (const match of html.matchAll(/]*\bdata-date\s*=[^>]*>/g)) { const date = calendarAttribute(match[0], "data-date"); const id = calendarAttribute(match[0], "id"); const count = id ? counts.get(id) : undefined; const endOfDay = date ? Date.parse(`${date}T23:59:59.999Z`) : NaN; if ( !date || !CONTRIBUTION_DAY.test(date) || !date.startsWith(`${year}-`) || !Number.isFinite(endOfDay) || new Date(endOfDay).toISOString().slice(0, 10) !== date || count === undefined || dates.has(date) ) { throw new Error("GitHub returned an invalid contributions calendar"); } dates.add(date); if (count === 0 || date > now.toISOString().slice(0, 10)) continue; days.push({ id: `github:contributions:${date}`, source: "github", kind: "contributions", // Today's observation time stays stable in the archive until its count changes. occurredAt: new Date(Math.min(endOfDay, now.getTime())).toISOString(), count: Math.min(count, MAX_CONTRIBUTIONS_PER_DAY), }); } // Login/error pages and changed markup must retain the last good snapshot. if (!dates.size) throw new Error("GitHub returned an invalid contributions calendar"); return days; } /** * GraphQL calendar totals depend on the token's repository access, even for the * account owner. The public profile includes anonymized private contributions * when the owner enables them, so read those exact counts without credentials. * GraphQL is only used to discover the available years. */ async function fetchContributionDays(context: ProviderContext, token: string, now: Date): Promise { const years = await fetchContributionYears(context, token, now); const calendars = await Promise.all( years.map(async (year) => { const url = `https://github.com/users/${GITHUB_USERNAME}/contributions?from=${year}-01-01&to=${year}-12-31`; const response = await context.fetch(url, { headers: { Accept: "text/html", "Accept-Language": "en-US", "User-Agent": "kualta.dev" }, signal: context.signal, }); if (!response.ok) throw new Error(`GitHub request failed with status ${response.status}`); return normalizeContributionCalendar(await response.text(), year, now); }), ); return calendars.flat(); } async function fetchGitHubActivity(context: ProviderContext): Promise { const token = context.secrets.githubToken; if (!token) throw new Error("GitHub activity token is missing"); const viewer = asRecord(await fetchGitHubJson(context, "/user", token)); const login = stringField(viewer, "login"); if (login?.toLowerCase() !== GITHUB_USERNAME) { throw new Error("GitHub activity token belongs to the wrong account"); } const [events, contributions] = await Promise.all([ fetchGitHubJson(context, `/users/${GITHUB_USERNAME}/events?per_page=100`, token).then(normalizeGitHubEvents), fetchContributionDays(context, token, context.now), ]); return [...events, ...contributions]; } export const githubProvider: ActivityProvider = { id: "github", fetch: fetchGitHubActivity, };