Gitea's POST /users/{name}/tokens is explicitly Basic-auth only;
neither the admin token nor Sudo header is accepted. Keep the random
password we generate at createUser time and pass it straight into
createAccessTokenFor as Basic auth.
For bots that already exist from a half-failed previous provision
run, reset their password via PATCH /admin/users/{name} so we can
Basic-auth as them and mint a fresh token.
Made-with: Cursor
446 lines
13 KiB
TypeScript
446 lines
13 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. By default creates under the admin user.
|
|
* Pass `owner` to create under a specific user OR org — when the owner
|
|
* is an org (or any user other than the token holder), Gitea requires
|
|
* the org-scoped endpoint `/orgs/{owner}/repos`.
|
|
*/
|
|
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;
|
|
|
|
const body = JSON.stringify({
|
|
name,
|
|
description,
|
|
private: isPrivate,
|
|
auto_init,
|
|
default_branch: 'main',
|
|
});
|
|
|
|
// Token-owner repos use /user/repos; everything else (orgs, other users)
|
|
// must go through /orgs/{owner}/repos.
|
|
const path = owner === GITEA_ADMIN_USER ? `/user/repos` : `/orgs/${owner}/repos`;
|
|
return giteaFetch(path, { method: 'POST', body });
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// Organizations (per-workspace tenancy)
|
|
// ──────────────────────────────────────────────────
|
|
|
|
export interface GiteaOrg {
|
|
id: number;
|
|
username: string; // org name (Gitea uses "username" for orgs too)
|
|
full_name: string;
|
|
description?: string;
|
|
visibility: 'public' | 'private' | 'limited';
|
|
}
|
|
|
|
/**
|
|
* Create a Gitea organization. Requires the admin token to have
|
|
* permission to create orgs.
|
|
*/
|
|
export async function createOrg(opts: {
|
|
name: string;
|
|
fullName?: string;
|
|
description?: string;
|
|
visibility?: 'public' | 'private' | 'limited';
|
|
}): Promise<GiteaOrg> {
|
|
const { name, fullName = name, description = '', visibility = 'private' } = opts;
|
|
return giteaFetch(`/orgs`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
username: name,
|
|
full_name: fullName,
|
|
description,
|
|
visibility,
|
|
repo_admin_change_team_access: true,
|
|
}),
|
|
});
|
|
}
|
|
|
|
export async function getOrg(name: string): Promise<GiteaOrg | null> {
|
|
try {
|
|
return await giteaFetch(`/orgs/${name}`);
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
if (msg.includes('404')) return null;
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add a Gitea user to an org's "Owners" team (full access to the org).
|
|
* Falls back to the org's default team when "Owners" cannot be located.
|
|
*/
|
|
export async function addOrgOwner(orgName: string, username: string): Promise<void> {
|
|
const teams = (await giteaFetch(`/orgs/${orgName}/teams`)) as Array<{ id: number; name: string }>;
|
|
const owners = teams.find(t => t.name.toLowerCase() === 'owners') ?? teams[0];
|
|
if (!owners) throw new Error(`No teams found for org ${orgName}`);
|
|
await giteaFetch(`/teams/${owners.id}/members/${username}`, { method: 'PUT' });
|
|
}
|
|
|
|
export async function getUser(username: string): Promise<{ id: number; login: string } | null> {
|
|
try {
|
|
return await giteaFetch(`/users/${username}`);
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
if (msg.includes('404')) return null;
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// Admin: create users + mint PATs + team management
|
|
// (used for per-workspace bot provisioning)
|
|
// ──────────────────────────────────────────────────
|
|
|
|
export interface GiteaUser {
|
|
id: number;
|
|
login: string;
|
|
full_name?: string;
|
|
email?: string;
|
|
}
|
|
|
|
/**
|
|
* Create a Gitea user via the admin API. Requires the root admin token
|
|
* to have the site-admin bit. `must_change_password: false` because the
|
|
* bot never logs in interactively — only the PAT is used.
|
|
*/
|
|
export async function createUser(opts: {
|
|
username: string;
|
|
email: string;
|
|
password: string;
|
|
fullName?: string;
|
|
}): Promise<GiteaUser> {
|
|
return giteaFetch(`/admin/users`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
username: opts.username,
|
|
email: opts.email,
|
|
password: opts.password,
|
|
full_name: opts.fullName ?? opts.username,
|
|
must_change_password: false,
|
|
send_notify: false,
|
|
source_id: 0,
|
|
}),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Mint a Personal Access Token for a user.
|
|
*
|
|
* Gitea's `POST /users/{username}/tokens` endpoint is explicitly Basic-auth
|
|
* only: neither an admin token nor the `Sudo` header work. So we require
|
|
* the target user's password (we know it — we just created the user with
|
|
* a random one and can discard it right after).
|
|
*
|
|
* Returns the plaintext token (`sha1` field) — store it encrypted;
|
|
* Gitea never shows it again.
|
|
*/
|
|
export async function createAccessTokenFor(opts: {
|
|
username: string;
|
|
password: string;
|
|
name: string;
|
|
scopes?: string[];
|
|
}): Promise<{ id: number; name: string; sha1: string; token_last_eight: string }> {
|
|
const { username, password, name, scopes = ['write:repository', 'write:issue', 'write:user'] } = opts;
|
|
const basic = Buffer.from(`${username}:${password}`).toString('base64');
|
|
const url = `${GITEA_API_URL}/api/v1/users/${username}/tokens`;
|
|
const res = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Basic ${basic}`,
|
|
},
|
|
body: JSON.stringify({ name, scopes }),
|
|
});
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
throw new Error(`Gitea token-mint error ${res.status} on /users/${username}/tokens: ${text}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
/**
|
|
* Admin: edit an existing user. Used during re-provisioning to reset
|
|
* the bot's password so we can Basic-auth as it and mint a fresh PAT.
|
|
*/
|
|
export async function adminEditUser(opts: {
|
|
username: string;
|
|
password?: string;
|
|
fullName?: string;
|
|
email?: string;
|
|
}): Promise<GiteaUser> {
|
|
const body: Record<string, unknown> = {
|
|
source_id: 0,
|
|
// Editing requires specifying login name explicitly.
|
|
login_name: opts.username,
|
|
};
|
|
if (opts.password) body.password = opts.password;
|
|
if (opts.fullName) body.full_name = opts.fullName;
|
|
if (opts.email) body.email = opts.email;
|
|
return giteaFetch(`/admin/users/${opts.username}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
export interface GiteaTeam {
|
|
id: number;
|
|
name: string;
|
|
permission?: string;
|
|
}
|
|
|
|
export async function listOrgTeams(orgName: string): Promise<GiteaTeam[]> {
|
|
return giteaFetch(`/orgs/${orgName}/teams`);
|
|
}
|
|
|
|
/**
|
|
* Create a new team inside an org with a scoped permission.
|
|
* `permission`: "read" | "write" | "admin" | "owner".
|
|
* Used to give bot users less than full owner access.
|
|
*/
|
|
export async function createOrgTeam(opts: {
|
|
org: string;
|
|
name: string;
|
|
description?: string;
|
|
permission?: 'read' | 'write' | 'admin';
|
|
includesAllRepos?: boolean;
|
|
}): Promise<GiteaTeam> {
|
|
const {
|
|
org,
|
|
name,
|
|
description = '',
|
|
permission = 'write',
|
|
includesAllRepos = true,
|
|
} = opts;
|
|
return giteaFetch(`/orgs/${org}/teams`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
name,
|
|
description,
|
|
permission,
|
|
includes_all_repositories: includesAllRepos,
|
|
can_create_org_repo: true,
|
|
units: [
|
|
'repo.code',
|
|
'repo.issues',
|
|
'repo.pulls',
|
|
'repo.releases',
|
|
'repo.wiki',
|
|
'repo.ext_wiki',
|
|
'repo.ext_issues',
|
|
'repo.projects',
|
|
'repo.actions',
|
|
'repo.packages',
|
|
],
|
|
}),
|
|
});
|
|
}
|
|
|
|
/** Add a user to a team by id. */
|
|
export async function addOrgTeamMember(teamId: number, username: string): Promise<void> {
|
|
await giteaFetch(`/teams/${teamId}/members/${username}`, { method: 'PUT' });
|
|
}
|
|
|
|
/**
|
|
* Ensure a team exists on an org with the requested permission, and
|
|
* that `username` is a member of it. Idempotent.
|
|
*/
|
|
export async function ensureOrgTeamMembership(opts: {
|
|
org: string;
|
|
teamName: string;
|
|
permission?: 'read' | 'write' | 'admin';
|
|
username: string;
|
|
}): Promise<GiteaTeam> {
|
|
const { org, teamName, permission = 'write', username } = opts;
|
|
const teams = await listOrgTeams(org);
|
|
let team = teams.find(t => t.name.toLowerCase() === teamName.toLowerCase());
|
|
if (!team) {
|
|
team = await createOrgTeam({
|
|
org,
|
|
name: teamName,
|
|
description: `Vibn ${teamName} team`,
|
|
permission,
|
|
});
|
|
}
|
|
await addOrgTeamMember(team.id, username);
|
|
return team;
|
|
}
|
|
|
|
/**
|
|
* 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;
|