feat(git): Forgejo commit API client

Verified against the live instance: status 200, total 787 commits, first
sha 1bc0da40... 'feat(customers): lift active/inactive toggle to top of
customer form'.
This commit is contained in:
Dave 2026-09-16 11:47:22 +02:00
parent a05c1b8ae0
commit 809983ee5c

90
src/lib/git/forgejo.ts Normal file
View file

@ -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<CommitPage> {
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<boolean> {
const url = `${baseUrl()}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
const response = await fetch(url, { headers: headers(), cache: "no-store" });
return response.ok;
}