import { TRPCError } from "@trpc/server"; import { and, asc, eq } from "drizzle-orm"; import { z } from "zod"; import { connectedRepos } from "@tasks/database/schema"; import { connectedRepoSchema, forgeTypeSchema, parseGitRemoteUrl, type ConnectedRepo, } from "@tasks/shared/connected-repos"; import { getForgeAdapter } from "@/server/lib/forge/adapters"; import { router, workspaceProcedure } from "@/server/trpc"; import type { WorkspaceProcedureContext } from "@/server/trpc"; type RepoRow = typeof connectedRepos.$inferSelect; function assertWorkspaceAdmin(ctx: WorkspaceProcedureContext): void { if (ctx.workspace.role !== "owner" && ctx.workspace.role !== "admin") { throw new TRPCError({ code: "FORBIDDEN", message: "Only workspace owners and admins can manage connected repos.", }); } } function canManageWorkspace(ctx: WorkspaceProcedureContext): boolean { return ctx.workspace.role === "owner" || ctx.workspace.role === "admin"; } function readToken(envVarName: string | null | undefined): string | undefined { if (!envVarName?.trim()) { return undefined; } return process.env[envVarName]?.trim() || undefined; } function mapRepoRow(row: RepoRow): ConnectedRepo { return connectedRepoSchema.parse({ id: row.id, workspaceId: row.workspaceId, displayName: row.displayName, gitRemoteUrl: row.gitRemoteUrl, forgeType: row.forgeType, apiBaseUrl: row.apiBaseUrl, owner: row.owner, repoName: row.repoName, defaultBranch: row.defaultBranch, tokenEnvVar: row.tokenEnvVar, metadata: row.metadata, createdAt: row.createdAt, updatedAt: row.updatedAt, }); } const connectInputSchema = z.object({ workspace: z.string().min(1), remoteUrl: z.string().min(1), displayName: z.string().min(1).max(200).optional(), tokenEnvVar: z.string().min(1).max(120).nullable().optional(), forgeType: forgeTypeSchema.optional(), }); export const connectedReposRouter = router({ list: workspaceProcedure.query(async ({ ctx }) => { const rows = await ctx.db .select() .from(connectedRepos) .where(eq(connectedRepos.workspaceId, ctx.workspace.id)) .orderBy(asc(connectedRepos.displayName)); return { repos: rows.map(mapRepoRow), canManage: canManageWorkspace(ctx), }; }), parseRemote: workspaceProcedure .input( z.object({ workspace: z.string().min(1), remoteUrl: z.string().min(1), }), ) .mutation(({ ctx, input }) => { assertWorkspaceAdmin(ctx); try { return parseGitRemoteUrl(input.remoteUrl); } catch (error) { throw new TRPCError({ code: "BAD_REQUEST", message: error instanceof Error ? error.message : "Invalid remote URL.", }); } }), connect: workspaceProcedure.input(connectInputSchema).mutation(async ({ ctx, input }) => { assertWorkspaceAdmin(ctx); let parsed; try { parsed = parseGitRemoteUrl(input.remoteUrl); } catch (error) { throw new TRPCError({ code: "BAD_REQUEST", message: error instanceof Error ? error.message : "Invalid remote URL.", }); } const forgeType = input.forgeType ?? parsed.forgeType; const displayName = input.displayName?.trim() || `${parsed.owner}/${parsed.repoName}`; const [row] = await ctx.db .insert(connectedRepos) .values({ workspaceId: ctx.workspace.id, displayName, gitRemoteUrl: parsed.gitRemoteUrl, forgeType, apiBaseUrl: parsed.apiBaseUrl, owner: parsed.owner, repoName: parsed.repoName, tokenEnvVar: input.tokenEnvVar ?? null, updatedAt: new Date(), }) .returning(); if (!row) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to create connected repo.", }); } return mapRepoRow(row); }), update: workspaceProcedure .input( z.object({ workspace: z.string().min(1), repoId: z.string().uuid(), displayName: z.string().min(1).max(200).optional(), tokenEnvVar: z.string().min(1).max(120).nullable().optional(), }), ) .mutation(async ({ ctx, input }) => { assertWorkspaceAdmin(ctx); const [row] = await ctx.db .update(connectedRepos) .set({ ...(input.displayName !== undefined ? { displayName: input.displayName } : {}), ...(input.tokenEnvVar !== undefined ? { tokenEnvVar: input.tokenEnvVar } : {}), updatedAt: new Date(), }) .where( and( eq(connectedRepos.id, input.repoId), eq(connectedRepos.workspaceId, ctx.workspace.id), ), ) .returning(); if (!row) { throw new TRPCError({ code: "NOT_FOUND", message: "Connected repo not found." }); } return mapRepoRow(row); }), disconnect: workspaceProcedure .input( z.object({ workspace: z.string().min(1), repoId: z.string().uuid(), }), ) .mutation(async ({ ctx, input }) => { assertWorkspaceAdmin(ctx); const [row] = await ctx.db .delete(connectedRepos) .where( and( eq(connectedRepos.id, input.repoId), eq(connectedRepos.workspaceId, ctx.workspace.id), ), ) .returning({ id: connectedRepos.id }); if (!row) { throw new TRPCError({ code: "NOT_FOUND", message: "Connected repo not found." }); } return { ok: true as const }; }), testConnection: workspaceProcedure .input( z.object({ workspace: z.string().min(1), repoId: z.string().uuid(), }), ) .mutation(async ({ ctx, input }) => { assertWorkspaceAdmin(ctx); const [row] = await ctx.db .select() .from(connectedRepos) .where( and( eq(connectedRepos.id, input.repoId), eq(connectedRepos.workspaceId, ctx.workspace.id), ), ) .limit(1); if (!row) { throw new TRPCError({ code: "NOT_FOUND", message: "Connected repo not found." }); } const started = Date.now(); const adapter = getForgeAdapter(forgeTypeSchema.parse(row.forgeType)); const token = readToken(row.tokenEnvVar); try { const metadata = await adapter.fetchRepoMetadata({ apiBaseUrl: row.apiBaseUrl, owner: row.owner, repo: row.repoName, token, }); await ctx.db .update(connectedRepos) .set({ defaultBranch: metadata.defaultBranch, metadata: { ...(row.metadata ?? {}), cloneUrl: metadata.cloneUrl, description: metadata.description ?? null, lastTestedAt: new Date().toISOString(), }, updatedAt: new Date(), }) .where( and( eq(connectedRepos.id, row.id), eq(connectedRepos.workspaceId, ctx.workspace.id), ), ); return { ok: true as const, latencyMs: Date.now() - started, defaultBranch: metadata.defaultBranch, cloneUrl: metadata.cloneUrl, }; } catch (error) { return { ok: false as const, latencyMs: Date.now() - started, error: error instanceof Error ? error.message : "Connection test failed.", }; } }), });