Files
vibn-frontend/lib/gitea.ts
Mark Henderson c8dec7c656 feat(mcp): add gitea_* tools so the AI can write code, not just deploy it
Closes the AI's self-reported gap: "I cannot directly commit or push code".

New MCP capabilities (8) — all scoped to the workspace's Gitea org via
requireGiteaOrg + ensureRepoOwnerInOrg:

- gitea.repos.list           — discover existing repos
- gitea.repo.get             — metadata (default branch, clone URL)
- gitea.repo.create          — mint a new private repo with auto-init
- gitea.file.read            — read a file (or list a directory)
- gitea.file.write           — create/update one file in one commit
- gitea.file.delete          — delete a file (auto-resolves sha)
- gitea.branches.list        — list branches with head sha
- gitea.branch.create        — branch off an existing branch

Wired through:
- lib/gitea.ts: giteaReadFile, giteaListContents, giteaListBranches,
  giteaCreateBranch, giteaListOrgRepos, giteaDeleteFile.
- lib/ai/vibn-tools.ts: 8 new Gemini tool declarations (53 total).
- app/api/chat/route.ts: system prompt now teaches the end-to-end
  scaffold-then-deploy recipe so the AI stops deferring to the user.

MCP capability descriptor bumped to version 2.5.0.

Made-with: Cursor
2026-04-28 11:52:16 -07:00

616 lines
18 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;
active?: boolean;
}
/**
* 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> {
const created = await 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,
}),
});
// Gitea's admin-create endpoint returns users with `active=false` by
// default (a quirk of the admin API — UI-created users skip email
// verification but API-created ones don't). Inactive users fail
// permission checks and cannot clone private repos, so we flip the
// flag immediately via a PATCH. Idempotent: a second call is a noop.
try {
await giteaFetch(`/admin/users/${opts.username}`, {
method: 'PATCH',
body: JSON.stringify({
source_id: 0,
login_name: opts.username,
active: true,
}),
});
(created as GiteaUser).active = true;
} catch (err) {
console.warn('[gitea] failed to activate bot user', opts.username, err);
}
return created;
}
/**
* 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',
'write:organization',
],
} = 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;
}
// ──────────────────────────────────────────────────
// Admin: SSH keys on a user (for Coolify deploy-key flow)
// ──────────────────────────────────────────────────
export interface GiteaSshKey {
id: number;
key: string;
title: string;
fingerprint?: string;
read_only?: boolean;
}
/**
* Register an SSH public key under a target user via the admin API.
* The resulting key gives anyone holding the matching private key the
* same repo-read access as the user (bounded by that user's team
* memberships — for bots, usually read/write on one org only).
*/
export async function adminAddUserSshKey(opts: {
username: string;
title: string;
key: string; // OpenSSH-format public key, e.g. "ssh-ed25519 AAAAC3... comment"
readOnly?: boolean;
}): Promise<GiteaSshKey> {
return giteaFetch(`/admin/users/${opts.username}/keys`, {
method: 'POST',
body: JSON.stringify({
title: opts.title,
key: opts.key,
read_only: opts.readOnly ?? false,
}),
});
}
/**
* List SSH keys for a user (admin view).
*/
export async function adminListUserSshKeys(username: string): Promise<GiteaSshKey[]> {
return giteaFetch(`/users/${username}/keys`);
}
/**
* Delete an SSH key by id (owned by a user). Used when rotating keys.
*/
export async function adminDeleteUserSshKey(keyId: number): Promise<void> {
await giteaFetch(`/admin/users/keys/${keyId}`, { method: 'DELETE' });
}
/**
* 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 } : {}) }),
});
}
/**
* Read a file from a Gitea repo. Returns decoded content + sha.
* Throws if path is missing.
*/
export async function giteaReadFile(
owner: string,
repo: string,
path: string,
ref?: string,
): Promise<{ content: string; sha: string; size: number; encoding: string }> {
const qs = ref ? `?ref=${encodeURIComponent(ref)}` : '';
const data = await giteaFetch(`/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}${qs}`);
const d = data as any;
const raw = d.content && d.encoding === 'base64'
? Buffer.from(d.content, 'base64').toString('utf-8')
: (d.content ?? '');
return { content: raw, sha: d.sha, size: d.size ?? 0, encoding: d.encoding ?? 'utf-8' };
}
/** List files/folders at a path inside a Gitea repo. */
export async function giteaListContents(
owner: string,
repo: string,
path = '',
ref?: string,
): Promise<Array<{ name: string; path: string; type: string; size: number; sha: string }>> {
const qs = ref ? `?ref=${encodeURIComponent(ref)}` : '';
const data = await giteaFetch(`/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}${qs}`);
const arr = Array.isArray(data) ? data : [data];
return arr.map((d: any) => ({
name: d.name,
path: d.path,
type: d.type,
size: d.size ?? 0,
sha: d.sha,
}));
}
export interface GiteaBranch {
name: string;
commit: { id: string; message?: string };
protected: boolean;
}
export async function giteaListBranches(owner: string, repo: string): Promise<GiteaBranch[]> {
const data = await giteaFetch(`/repos/${owner}/${repo}/branches`);
return Array.isArray(data) ? (data as GiteaBranch[]) : [];
}
/** Create a new branch from an existing one (defaults to repo's default branch). */
export async function giteaCreateBranch(
owner: string,
repo: string,
newBranch: string,
fromBranch?: string,
): Promise<GiteaBranch> {
return giteaFetch(`/repos/${owner}/${repo}/branches`, {
method: 'POST',
body: JSON.stringify({
new_branch_name: newBranch,
...(fromBranch ? { old_branch_name: fromBranch } : {}),
}),
}) as Promise<GiteaBranch>;
}
/** List repos owned by an org (or user). */
export async function giteaListOrgRepos(orgOrUser: string): Promise<GiteaRepo[]> {
const data = await giteaFetch(`/orgs/${orgOrUser}/repos`).catch(async () => {
// If it's not an org, try the user endpoint.
return giteaFetch(`/users/${orgOrUser}/repos`);
});
return Array.isArray(data) ? (data as GiteaRepo[]) : [];
}
/**
* Delete a single file at path. Requires the file's current sha.
*/
export async function giteaDeleteFile(
owner: string,
repo: string,
path: string,
sha: string,
message: string,
branch = 'main',
): Promise<void> {
await giteaFetch(`/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}`, {
method: 'DELETE',
body: JSON.stringify({ message, sha, branch }),
});
}
export const GITEA_ADMIN_USER_EXPORT = GITEA_ADMIN_USER;