feat: add Gitea API client for state commits and PR comments

This commit is contained in:
Prox 2026-03-04 00:19:12 +02:00
parent 05440ea740
commit 122db3540f
2 changed files with 332 additions and 0 deletions

192
src/gitea/client.test.ts Normal file
View File

@ -0,0 +1,192 @@
import { assertEquals } from "@std/assert";
import { type FetchFn, GiteaApiError, GiteaClient } from "./client.ts";
function mockFetch(
responses: Map<string, { status: number; body: unknown }>,
): FetchFn {
return (input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input.url;
const method = init?.method ?? "GET";
const key = `${method} ${url}`;
const resp = responses.get(key);
if (!resp) throw new Error(`Unmocked request: ${key}`);
return Promise.resolve(
new Response(JSON.stringify(resp.body), {
status: resp.status,
headers: { "Content-Type": "application/json" },
}),
);
};
}
Deno.test("GiteaClient.getFileContent fetches and decodes base64 content with SHA", async () => {
const client = new GiteaClient(
"https://gitea.example.com",
"test-token",
"BlastPilot/netbird-gitops",
mockFetch(
new Map([
[
"GET https://gitea.example.com/api/v1/repos/BlastPilot/netbird-gitops/contents/netbird.json?ref=main",
{
status: 200,
body: {
content: btoa('{"groups":{}}'),
sha: "abc123",
},
},
],
]),
),
);
const result = await client.getFileContent("netbird.json", "main");
assertEquals(result.sha, "abc123");
assertEquals(result.content, '{"groups":{}}');
});
Deno.test("GiteaClient.updateFile sends PUT with base64 content and SHA", async () => {
let capturedMethod: string | undefined;
let capturedBody: string | undefined;
let capturedUrl: string | undefined;
const fakeFetch: FetchFn = (
input: string | URL | Request,
init?: RequestInit,
) => {
capturedUrl = typeof input === "string" ? input : input.toString();
capturedMethod = init?.method;
capturedBody = init?.body as string;
return Promise.resolve(
new Response(JSON.stringify({ content: {} }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
};
const client = new GiteaClient(
"https://gitea.example.com",
"test-token",
"BlastPilot/netbird-gitops",
fakeFetch,
);
await client.updateFile(
"netbird.json",
'{"groups":{}}',
"abc123",
"chore: update enrolled state",
"main",
);
assertEquals(capturedMethod, "PUT");
assertEquals(
capturedUrl,
"https://gitea.example.com/api/v1/repos/BlastPilot/netbird-gitops/contents/netbird.json",
);
const parsed = JSON.parse(capturedBody!);
assertEquals(parsed.sha, "abc123");
assertEquals(parsed.branch, "main");
assertEquals(parsed.message, "chore: update enrolled state");
assertEquals(atob(parsed.content), '{"groups":{}}');
});
Deno.test("GiteaClient.postIssueComment sends POST with body", async () => {
let capturedMethod: string | undefined;
let capturedBody: string | undefined;
let capturedUrl: string | undefined;
const fakeFetch: FetchFn = (
input: string | URL | Request,
init?: RequestInit,
) => {
capturedUrl = typeof input === "string" ? input : input.toString();
capturedMethod = init?.method;
capturedBody = init?.body as string;
return Promise.resolve(
new Response(JSON.stringify({ id: 1 }), {
status: 201,
headers: { "Content-Type": "application/json" },
}),
);
};
const client = new GiteaClient(
"https://gitea.example.com",
"test-token",
"BlastPilot/netbird-gitops",
fakeFetch,
);
await client.postIssueComment(42, "Reconciliation complete.");
assertEquals(capturedMethod, "POST");
assertEquals(
capturedUrl,
"https://gitea.example.com/api/v1/repos/BlastPilot/netbird-gitops/issues/42/comments",
);
assertEquals(JSON.parse(capturedBody!), { body: "Reconciliation complete." });
});
Deno.test("GiteaClient throws GiteaApiError on non-2xx response", async () => {
const client = new GiteaClient(
"https://gitea.example.com",
"test-token",
"BlastPilot/netbird-gitops",
mockFetch(
new Map([
[
"GET https://gitea.example.com/api/v1/repos/BlastPilot/netbird-gitops/contents/netbird.json?ref=main",
{
status: 404,
body: { message: "not found" },
},
],
]),
),
);
try {
await client.getFileContent("netbird.json", "main");
throw new Error("Should have thrown");
} catch (e) {
assertEquals(e instanceof GiteaApiError, true);
assertEquals((e as GiteaApiError).status, 404);
assertEquals((e as Error).message.includes("404"), true);
}
});
Deno.test("GiteaClient sends lowercase 'token' auth header (Gitea convention)", async () => {
let capturedHeaders: Headers | undefined;
const fakeFetch: FetchFn = (
_input: string | URL | Request,
init?: RequestInit,
) => {
capturedHeaders = new Headers(init?.headers);
return Promise.resolve(
new Response(JSON.stringify({ content: btoa("{}"), sha: "x" }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
};
const client = new GiteaClient(
"https://gitea.example.com",
"my-secret-token",
"BlastPilot/netbird-gitops",
fakeFetch,
);
await client.getFileContent("netbird.json", "main");
assertEquals(
capturedHeaders?.get("Authorization"),
"token my-secret-token",
);
assertEquals(capturedHeaders?.get("Accept"), "application/json");
});

140
src/gitea/client.ts Normal file
View File

@ -0,0 +1,140 @@
/** Narrowed fetch signature used for dependency injection. */
export type FetchFn = (
input: string | URL | Request,
init?: RequestInit,
) => Promise<Response>;
/** Thrown when the Gitea API returns a non-2xx status. */
export class GiteaApiError extends Error {
constructor(
public readonly status: number,
public readonly method: string,
public readonly path: string,
public readonly body: unknown,
) {
super(`Gitea API error: ${method} ${path} returned ${status}`);
this.name = "GiteaApiError";
}
}
/**
* Thin HTTP client for the Gitea API.
*
* Used by the event poller to commit `enrolled: true` state changes
* back to the git repo, and by CI workflows via PR comments.
*
* Auth uses `token <token>` (lowercase Gitea convention, distinct
* from NetBird's uppercase `Token`).
*
* Accepts an injectable fetch function so callers (and tests) can swap
* the transport without touching the client logic.
*/
export class GiteaClient {
constructor(
private readonly baseUrl: string,
private readonly token: string,
private readonly repo: string, // "owner/repo"
private readonly fetchFn: FetchFn = fetch,
) {}
// ---------------------------------------------------------------------------
// Internal
// ---------------------------------------------------------------------------
private async request<T>(
method: string,
path: string,
body?: unknown,
): Promise<T> {
const url = `${this.baseUrl}/api/v1${path}`;
const headers: Record<string, string> = {
"Authorization": `token ${this.token}`,
"Accept": "application/json",
};
if (body !== undefined) {
headers["Content-Type"] = "application/json";
}
const resp = await this.fetchFn(url, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!resp.ok) {
const text = await resp.text().catch(() => "");
let errorBody: unknown;
try {
errorBody = JSON.parse(text);
} catch {
errorBody = text;
}
throw new GiteaApiError(resp.status, method, path, errorBody);
}
// 204 No Content — nothing to parse
if (resp.status === 204) {
return undefined as T;
}
return (await resp.json()) as T;
}
// ---------------------------------------------------------------------------
// Repository Contents
// ---------------------------------------------------------------------------
/** Get file content and SHA for optimistic concurrency. */
async getFileContent(
path: string,
ref: string,
): Promise<{ content: string; sha: string }> {
const data = await this.request<{ content: string; sha: string }>(
"GET",
`/repos/${this.repo}/contents/${path}?ref=${ref}`,
);
return {
content: atob(data.content),
sha: data.sha,
};
}
/**
* Update file with optimistic concurrency (SHA check).
*
* The SHA from getFileContent acts as a CAS token the update will be
* rejected by Gitea if the file has been modified since we read it.
* This prevents race conditions when the poller and CI both try to
* update netbird.json.
*/
async updateFile(
path: string,
content: string,
sha: string,
message: string,
branch: string,
): Promise<void> {
await this.request("PUT", `/repos/${this.repo}/contents/${path}`, {
content: btoa(content),
sha,
message,
branch,
});
}
// ---------------------------------------------------------------------------
// Issue Comments (used for PR status reporting)
// ---------------------------------------------------------------------------
/** Post a comment on an issue or pull request. */
async postIssueComment(
issueNumber: number,
body: string,
): Promise<void> {
await this.request(
"POST",
`/repos/${this.repo}/issues/${issueNumber}/comments`,
{ body },
);
}
}