Files
vibn-agent-runner/platform/backend/control-plane/src/gitea.ts
mawkone 2c3e7f9dfb feat: add Turborepo per-project monorepo scaffold and project API
- Add Turborepo scaffold templates (apps: product, website, admin, storybook; packages: ui, tokens, types, config)
- Add ProjectRecord and AppRecord types to control plane
- Add Gitea integration service (repo creation, scaffold push, webhooks)
- Add Coolify integration service (project + per-app service provisioning with turbo --filter)
- Add project routes: GET/POST /projects, GET /projects/:id/apps, POST /projects/:id/deploy
- Update chat route to inject project/monorepo context into AI requests
- Add deploy_app and scaffold_app tools to Gemini tool set
- Update deploy executor with monorepo-aware /execute/deploy endpoint
- Add TURBOREPO_MIGRATION_PLAN.md documenting rationale and scope

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-21 15:07:35 -08:00

155 lines
4.5 KiB
TypeScript

/**
* Gitea API integration
*
* Handles repo creation, file scaffolding, and webhook registration
* for user projects. All project repos are created under the user's
* Gitea account and contain the full Turborepo monorepo structure.
*/
import { config } from "./config.js";
import { readdir, readFile } from "node:fs/promises";
import { join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const TEMPLATES_DIR = join(__dirname, "../../../../scripts/templates/turborepo");
type GiteaFile = {
path: string;
content: string;
};
async function giteaFetch(path: string, options: RequestInit = {}): Promise<Response> {
const url = `${config.giteaUrl}/api/v1${path}`;
const res = await fetch(url, {
...options,
headers: {
"Authorization": `token ${config.giteaToken}`,
"Content-Type": "application/json",
...options.headers,
},
});
return res;
}
export async function createRepo(owner: string, repoName: string, description: string): Promise<string> {
const res = await giteaFetch(`/user/repos`, {
method: "POST",
body: JSON.stringify({
name: repoName,
description,
private: false,
auto_init: false,
}),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`Failed to create Gitea repo: ${res.status} ${body}`);
}
const data = await res.json() as { clone_url: string };
return data.clone_url;
}
export async function registerWebhook(owner: string, repoName: string, webhookUrl: string): Promise<void> {
const res = await giteaFetch(`/repos/${owner}/${repoName}/hooks`, {
method: "POST",
body: JSON.stringify({
type: "gitea",
active: true,
events: ["push", "pull_request"],
config: {
url: webhookUrl,
content_type: "json",
},
}),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`Failed to register webhook: ${res.status} ${body}`);
}
}
/**
* Walk the template directory and collect all files with their content,
* replacing {{project-slug}} and {{project-name}} placeholders.
*/
async function collectTemplateFiles(
projectSlug: string,
projectName: string
): Promise<GiteaFile[]> {
const files: GiteaFile[] = [];
async function walk(dir: string) {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
await walk(fullPath);
} else {
const relPath = relative(TEMPLATES_DIR, fullPath);
let content = await readFile(fullPath, "utf-8");
content = content
.replaceAll("{{project-slug}}", projectSlug)
.replaceAll("{{project-name}}", projectName);
files.push({ path: relPath, content });
}
}
}
await walk(TEMPLATES_DIR);
return files;
}
/**
* Push the full Turborepo scaffold to a Gitea repo as an initial commit.
* Uses Gitea's contents API to create each file individually.
*/
export async function scaffoldRepo(
owner: string,
repoName: string,
projectSlug: string,
projectName: string
): Promise<void> {
const files = await collectTemplateFiles(projectSlug, projectName);
for (const file of files) {
const encoded = Buffer.from(file.content).toString("base64");
const res = await giteaFetch(`/repos/${owner}/${repoName}/contents/${file.path}`, {
method: "POST",
body: JSON.stringify({
message: `chore: scaffold ${file.path}`,
content: encoded,
branch: "main",
}),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`Failed to push ${file.path}: ${res.status} ${body}`);
}
}
}
export async function getRepoTree(owner: string, repoName: string, ref = "main"): Promise<string[]> {
const res = await giteaFetch(`/repos/${owner}/${repoName}/git/trees/${ref}?recursive=true`);
if (!res.ok) return [];
const data = await res.json() as { tree: { path: string; type: string }[] };
return data.tree.filter(e => e.type === "blob").map(e => e.path);
}
export async function getFileContent(
owner: string,
repoName: string,
filePath: string,
ref = "main"
): Promise<string | null> {
const res = await giteaFetch(`/repos/${owner}/${repoName}/contents/${filePath}?ref=${ref}`);
if (!res.ok) return null;
const data = await res.json() as { content: string };
return Buffer.from(data.content, "base64").toString("utf-8");
}