diff --git a/src/lib/git/forgejo.ts b/src/lib/git/forgejo.ts new file mode 100644 index 0000000..ceac1fa --- /dev/null +++ b/src/lib/git/forgejo.ts @@ -0,0 +1,90 @@ +import "server-only"; + +/** + * Forgejo REST client, against the Gitea 1.22 API shape. + * + * Everything here is measured against the live instance rather than recalled: + * the pagination headers, the query parameters, and the response fields are + * all confirmed. See the header of the Phase 2a plan. + */ + +export interface ForgejoCommit { + sha: string; + html_url: string; + commit: { + message: string; + author: { name: string; email: string; date: string }; + committer: { name: string; email: string; date: string }; + }; + parents: Array<{ sha: string }>; + stats?: { total: number; additions: number; deletions: number }; +} + +export interface CommitPage { + commits: ForgejoCommit[]; + page: number; + pageCount: number; + total: number; + hasMore: boolean; +} + +/** Forgejo caps limit at 50 on a default install; asking for more silently caps. */ +export const PAGE_SIZE = 50; + +function baseUrl(): string { + const url = process.env.FORGEJO_URL; + if (!url) throw new Error("FORGEJO_URL is not set"); + return url.replace(/\/+$/, ""); +} + +function headers(): HeadersInit { + const token = process.env.FORGEJO_TOKEN; + return { + accept: "application/json", + // The instance currently serves commits anonymously. Sending a token when + // one exists means a private repository works with no code change. + ...(token ? { authorization: `token ${token}` } : {}), + }; +} + +/** + * One page of commits, newest first. + * + * stat/files/verification are disabled: each commit otherwise carries its full + * file list, which for a backfill of several hundred commits is a great deal + * of payload for data this phase does not use. + */ +export async function fetchCommitPage( + owner: string, + repo: string, + page: number +): Promise { + const url = + `${baseUrl()}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits` + + `?page=${page}&limit=${PAGE_SIZE}&stat=false&files=false&verification=false`; + + const response = await fetch(url, { headers: headers(), cache: "no-store" }); + + if (!response.ok) { + throw new Error( + `Forgejo returned ${response.status} for ${owner}/${repo} page ${page}` + ); + } + + const commits = (await response.json()) as ForgejoCommit[]; + + return { + commits, + page: Number(response.headers.get("x-page") ?? page), + pageCount: Number(response.headers.get("x-pagecount") ?? 1), + total: Number(response.headers.get("x-total-count") ?? commits.length), + hasMore: response.headers.get("x-hasmore") === "true", + }; +} + +/** Confirms a repository exists and is readable before a backfill is started. */ +export async function repoExists(owner: string, repo: string): Promise { + const url = `${baseUrl()}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + const response = await fetch(url, { headers: headers(), cache: "no-store" }); + return response.ok; +} \ No newline at end of file