import type { GitHubIssueSummary, GitHubPullRequestSummary, GitHubReferenceAuthor, GitHubReferenceLabel, } from "@chopin/protocol/github-reference"; export type GitHubUser = { id: string; login: string; avatarUrl: string; }; export type GitHubOrganizationMembership = { state: "active" | "pending"; role: "admin" | "member" | "billing_manager"; }; export type Repository = { id: string; owner: string; ownerAvatarUrl?: string; name: string; fullName: string; private: boolean; url: string; defaultBranch: string; permissions: { pull: boolean; push: boolean; admin: boolean; }; }; export type RepositoryPage = { repositories: Repository[]; nextPage: number | undefined; }; export type GitHubInstallation = { id: string; account: { login: string; avatarUrl: string; type: "user" | "organization"; }; repositorySelection: "all" | "selected"; configureUrl: string; suspended: boolean; permissions: { contents: boolean; pullRequests: boolean; checks: boolean; statuses: boolean; issues: boolean; }; }; export type InstallationPage = { installations: GitHubInstallation[]; nextPage: number | undefined; }; export type GitHubCondition = { ifNoneMatch?: string; }; export type GitHubConditional = | (T & { etag?: string }) | { notModified: true; etag?: string }; export type InstalledRepository = { repository: Repository; installation: GitHubInstallation; }; /** The narrow surface the GitHub reference summary route needs. */ export interface GitHubReferenceSource { /** Like `repositoryAccess`, but also names the installation that grants it. */ installedRepository( token: string, owner: string, name: string, ): Promise; pullRequest( token: string, owner: string, name: string, number: number, condition?: GitHubCondition, ): Promise>; issue( token: string, owner: string, name: string, number: number, condition?: GitHubCondition, ): Promise>; } export type GitHubTokenGrant = { accessToken: string; accessExpiresIn: number; refreshToken: string; refreshExpiresIn: number; }; export interface GitHub { authorize(input: { clientId: string; redirectUri: string; state: string; challenge: string; }): string; exchange(input: { clientId: string; clientSecret: string; redirectUri: string; code: string; verifier: string; }): Promise; refresh(input: { clientId: string; clientSecret?: string; refreshToken: string; }): Promise; user(token: string): Promise; organizationMembership( token: string, organization: string, ): Promise; installations(token: string, page: number): Promise; installations( token: string, page: number, condition: GitHubCondition, ): Promise>; installationRepositories( token: string, installationId: string, page: number, ): Promise; installationRepositories( token: string, installationId: string, page: number, condition: GitHubCondition, ): Promise>; /** Resolve the repository role granted directly to a bearer token. */ repository(token: string, owner: string, name: string): Promise; /** Resolve the repository role within this GitHub App's active installations. */ repositoryAccess(token: string, owner: string, name: string): Promise; invalidate(token: string): void; } export class GitHubError extends Error { readonly status: number; constructor(message: string, status = 502) { super(message); this.name = "GitHubError"; this.status = status; } } export class GitHubTokenError extends GitHubError { readonly terminal: boolean; constructor(message: string, status = 502, terminal = false) { super(message, status); this.name = "GitHubTokenError"; this.terminal = terminal; } } type Endpoints = { authorize: string; token: string; api: string; }; type Options = { fetch?: (input: string | URL | Request, init?: RequestInit) => Promise; endpoints?: Partial; clock?: () => number; }; type CachedAccess = { expiresAt: number; installations: Promise; repositories: Map; }; type RepositoryListing = { complete: boolean; index: Map; loading?: Promise; nextPage: number; visited: Set; }; const DEFAULTS: Endpoints = { authorize: "https://github.com/login/oauth/authorize", token: "https://github.com/login/oauth/access_token", api: "https://api.github.com", }; const REQUEST_TIMEOUT_MS = 15_000; const ACCESS_CACHE_MS = 30_000; const MAX_LIST_PAGES = 100; function tokenKey(token: string): string { return createHash("sha256").update(token).digest("base64url"); } function record(value: unknown): Record | undefined { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : undefined; } function user(value: unknown): GitHubUser { let item = record(value); if ( !item || typeof item.node_id !== "string" || typeof item.login !== "string" || typeof item.avatar_url !== "string" ) throw new GitHubError("GitHub returned an invalid user"); return { id: item.node_id, login: item.login, avatarUrl: item.avatar_url }; } function organizationMembership(value: unknown): GitHubOrganizationMembership { let item = record(value); if ( !item || (item.state !== "active" && item.state !== "pending") || (item.role !== "admin" && item.role !== "member" && item.role !== "billing_manager") ) throw new GitHubError("GitHub returned an invalid organization membership"); return { state: item.state, role: item.role }; } function repository(value: unknown): Repository { let item = record(value); let owner = record(item?.owner); let permissions = record(item?.permissions); if ( !item || !owner || !permissions || typeof item.node_id !== "string" || typeof owner.login !== "string" || typeof item.name !== "string" || typeof item.full_name !== "string" || typeof item.private !== "boolean" || typeof item.html_url !== "string" || typeof item.default_branch !== "string" || typeof permissions.pull !== "boolean" || typeof permissions.push !== "boolean" || typeof permissions.admin !== "boolean" ) throw new GitHubError("GitHub returned an invalid repository"); return { id: item.node_id, owner: owner.login, ...(typeof owner.avatar_url === "string" ? { ownerAvatarUrl: owner.avatar_url } : {}), name: item.name, fullName: item.full_name, private: item.private, url: item.html_url, defaultBranch: item.default_branch, permissions: { pull: permissions.pull, push: permissions.push, admin: permissions.admin, }, }; } function installation(value: unknown): GitHubInstallation { let item = record(value); let account = record(item?.account); let permissions = record(item?.permissions); let type: "user" | "organization" | undefined = account?.type === "User" ? "user" : account?.type === "Organization" ? "organization" : undefined; if ( !item || !account || !permissions || !Number.isSafeInteger(item.id) || (item.id as number) <= 0 || typeof account.login !== "string" || typeof account.avatar_url !== "string" || !type || (item.repository_selection !== "all" && item.repository_selection !== "selected") || typeof item.html_url !== "string" || !(item.suspended_at === null || typeof item.suspended_at === "string") ) throw new GitHubError("GitHub returned an invalid installation"); let allowed = (name: string) => permissions[name] === "read" || permissions[name] === "write"; return { id: String(item.id), account: { login: account.login, avatarUrl: account.avatar_url, type }, repositorySelection: item.repository_selection, configureUrl: item.html_url, suspended: item.suspended_at !== null, permissions: { contents: allowed("contents"), pullRequests: allowed("pull_requests"), checks: allowed("checks"), statuses: allowed("statuses"), issues: allowed("issues"), }, }; } const MAX_SUMMARY_TITLE = 500; const MAX_SUMMARY_LABELS = 20; function timestamp(value: unknown): string | undefined { if (typeof value !== "string") return undefined; let time = Date.parse(value); return Number.isNaN(time) ? undefined : new Date(time).toISOString(); } function optionalTimestamp(value: unknown): string | null | undefined { return value === null || value === undefined ? null : timestamp(value); } function author(value: unknown): GitHubReferenceAuthor | null | undefined { if (value === null) return null; let item = record(value); if (!item || typeof item.login !== "string" || typeof item.avatar_url !== "string") { return undefined; } return { login: item.login, avatarUrl: item.avatar_url }; } function labels(value: unknown): GitHubReferenceLabel[] | undefined { if (!Array.isArray(value)) return undefined; let result: GitHubReferenceLabel[] = []; for (let entry of value) { let item = record(entry); if ( !item || typeof item.name !== "string" || typeof item.color !== "string" || !/^[0-9a-fA-F]{6}$/.test(item.color) ) continue; result.push({ name: item.name, color: item.color.toLowerCase() }); if (result.length >= MAX_SUMMARY_LABELS) break; } return result; } function summaryBase(item: Record, owner: string, name: string, path: string) { let createdAt = timestamp(item.created_at); let updatedAt = timestamp(item.updated_at); let closedAt = optionalTimestamp(item.closed_at); let user = author(item.user); let labelList = labels(item.labels); if ( !Number.isSafeInteger(item.number) || (item.number as number) <= 0 || typeof item.title !== "string" || !Number.isSafeInteger(item.comments) || (item.comments as number) < 0 || !createdAt || !updatedAt || closedAt === undefined || user === undefined || !labelList ) return undefined; return { owner, repository: name, number: item.number as number, url: `https://github.com/${owner}/${name}/${path}/${item.number}`, title: item.title.slice(0, MAX_SUMMARY_TITLE), author: user, labels: labelList, comments: item.comments as number, createdAt, updatedAt, closedAt, }; } function pullRequestSummary(value: unknown, owner: string, name: string): GitHubPullRequestSummary { let item = record(value); let base = item && summaryBase(item, owner, name, "pull"); let head = record(item?.head); let target = record(item?.base); let mergedAt = optionalTimestamp(item?.merged_at); if ( !item || !base || (item.state !== "open" && item.state !== "closed") || typeof item.draft !== "boolean" || mergedAt === undefined || typeof head?.ref !== "string" || typeof target?.ref !== "string" ) throw new GitHubError("GitHub returned an invalid pull request"); return { ...base, kind: "pull", state: mergedAt ? "merged" : item.state, draft: item.draft, mergedAt, headBranch: head.ref, baseBranch: target.ref, }; } function issueSummary(value: unknown, owner: string, name: string): GitHubIssueSummary { let item = record(value); let base = item && summaryBase(item, owner, name, "issues"); if (!item || !base || (item.state !== "open" && item.state !== "closed")) { throw new GitHubError("GitHub returned an invalid issue"); } // The issues endpoint also serves pull requests; those belong to the pull summary. if (item.pull_request !== undefined) throw new GitHubError("issue not found", 404); let reason = item.state_reason; return { ...base, kind: "issue", state: item.state, stateReason: item.state === "open" ? null : reason === "not_planned" || reason === "duplicate" ? "not_planned" : "completed", }; } async function body(response: Response): Promise { try { return await response.json(); } catch { throw new GitHubError("GitHub returned an unreadable response"); } } async function isRateLimited(response: Response): Promise { if (response.status === 429) return true; if (response.status !== 403) return false; if ( response.headers.get("x-ratelimit-remaining") === "0" || response.headers.has("retry-after") ) return true; try { let value = record(await response.clone().json()); return (typeof value?.message === "string" && /(rate limit|abuse detection)/i.test(value.message)) || (typeof value?.documentation_url === "string" && /(rate-limits|rate_limit)/i.test(value.documentation_url)); } catch { return false; } } function nextPage(link: string | null, api: string, path: string): number | undefined { if (!link) return undefined; let next = link.split(",").map(part => part.trim()).find(part => /;\s*rel="next"$/.test(part)); let match = next?.match(/^<([^>]+)>/); if (!match) return undefined; try { let url = new URL(match[1]!); let expected = new URL(api); if (url.origin !== expected.origin || url.pathname !== path) return undefined; let page = Number(url.searchParams.get("page")); return Number.isSafeInteger(page) && page > 0 ? page : undefined; } catch { return undefined; } } /** Narrow GitHub OAuth and repository API client with validated responses. */ export class GitHubClient implements GitHub, GitHubReferenceSource { readonly #fetch: (input: string | URL | Request, init?: RequestInit) => Promise; readonly #endpoints: Endpoints; readonly #clock: () => number; readonly #access = new Map(); constructor(options: Options = {}) { this.#fetch = options.fetch ?? fetch; this.#endpoints = { ...DEFAULTS, ...options.endpoints }; this.#clock = options.clock ?? Date.now; } authorize(input: { clientId: string; redirectUri: string; state: string; challenge: string; }): string { let url = new URL(this.#endpoints.authorize); url.searchParams.set("client_id", input.clientId); url.searchParams.set("redirect_uri", input.redirectUri); url.searchParams.set("state", input.state); url.searchParams.set("code_challenge", input.challenge); url.searchParams.set("code_challenge_method", "S256"); return url.href; } async exchange(input: { clientId: string; clientSecret: string; redirectUri: string; code: string; verifier: string; }): Promise { return this.#token({ client_id: input.clientId, client_secret: input.clientSecret, code: input.code, redirect_uri: input.redirectUri, code_verifier: input.verifier, }, false); } async refresh(input: { clientId: string; clientSecret?: string; refreshToken: string; }): Promise { return this.#token({ client_id: input.clientId, ...(input.clientSecret ? { client_secret: input.clientSecret } : {}), grant_type: "refresh_token", refresh_token: input.refreshToken, }, true); } async #token(parameters: Record, refreshing: boolean): Promise { let response: Response; try { response = await this.#fetch(this.#endpoints.token, { method: "POST", headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded", }, body: new URLSearchParams(parameters), redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); } catch { throw new GitHubError("GitHub OAuth is unavailable"); } let value = record(await body(response)); let terminal = refreshing && value?.error === "bad_refresh_token"; if (!response.ok || value?.error) { throw new GitHubTokenError( refreshing ? "GitHub rejected the token refresh" : "GitHub rejected the OAuth exchange", terminal ? 401 : response.status === 429 ? 429 : 502, terminal, ); } if ( typeof value?.access_token !== "string" || !value.access_token || typeof value.refresh_token !== "string" || !value.refresh_token || !Number.isSafeInteger(value.expires_in) || (value.expires_in as number) <= 0 || !Number.isSafeInteger(value.refresh_token_expires_in) || (value.refresh_token_expires_in as number) <= 0 || value.token_type !== "bearer" ) { throw new GitHubTokenError("GitHub returned an invalid expiring user token"); } return { accessToken: value.access_token, accessExpiresIn: value.expires_in as number, refreshToken: value.refresh_token, refreshExpiresIn: value.refresh_token_expires_in as number, }; } async user(token: string): Promise { return user(await this.#api("/user", token)); } async organizationMembership( token: string, organization: string, ): Promise { try { return organizationMembership( await this.#api(`/user/memberships/orgs/${encodeURIComponent(organization)}`, token), ); } catch (err) { if (err instanceof GitHubError && err.status === 404) return undefined; throw err; } } installations(token: string, page: number): Promise; installations( token: string, page: number, condition: GitHubCondition, ): Promise>; async installations( token: string, page: number, condition?: GitHubCondition, ): Promise> { let path = "/user/installations"; let url = new URL(path, this.#endpoints.api); url.searchParams.set("per_page", "100"); url.searchParams.set("page", String(page)); let response = await this.#request(url, token, condition?.ifNoneMatch); if (response.status === 304) { let etag = response.headers.get("etag") ?? condition?.ifNoneMatch; return { notModified: true, ...(etag ? { etag } : {}) }; } let value = record(await body(response)); if (!Array.isArray(value?.installations)) { throw new GitHubError("GitHub returned an invalid installation list"); } let result: InstallationPage = { installations: value.installations.map(installation), nextPage: nextPage(response.headers.get("link"), this.#endpoints.api, path), }; let etag = condition && response.headers.get("etag"); return etag ? { ...result, etag } : result; } installationRepositories( token: string, installationId: string, page: number, ): Promise; installationRepositories( token: string, installationId: string, page: number, condition: GitHubCondition, ): Promise>; async installationRepositories( token: string, installationId: string, page: number, condition?: GitHubCondition, ): Promise> { if (!/^\d+$/.test(installationId)) throw new GitHubError("installation not found", 404); let path = `/user/installations/${installationId}/repositories`; let url = new URL(path, this.#endpoints.api); url.searchParams.set("per_page", "100"); url.searchParams.set("page", String(page)); let response = await this.#request(url, token, condition?.ifNoneMatch); if (response.status === 304) { let etag = response.headers.get("etag") ?? condition?.ifNoneMatch; return { notModified: true, ...(etag ? { etag } : {}) }; } let value = record(await body(response)); if (!Array.isArray(value?.repositories)) { throw new GitHubError("GitHub returned an invalid repository list"); } let result: RepositoryPage = { repositories: value.repositories.map(repository), nextPage: nextPage(response.headers.get("link"), this.#endpoints.api, path), }; let etag = condition && response.headers.get("etag"); return etag ? { ...result, etag } : result; } async repository(token: string, owner: string, name: string): Promise { let path = `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`; return repository(await this.#api(path, token)); } async repositoryAccess( token: string, owner: string, name: string, ): Promise { return (await this.installedRepository(token, owner, name))?.repository; } async installedRepository( token: string, owner: string, name: string, ): Promise { let fullName = `${owner}/${name}`.toLowerCase(); let now = this.#clock(); for (let [key, entry] of this.#access) { if (entry.expiresAt <= now) this.#access.delete(key); } let key = tokenKey(token); let cached = this.#access.get(key); if (!cached || cached.expiresAt <= now) { cached = { expiresAt: now + ACCESS_CACHE_MS, installations: this.#installationIndex(token), repositories: new Map(), }; this.#access.set(key, cached); } try { let installations = await cached.installations; for (let installed of installations) { if ( installed.suspended || !installed.permissions.contents || installed.account.login.toLowerCase() !== owner.toLowerCase() ) continue; let listing = cached.repositories.get(installed.id); if (!listing) { listing = { complete: false, index: new Map(), nextPage: 1, visited: new Set(), }; cached.repositories.set(installed.id, listing); } let found = await this.#findRepository(token, installed.id, fullName, listing); if (found) return { repository: found, installation: installed }; } return undefined; } catch (err) { if (this.#access.get(key) === cached) this.#access.delete(key); throw err; } } pullRequest( token: string, owner: string, name: string, number: number, condition?: GitHubCondition, ): Promise> { return this.#summary(token, owner, name, "pulls", number, condition, pullRequestSummary); } issue( token: string, owner: string, name: string, number: number, condition?: GitHubCondition, ): Promise> { return this.#summary(token, owner, name, "issues", number, condition, issueSummary); } async #summary( token: string, owner: string, name: string, collection: "pulls" | "issues", number: number, condition: GitHubCondition | undefined, parse: (value: unknown, owner: string, name: string) => T, ): Promise> { if (!Number.isSafeInteger(number) || number <= 0) { throw new GitHubError(`${collection} not found`, 404); } let url = new URL( `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/${collection}/${number}`, this.#endpoints.api, ); let response = await this.#request(url, token, condition?.ifNoneMatch); let etag = response.headers.get("etag") ?? undefined; if (response.status === 304) { etag ??= condition?.ifNoneMatch; return { notModified: true, ...(etag ? { etag } : {}) }; } let result = parse(await body(response), owner, name); return etag ? { ...result, etag } : result; } invalidate(token: string): void { this.#access.delete(tokenKey(token)); } async #installationIndex(token: string): Promise { let index: GitHubInstallation[] = []; let installationPage = 1; let installationPages = new Set(); while (!installationPages.has(installationPage)) { if (installationPages.size >= MAX_LIST_PAGES) { throw new GitHubError("GitHub installation listing exceeded its safety limit"); } installationPages.add(installationPage); let result = await this.installations(token, installationPage); index.push(...result.installations); if (!result.nextPage) return index; installationPage = result.nextPage; } return index; } async #findRepository( token: string, installationId: string, fullName: string, listing: RepositoryListing, ): Promise { while (true) { let found = listing.index.get(fullName); if (found || listing.complete) return found; if (listing.loading) { await listing.loading; continue; } if (listing.visited.size >= MAX_LIST_PAGES || listing.visited.has(listing.nextPage)) { throw new GitHubError("GitHub repository listing exceeded its safety limit"); } let page = listing.nextPage; listing.visited.add(page); let loading = this.installationRepositories(token, installationId, page).then(result => { for (let item of result.repositories) { listing.index.set(item.fullName.toLowerCase(), item); } if (result.nextPage) listing.nextPage = result.nextPage; else listing.complete = true; }); listing.loading = loading; try { await loading; } finally { if (listing.loading === loading) listing.loading = undefined; } } } async #api(path: string, token: string): Promise { return body(await this.#request(new URL(path, this.#endpoints.api), token)); } async #request(url: URL, token: string, ifNoneMatch?: string): Promise { let response: Response; try { let headers = new Headers({ accept: "application/vnd.github+json", authorization: `Bearer ${token}`, "user-agent": "chopin", "x-github-api-version": "2022-11-28", }); if (ifNoneMatch) headers.set("if-none-match", ifNoneMatch); response = await this.#fetch(url, { headers, redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); } catch { throw new GitHubError("GitHub API is unavailable"); } if (response.status === 304 && ifNoneMatch) return response; if (!response.ok) { let rateLimited = await isRateLimited(response); let status = rateLimited ? 429 : response.status === 401 || response.status === 403 || response.status === 404 ? response.status : 502; throw new GitHubError("GitHub API rejected the request", status); } return response; } } import { createHash } from "node:crypto";