Files
zopu-code/packages/backend/convex/projects.ts

177 lines
5.3 KiB
TypeScript

import { ConvexError, v } from "convex/values";
import { z } from "zod";
import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import { action, internalMutation, query } from "./_generated/server";
import { createInitialArtifacts } from "./artifactModel";
import { requireOwnerId } from "./authz";
const gitHubRepositorySchema = z.object({
default_branch: z.string(),
description: z.string().nullable(),
html_url: z.url(),
id: z.number(),
name: z.string(),
owner: z.object({ login: z.string() }),
});
const parseRepository = (value: string): { owner: string; repo: string } => {
const normalized = value.trim().replace(/\.git$/u, "");
const match = normalized.match(
/^(?:https?:\/\/github\.com\/)?(?<owner>[^/\s]+)\/(?<repo>[^/\s]+)$/iu
);
if (!match?.groups?.owner || !match.groups.repo) {
throw new ConvexError("Use a GitHub repository in owner/name format");
}
return { owner: match.groups.owner, repo: match.groups.repo };
};
const readGitHubRepository = (value: unknown) => {
const result = gitHubRepositorySchema.safeParse(value);
if (!result.success) {
throw new ConvexError("GitHub returned incomplete repository metadata");
}
return {
defaultBranch: result.data.default_branch,
...(result.data.description === null
? {}
: { description: result.data.description }),
id: result.data.id,
name: result.data.name,
owner: result.data.owner.login,
url: result.data.html_url,
};
};
export const connectGitHub = action({
args: { repository: v.string() },
handler: async (ctx, args): Promise<Id<"projects">> => {
const ownerId = await requireOwnerId(ctx);
const { owner, repo } = parseRepository(args.repository);
const response = await fetch(
`https://api.github.com/repos/${owner}/${repo}`,
{
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "zopu-code",
"X-GitHub-Api-Version": "2022-11-28",
},
}
);
if (!response.ok) {
if (response.status === 404) {
throw new ConvexError(
"Repository not found. This first flow supports public GitHub repositories."
);
}
throw new ConvexError(`GitHub connection failed with ${response.status}`);
}
const repository = readGitHubRepository(await response.json());
const projectId: Id<"projects"> = await ctx.runMutation(
internal.projects.storeGitHubProject,
{ ownerId, repository }
);
return projectId;
},
});
export const storeGitHubProject = internalMutation({
args: {
ownerId: v.string(),
repository: v.object({
defaultBranch: v.string(),
description: v.optional(v.string()),
id: v.number(),
name: v.string(),
owner: v.string(),
url: v.string(),
}),
},
handler: async (ctx, args) => {
const existing = await ctx.db
.query("projects")
.withIndex("by_owner_and_repository", (q) =>
q
.eq("ownerId", args.ownerId)
.eq("provider", "github")
.eq("repoOwner", args.repository.owner)
.eq("repoName", args.repository.name)
)
.unique();
const timestamp = Date.now();
if (existing) {
await ctx.db.patch("projects", existing._id, {
defaultBranch: args.repository.defaultBranch,
description: args.repository.description,
providerRepoId: args.repository.id,
repoUrl: args.repository.url,
updatedAt: timestamp,
});
return existing._id;
}
const projectId = await ctx.db.insert("projects", {
createdAt: timestamp,
defaultBranch: args.repository.defaultBranch,
description: args.repository.description,
name: args.repository.name,
ownerId: args.ownerId,
provider: "github",
providerRepoId: args.repository.id,
repoName: args.repository.name,
repoOwner: args.repository.owner,
repoUrl: args.repository.url,
updatedAt: timestamp,
});
const artifacts = createInitialArtifacts({
defaultBranch: args.repository.defaultBranch,
description: args.repository.description,
name: args.repository.name,
repoName: args.repository.name,
repoOwner: args.repository.owner,
repoUrl: args.repository.url,
});
await Promise.all(
artifacts.map(({ content, path }) =>
ctx.db.insert("projectArtifacts", {
content,
createdAt: timestamp,
path,
projectId,
revision: 1,
updatedAt: timestamp,
})
)
);
await ctx.db.insert("projectSignals", {
createdAt: timestamp,
data: { provider: "github", repository: args.repository.url },
kind: "project.connected",
projectId,
});
return projectId;
},
});
export const list = query({
args: {},
handler: async (ctx) => {
const ownerId = await requireOwnerId(ctx);
return await ctx.db
.query("projects")
.withIndex("by_owner_and_createdAt", (q) => q.eq("ownerId", ownerId))
.order("desc")
.take(50);
},
});
export const get = query({
args: { projectId: v.id("projects") },
handler: async (ctx, args) => {
const ownerId = await requireOwnerId(ctx);
const project = await ctx.db.get("projects", args.projectId);
return project?.ownerId === ownerId ? project : null;
},
});