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\/)?(?[^/\s]+)\/(?[^/\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> => { 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; }, });