196 lines
5.0 KiB
TypeScript
196 lines
5.0 KiB
TypeScript
/**
|
|
* Gitea API client for Vibn project provisioning.
|
|
*
|
|
* Used server-side only. Credentials come from env vars:
|
|
* GITEA_API_URL — e.g. https://git.vibnai.com
|
|
* GITEA_API_TOKEN — admin token
|
|
* GITEA_ADMIN_USER — default owner for repos (e.g. "mark")
|
|
*/
|
|
|
|
const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com';
|
|
const GITEA_API_TOKEN = process.env.GITEA_API_TOKEN ?? '';
|
|
const GITEA_ADMIN_USER = process.env.GITEA_ADMIN_USER ?? 'mark';
|
|
|
|
export interface GiteaRepo {
|
|
id: number;
|
|
name: string;
|
|
full_name: string;
|
|
html_url: string;
|
|
clone_url: string;
|
|
ssh_url: string;
|
|
private: boolean;
|
|
default_branch: string;
|
|
}
|
|
|
|
export interface GiteaWebhook {
|
|
id: number;
|
|
type: string;
|
|
active: boolean;
|
|
config: {
|
|
url: string;
|
|
content_type: string;
|
|
secret?: string;
|
|
};
|
|
}
|
|
|
|
async function giteaFetch(path: string, options: RequestInit = {}) {
|
|
const url = `${GITEA_API_URL}/api/v1${path}`;
|
|
const res = await fetch(url, {
|
|
...options,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `token ${GITEA_API_TOKEN}`,
|
|
...(options.headers ?? {}),
|
|
},
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
throw new Error(`Gitea API error ${res.status} on ${path}: ${text}`);
|
|
}
|
|
|
|
if (res.status === 204) return null;
|
|
return res.json();
|
|
}
|
|
|
|
/**
|
|
* Create a new repo under the admin user (or a specified owner).
|
|
*/
|
|
export async function createRepo(
|
|
name: string,
|
|
opts: { description?: string; private?: boolean; owner?: string; auto_init?: boolean } = {}
|
|
): Promise<GiteaRepo> {
|
|
const { description = '', private: isPrivate = true, owner = GITEA_ADMIN_USER, auto_init = true } = opts;
|
|
|
|
return giteaFetch(`/user/repos`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
name,
|
|
description,
|
|
private: isPrivate,
|
|
auto_init,
|
|
default_branch: 'main',
|
|
}),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get an existing repo.
|
|
*/
|
|
export async function getRepo(owner: string, repo: string): Promise<GiteaRepo | null> {
|
|
try {
|
|
return await giteaFetch(`/repos/${owner}/${repo}`);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete a repo (used for project cleanup).
|
|
*/
|
|
export async function deleteRepo(owner: string, repo: string): Promise<void> {
|
|
await giteaFetch(`/repos/${owner}/${repo}`, { method: 'DELETE' });
|
|
}
|
|
|
|
/**
|
|
* Register a webhook on a repo that fires on push, PR, and issue events.
|
|
*
|
|
* @param owner Repo owner (user or org)
|
|
* @param repo Repo name
|
|
* @param webhookUrl Target URL — should include projectId as query param
|
|
* @param secret Shared secret for payload signature verification
|
|
*/
|
|
export async function createWebhook(
|
|
owner: string,
|
|
repo: string,
|
|
webhookUrl: string,
|
|
secret: string
|
|
): Promise<GiteaWebhook> {
|
|
return giteaFetch(`/repos/${owner}/${repo}/hooks`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
type: 'gitea',
|
|
active: true,
|
|
events: ['push', 'pull_request', 'issues', 'issue_comment'],
|
|
config: {
|
|
url: webhookUrl,
|
|
content_type: 'json',
|
|
secret,
|
|
},
|
|
}),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* List webhooks on a repo.
|
|
*/
|
|
export async function listWebhooks(owner: string, repo: string): Promise<GiteaWebhook[]> {
|
|
return giteaFetch(`/repos/${owner}/${repo}/hooks`);
|
|
}
|
|
|
|
/**
|
|
* Delete a webhook.
|
|
*/
|
|
export async function deleteWebhook(owner: string, repo: string, hookId: number): Promise<void> {
|
|
await giteaFetch(`/repos/${owner}/${repo}/hooks/${hookId}`, { method: 'DELETE' });
|
|
}
|
|
|
|
/**
|
|
* Verify the X-Gitea-Signature-256 header on an incoming webhook payload.
|
|
* Returns true if the signature matches.
|
|
*/
|
|
export async function verifyWebhookSignature(
|
|
body: string,
|
|
signature: string,
|
|
secret: string
|
|
): Promise<boolean> {
|
|
if (!signature?.startsWith('sha256=')) return false;
|
|
|
|
const encoder = new TextEncoder();
|
|
const key = await crypto.subtle.importKey(
|
|
'raw',
|
|
encoder.encode(secret),
|
|
{ name: 'HMAC', hash: 'SHA-256' },
|
|
false,
|
|
['sign']
|
|
);
|
|
|
|
const sigBytes = await crypto.subtle.sign('HMAC', key, encoder.encode(body));
|
|
const expected = 'sha256=' + Array.from(new Uint8Array(sigBytes))
|
|
.map(b => b.toString(16).padStart(2, '0'))
|
|
.join('');
|
|
|
|
return expected === signature;
|
|
}
|
|
|
|
/**
|
|
* Push a single file to a repo via the Gitea contents API.
|
|
* Creates the file if it doesn't exist; updates it if it does.
|
|
*/
|
|
export async function giteaPushFile(
|
|
owner: string,
|
|
repo: string,
|
|
path: string,
|
|
content: string,
|
|
message: string,
|
|
branch = 'main',
|
|
): Promise<void> {
|
|
const encoded = Buffer.from(content).toString('base64');
|
|
|
|
// Check if file already exists to get its SHA (required for updates)
|
|
let sha: string | undefined;
|
|
try {
|
|
const existing = await giteaFetch(`/repos/${owner}/${repo}/contents/${path}?ref=${branch}`);
|
|
sha = (existing as any)?.sha;
|
|
} catch {
|
|
// File doesn't exist — create it
|
|
}
|
|
|
|
await giteaFetch(`/repos/${owner}/${repo}/contents/${path}`, {
|
|
method: sha ? 'PUT' : 'POST',
|
|
body: JSON.stringify({ message, content: encoded, branch, ...(sha ? { sha } : {}) }),
|
|
});
|
|
}
|
|
|
|
export const GITEA_ADMIN_USER_EXPORT = GITEA_ADMIN_USER;
|