Submatter/apps/web/server/routers/connected-repos.ts

273 lines
7.4 KiB
TypeScript
Raw Normal View History

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.",
};
}
}),
});