From 6e4e9c02ffd2634ff1837f37f0a6551339d9e166 Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Tue, 28 Apr 2026 16:49:34 -0700 Subject: [PATCH] feat(project): auto-discover codebases from Gitea instead of hard-coding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds GET /api/projects/[id]/codebases that inspects the project's Gitea repo: - apps/* present → one codebase per subdir (Turborepo) - else → single codebase rooted at the repo root - no repo → empty list with reason="no_repo" Product tab now fetches this list, picks the first as the default selection, and surfaces explicit loading / error / empty states (previously it hung on "Loading…" when apps/web 404'd in single- repo projects). Made-with: Cursor --- .../[projectId]/(home)/product/page.tsx | 134 ++++++++++++++++-- .../projects/[projectId]/codebases/route.ts | 127 +++++++++++++++++ 2 files changed, 246 insertions(+), 15 deletions(-) create mode 100644 app/api/projects/[projectId]/codebases/route.ts diff --git a/app/[workspace]/project/[projectId]/(home)/product/page.tsx b/app/[workspace]/project/[projectId]/(home)/product/page.tsx index 39b1adf7..e4ec4c75 100644 --- a/app/[workspace]/project/[projectId]/(home)/product/page.tsx +++ b/app/[workspace]/project/[projectId]/(home)/product/page.tsx @@ -1,53 +1,157 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useParams } from "next/navigation"; +import { Loader2, AlertCircle } from "lucide-react"; import { SectionScaffold, StatusPanel } from "@/components/project/section-scaffold"; import { GiteaFileTree } from "@/components/project/gitea-file-tree"; /** * Product tab. * - * Each tile is a CODEBASE that lives in this project's monorepo - * (Turborepo `apps/*`). Selecting a tile renders that codebase's - * Gitea file tree in the right column. Phase 2 will discover the - * `apps/*` list from the dev container instead of hard-coding it. + * Each tile is a CODEBASE in this project's repo. The list is + * discovered server-side from Gitea by `/api/projects/[id]/codebases`: + * - Turborepo (`apps/*`) → one tile per app + * - Single-repo project → one tile pointing at the repo root + * - No repo connected → empty-state CTA */ interface Codebase { id: string; label: string; - hint: string; + path: string; + hint?: string; } -const CODEBASES: Codebase[] = [ - { id: "web", label: "web", hint: "The main customer-facing app." }, - { id: "marketing", label: "marketing", hint: "Public landing page + marketing site." }, -]; +interface CodebasesResponse { + codebases: Codebase[]; + reason?: "no_repo" | "empty_repo"; + error?: string; +} export default function ProductTab() { const params = useParams(); const projectId = params.projectId as string; - const [selectedId, setSelectedId] = useState(CODEBASES[0]?.id ?? ""); - const selected = CODEBASES.find(c => c.id === selectedId) ?? CODEBASES[0]; + const [codebases, setCodebases] = useState(null); + const [reason, setReason] = useState(); + const [error, setError] = useState(null); + const [selectedId, setSelectedId] = useState(""); + + useEffect(() => { + let cancelled = false; + setCodebases(null); + setError(null); + setReason(undefined); + + fetch(`/api/projects/${projectId}/codebases`, { credentials: "include" }) + .then(async r => { + const data = (await r.json()) as CodebasesResponse; + if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`); + return data; + }) + .then(data => { + if (cancelled) return; + setCodebases(data.codebases); + setReason(data.reason); + if (data.codebases[0]) setSelectedId(data.codebases[0].id); + }) + .catch(err => { + if (!cancelled) setError(err.message || "Failed to load"); + }); + + return () => { + cancelled = true; + }; + }, [projectId]); + + const selected = codebases?.find(c => c.id === selectedId) ?? codebases?.[0]; + + // ── Loading + if (codebases === null && !error) { + return ( + + + Loading codebases… + + + } + /> + ); + } + + // ── Error + if (error) { + return ( + + + {error} + + + } + /> + ); + } + + // ── Empty (no repo or empty repo) + if (!codebases || codebases.length === 0) { + return ( + + + {reason === "no_repo" + ? "No Gitea repo is connected to this project yet." + : "Repo is empty — push a first commit to see codebases here."} + + + } + /> + ); + } + + // ── Loaded return ( ({ + subAreas={codebases.map(cb => ({ label: cb.label, - hint: cb.hint, + hint: cb.hint ?? `apps/${cb.id}`, onClick: () => setSelectedId(cb.id), active: cb.id === selected?.id, }))} rightPanel={ selected ? ( - + ) : null } /> ); } + +function Centered({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} diff --git a/app/api/projects/[projectId]/codebases/route.ts b/app/api/projects/[projectId]/codebases/route.ts new file mode 100644 index 00000000..8c6b3aeb --- /dev/null +++ b/app/api/projects/[projectId]/codebases/route.ts @@ -0,0 +1,127 @@ +/** + * GET /api/projects/[projectId]/codebases + * + * Returns the list of codebases that make up this project, by + * inspecting the project's Gitea repository: + * + * - If `apps/` exists at the repo root, each subdirectory under + * `apps/` is one codebase (Turborepo convention). + * - Otherwise, the repo root itself is treated as a single + * codebase (typical for non-monorepo projects). + * - If no Gitea repo is connected to this project yet, an empty + * list is returned with a `reason` field for the UI to render. + * + * Response: + * { codebases: [{ id, label, path, hint? }], reason?: string } + */ + +import { NextResponse } from "next/server"; +import { authSession } from "@/lib/auth/session-server"; +import { query } from "@/lib/db-postgres"; + +const GITEA_API_URL = process.env.GITEA_API_URL ?? "https://git.vibnai.com"; +const GITEA_API_TOKEN = process.env.GITEA_API_TOKEN ?? ""; + +interface Codebase { + id: string; + label: string; + path: string; + hint?: string; +} + +interface GiteaItem { + name: string; + path: string; + type: "file" | "dir" | "symlink"; +} + +async function giteaList(repo: string, path: string): Promise { + const encoded = path ? encodeURIComponent(path).replace(/%2F/g, "/") : ""; + const res = await fetch( + `${GITEA_API_URL}/api/v1/repos/${repo}/contents/${encoded}`, + { + headers: { Authorization: `token ${GITEA_API_TOKEN}` }, + next: { revalidate: 30 }, + } + ); + if (res.status === 404) return null; + if (!res.ok) throw new Error(`Gitea ${res.status} listing ${repo}/${path}`); + const data = await res.json(); + return Array.isArray(data) ? (data as GiteaItem[]) : null; +} + +export async function GET( + _req: Request, + { params }: { params: Promise<{ projectId: string }> } +) { + try { + const { projectId } = await params; + const session = await authSession(); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const rows = await query<{ data: Record }>( + `SELECT p.data FROM fs_projects p + JOIN fs_users u ON u.id = p.user_id + WHERE p.id = $1 AND u.data->>'email' = $2 LIMIT 1`, + [projectId, session.user.email] + ); + if (rows.length === 0) { + return NextResponse.json({ error: "Project not found" }, { status: 404 }); + } + + const giteaRepo = rows[0].data?.giteaRepo as string | undefined; + if (!giteaRepo) { + return NextResponse.json({ + codebases: [], + reason: "no_repo", + }); + } + + // Inspect the repo root. If `apps/` is present, list its + // subdirectories. Otherwise treat the root as one codebase. + const root = await giteaList(giteaRepo, ""); + if (!root) { + return NextResponse.json({ + codebases: [], + reason: "empty_repo", + }); + } + + const appsDir = root.find(item => item.type === "dir" && item.name === "apps"); + let codebases: Codebase[] = []; + + if (appsDir) { + const appsChildren = await giteaList(giteaRepo, "apps"); + if (appsChildren) { + codebases = appsChildren + .filter(item => item.type === "dir") + .map(item => ({ + id: item.name, + label: item.name, + path: `apps/${item.name}`, + })); + } + } + + if (codebases.length === 0) { + // Single-repo project: use the repo's tail name as a friendly + // label (e.g. "twenty-crm"), root path is "". + const repoName = giteaRepo.split("/").pop() || "app"; + codebases = [ + { + id: "root", + label: repoName, + path: "", + hint: "Single-codebase project — repository root.", + }, + ]; + } + + return NextResponse.json({ codebases }); + } catch (err) { + console.error("[codebases API]", err); + return NextResponse.json({ error: "Failed to list codebases" }, { status: 500 }); + } +}