/** * 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 { 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 { 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 { 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 { 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 { return giteaFetch(`/repos/${owner}/${repo}/hooks`); } /** * Delete a webhook. */ export async function deleteWebhook(owner: string, repo: string, hookId: number): Promise { 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 { 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 { 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;