273 lines
7.4 KiB
TypeScript
273 lines
7.4 KiB
TypeScript
|
|
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.",
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}),
|
||
|
|
});
|