- 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>
155 lines
4.5 KiB
TypeScript
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");
|
|
}
|