177 lines
5.3 KiB
TypeScript
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;
|
|
},
|
|
});
|