From 122db3540f8fef3ca0f7cad0706d7440b1da939e Mon Sep 17 00:00:00 2001 From: Prox Date: Wed, 4 Mar 2026 00:19:12 +0200 Subject: [PATCH] feat: add Gitea API client for state commits and PR comments --- src/gitea/client.test.ts | 192 +++++++++++++++++++++++++++++++++++++++ src/gitea/client.ts | 140 ++++++++++++++++++++++++++++ 2 files changed, 332 insertions(+) create mode 100644 src/gitea/client.test.ts create mode 100644 src/gitea/client.ts diff --git a/src/gitea/client.test.ts b/src/gitea/client.test.ts new file mode 100644 index 0000000..2871f0c --- /dev/null +++ b/src/gitea/client.test.ts @@ -0,0 +1,192 @@ +import { assertEquals } from "@std/assert"; +import { type FetchFn, GiteaApiError, GiteaClient } from "./client.ts"; + +function mockFetch( + responses: Map, +): 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"); +}); diff --git a/src/gitea/client.ts b/src/gitea/client.ts new file mode 100644 index 0000000..cade5aa --- /dev/null +++ b/src/gitea/client.ts @@ -0,0 +1,140 @@ +/** Narrowed fetch signature used for dependency injection. */ +export type FetchFn = ( + input: string | URL | Request, + init?: RequestInit, +) => Promise; + +/** 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 ` (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( + method: string, + path: string, + body?: unknown, + ): Promise { + const url = `${this.baseUrl}/api/v1${path}`; + const headers: Record = { + "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 { + 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 { + await this.request( + "POST", + `/repos/${this.repo}/issues/${issueNumber}/comments`, + { body }, + ); + } +}