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