feat: Gitea auto-provisioning and webhook context sync
- Add lib/gitea.ts: Gitea API client (createRepo, createWebhook, deleteRepo, verifyWebhookSignature) - Add lib/coolify.ts: Coolify API client (projects, databases, applications, deployments) - Update api/projects/create: auto-creates a private Gitea repo and registers a webhook on every new project; stores giteaRepo, giteaRepoUrl, giteaCloneUrl, giteaSshUrl, giteaWebhookId in project data; Gitea errors are non-fatal so project creation still succeeds - Add api/webhooks/gitea: handles push, pull_request, issues events; verifies HMAC signature; updates contextSnapshot on project record - Add api/webhooks/coolify: handles deployment status events; updates contextSnapshot.lastDeployment on project record Requires env vars: GITEA_API_URL, GITEA_API_TOKEN, GITEA_ADMIN_USER, GITEA_WEBHOOK_SECRET, COOLIFY_URL, COOLIFY_API_TOKEN Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
166
lib/gitea.ts
Normal file
166
lib/gitea.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
export const GITEA_ADMIN_USER_EXPORT = GITEA_ADMIN_USER;
|
||||
Reference in New Issue
Block a user