feat: add Gitea API client for state commits and PR comments
This commit is contained in:
parent
05440ea740
commit
122db3540f
192
src/gitea/client.test.ts
Normal file
192
src/gitea/client.test.ts
Normal 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
140
src/gitea/client.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user