Skip to content

src/github.ts

126 lines · 3.7 KB · TypeScript
Raw
import githubRequest from 'kody:@kentcdodds/github/request'
import { withTransientGithubRetry } from './github-errors.ts'
import { MIN_GITHUB_REQUEST_MS, nextLinkFrom, requestTimeoutWithin } from './github-paging.ts'
import { repoHtmlUrl } from './repo.ts'
import type { RepoSettings } from './types.ts'

export { repoHtmlUrl }

export type GithubCommitListItem = {
	sha: string
	html_url: string
	commit: {
		message: string
		author: { name?: string; date?: string } | null
		committer: { name?: string; date?: string } | null
	}
	parents: Array<{ sha: string }>
}

export type GithubCommitFile = {
	filename: string
	status: string
	additions: number
	deletions: number
	changes: number
	patch?: string
}

export type GithubCommitDetail = GithubCommitListItem & {
	stats?: { additions: number; deletions: number; total: number }
	files?: GithubCommitFile[]
}

const COMMITS_PER_PAGE = 100

export type BranchHistory = {
	items: GithubCommitListItem[]
	pages: number
	/** False when the deadline stopped paging before the branch history ended. */
	complete: boolean
}

/**
 * List tracked-branch commits newest-first, one page per request, stopping at
 * `deadline`. Each page gets a bounded fetch timeout and its own transient
 * retry, so a slow GitHub page shortens the listing instead of restarting it
 * or running past the platform cap. Throws only when the first page fails.
 */
export async function listBranchCommits(
	repo: RepoSettings,
	options: { maxPages?: number; deadline?: number } = {},
): Promise<BranchHistory> {
	const maxPages = options.maxPages ?? 40
	const items: GithubCommitListItem[] = []
	let pages = 0
	// Follow GitHub's Link cursor; deep `page=N` offsets are several times slower.
	let nextPath: string | null = `/repos/${repo.owner}/${repo.repo}/commits`
	while (nextPath && pages < maxPages) {
		const path: string = nextPath
		let response
		try {
			response = await withTransientGithubRetry(() => {
				const timeoutMs = requestTimeoutWithin(options.deadline)
				if (timeoutMs === null) throw new BranchListingDeadlineError()
				return githubRequest({
					account: 'bot',
					path,
					query: pages === 0 ? { sha: repo.branch, per_page: COMMITS_PER_PAGE } : undefined,
					throwOnError: true,
					timeoutMs,
				})
			})
		} catch (error) {
			if (pages === 0 && !(error instanceof BranchListingDeadlineError)) throw error
			return { items, pages, complete: false }
		}
		const page = Array.isArray(response.data)
			? (response.data as GithubCommitListItem[])
			: []
		items.push(...page)
		pages += 1
		nextPath = nextLinkFrom(response.headers?.link)
	}
	return { items, pages, complete: nextPath === null }
}

class BranchListingDeadlineError extends Error {
	constructor() {
		super('Lineage branch listing deadline reached')
		this.name = 'BranchListingDeadlineError'
	}
}

export async function getCommitDetail(
	repo: RepoSettings,
	sha: string,
	options: { deadline?: number } = {},
): Promise<GithubCommitDetail> {
	const response = await withTransientGithubRetry(() =>
		githubRequest({
			account: 'bot',
			path: `/repos/${repo.owner}/${repo.repo}/commits/${sha}`,
			throwOnError: true,
			timeoutMs: requestTimeoutWithin(options.deadline) ?? MIN_GITHUB_REQUEST_MS,
		}),
	)
	return response.data as GithubCommitDetail
}

export async function getRepoTextFile(
	repo: RepoSettings,
	path: string,
): Promise<string | null> {
	const response = await githubRequest({
		account: 'bot',
		path: `/repos/${repo.owner}/${repo.repo}/contents/${path}`,
		headers: { Accept: 'application/vnd.github.raw' },
		query: { ref: repo.branch },
	})
	if (!response.ok) return null
	if (typeof response.text === 'string' && response.text && !response.text.startsWith('{')) {
		return response.text
	}
	if (typeof response.data === 'string') return response.data
	return null
}