- searchGithubRepositories action fetches up to 300 repos via paginated /user/repos, filters by name, upserts results into gitRepositories so createProjectFromRepository works unchanged; token never leaves backend - connectGithub now persists grantedScopesJson from getAccessToken().scopes - listProviderAccounts and list queries return grantedScopesJson - Gear/settings popover on connected GitHub chip shows scopes and triggers linkSocial with expanded scope set [repo, read:org, read:user, user:email] - RepositorySelector merges debounced live search results with synced snapshot - Restored CONVEX_SITE_URL env var (removed in prior commit but still used)
1584 lines
50 KiB
TypeScript
1584 lines
50 KiB
TypeScript
import { env } from "@code/env/convex";
|
|
import {
|
|
CREDENTIAL_FRESHNESS_MS,
|
|
PUTER_GIT_SERVER_URL,
|
|
} from "@code/primitives/git-provider";
|
|
import {
|
|
assertOwnerSafeProvisioningInput,
|
|
idempotencyKeyFor,
|
|
validateProjectInstructions,
|
|
validatePuterOrgName,
|
|
validatePuterUsername,
|
|
validateRepositoryName,
|
|
} from "@code/primitives/git-provisioning";
|
|
import { makeFunctionReference } from "convex/server";
|
|
import { ConvexError, v } from "convex/values";
|
|
import { Effect } from "effect";
|
|
|
|
import type { Doc, Id } from "./_generated/dataModel";
|
|
import {
|
|
action,
|
|
internalAction,
|
|
internalMutation,
|
|
internalQuery,
|
|
mutation,
|
|
query,
|
|
} from "./_generated/server";
|
|
import { authComponent, createAuth } from "./auth";
|
|
import {
|
|
requireCurrentOrganization,
|
|
requireCurrentOrganizationOwner,
|
|
} from "./authz";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Internal function references (codegen not regenerated without a deployment)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/* eslint-disable no-use-before-define -- backend module with many interdependent helpers */
|
|
const persistPuterUserAccountRef = makeFunctionReference<
|
|
"mutation",
|
|
{
|
|
betterAuthEmail: string;
|
|
betterAuthName: string;
|
|
externalUserId: string;
|
|
organizationId: Id<"organizations">;
|
|
puterUsername: string;
|
|
userId: string;
|
|
},
|
|
Id<"gitProviderAccounts">
|
|
>("gitProvisioning:persistPuterUserAccount");
|
|
|
|
const findMigrationRef = makeFunctionReference<
|
|
"query",
|
|
{ idempotencyKey: string },
|
|
Doc<"gitMigrations"> | null
|
|
>("gitProvisioning:findMigration");
|
|
|
|
const createMigrationRef = makeFunctionReference<
|
|
"mutation",
|
|
{
|
|
githubConnectionId: Id<"gitConnections">;
|
|
idempotencyKey: string;
|
|
includeLfs: boolean;
|
|
organizationId: Id<"organizations">;
|
|
puterConnectionId: Id<"gitConnections">;
|
|
sourceIsPrivate: boolean;
|
|
sourceRepositoryUrl: string;
|
|
targetOwner: string;
|
|
targetRepositoryName: string;
|
|
},
|
|
Id<"gitMigrations">
|
|
>("gitProvisioning:createMigration");
|
|
|
|
const updateMigrationStatusRef = makeFunctionReference<
|
|
"mutation",
|
|
{
|
|
failureReason?: string;
|
|
migrationId: Id<"gitMigrations">;
|
|
resultingRepositoryId?: Id<"gitRepositories">;
|
|
status: "queued" | "running" | "succeeded" | "failed" | "cancelled";
|
|
},
|
|
null
|
|
>("gitProvisioning:updateMigrationStatus");
|
|
|
|
const upsertRepositoryRef = makeFunctionReference<
|
|
"mutation",
|
|
{
|
|
cloneUrl: string;
|
|
defaultBranch: string;
|
|
fullName: string;
|
|
gitProviderAccountId: Id<"gitProviderAccounts">;
|
|
lfsCapability: "unknown" | "supported" | "unsupported";
|
|
name: string;
|
|
organizationId: Id<"organizations">;
|
|
owner: string;
|
|
permissionsAdmin: boolean;
|
|
permissionsMaintain: boolean;
|
|
permissionsPull: boolean;
|
|
permissionsPush: boolean;
|
|
permissionsTriage: boolean;
|
|
privacy: "public" | "private";
|
|
provider: "github" | "gitea";
|
|
providerRepositoryId: string;
|
|
serverUrl: string;
|
|
sourceMigrationId?: Id<"gitMigrations">;
|
|
webUrl: string;
|
|
},
|
|
Id<"gitRepositories">
|
|
>("gitProvisioning:upsertRepository");
|
|
|
|
const getConnectionRef = makeFunctionReference<
|
|
"query",
|
|
{ connectionId: Id<"gitConnections"> },
|
|
Doc<"gitConnections"> | null
|
|
>("gitProvisioning:getConnection");
|
|
const findProviderAccountRef = makeFunctionReference<
|
|
"query",
|
|
{ providerAccountId: Id<"gitProviderAccounts"> },
|
|
Doc<"gitProviderAccounts"> | null
|
|
>("gitProvisioning:findProviderAccount");
|
|
const persistPuterOrgRef = makeFunctionReference<
|
|
"mutation",
|
|
{
|
|
displayName?: string;
|
|
externalOrgId: string;
|
|
gitProviderAccountId: Id<"gitProviderAccounts">;
|
|
organizationId: Id<"organizations">;
|
|
puterOrgName: string;
|
|
},
|
|
Id<"gitProviderOrganizations">
|
|
>("gitProvisioning:persistPuterOrg");
|
|
const findProviderOrgByAccountRef = makeFunctionReference<
|
|
"query",
|
|
{
|
|
externalSlug: string;
|
|
gitProviderAccountId: Id<"gitProviderAccounts">;
|
|
},
|
|
{ _id: Id<"gitProviderOrganizations">; externalSlug: string } | null
|
|
>("gitProvisioning:findProviderOrgByAccount");
|
|
|
|
const currentOrgForOwnerRef = makeFunctionReference<
|
|
"query",
|
|
Record<string, never>,
|
|
{ organizationId: Id<"organizations">; userId: string }
|
|
>("gitProvisioning:resolveCurrentOrgForOwner");
|
|
|
|
const updateWebhookStateRef = makeFunctionReference<
|
|
"mutation",
|
|
{
|
|
repositoryId: Id<"gitRepositories">;
|
|
webhookState: "none" | "pending" | "active" | "failed";
|
|
},
|
|
null
|
|
>("gitProvisioning:updateWebhookState");
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Better Auth user info helper
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface BetterAuthUserInfo {
|
|
readonly email: string;
|
|
readonly name: string;
|
|
}
|
|
|
|
const getBetterAuthUser = async (ctx: unknown): Promise<BetterAuthUserInfo> => {
|
|
const { auth, headers } = await authComponent.getAuth(
|
|
createAuth,
|
|
ctx as never
|
|
);
|
|
const session = await auth.api.getSession({ headers });
|
|
if (!session || !session.user) {
|
|
throw new ConvexError("Authentication required");
|
|
}
|
|
return {
|
|
email: session.user.email,
|
|
name: session.user.name,
|
|
};
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Gitea admin API helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const giteaAdminHeaders = (): Record<string, string> => {
|
|
if (!env.PUTER_GIT_ADMIN_TOKEN) {
|
|
throw new ConvexError("PUTER_GIT_ADMIN_TOKEN is not configured");
|
|
}
|
|
return {
|
|
authorization: `token ${env.PUTER_GIT_ADMIN_TOKEN}`,
|
|
"content-type": "application/json",
|
|
};
|
|
};
|
|
|
|
const GITEA_API = `${PUTER_GIT_SERVER_URL}/api/v1`;
|
|
|
|
/**
|
|
* Ensure a Gitea webhook is configured for a Puter repository. The webhook
|
|
* must address the Convex Site HTTP action directly: `SITE_URL` is the web
|
|
* origin and does not proxy this endpoint in production.
|
|
*/
|
|
const ensurePuterWebhook = async (
|
|
owner: string,
|
|
repoName: string
|
|
): Promise<void> => {
|
|
if (!env.GITEA_WEBHOOK_SECRET) {
|
|
throw new ConvexError(
|
|
"GITEA_WEBHOOK_SECRET is not configured; cannot create Puter webhook"
|
|
);
|
|
}
|
|
if (!env.CONVEX_SITE_URL) {
|
|
throw new ConvexError(
|
|
"CONVEX_SITE_URL is not configured; cannot create Puter webhook"
|
|
);
|
|
}
|
|
const webhookUrl = `${env.CONVEX_SITE_URL.replace(/\/+$/u, "")}/api/git/webhooks/puter`;
|
|
// Check if a webhook already exists for this repo.
|
|
const listResponse = await fetch(
|
|
`${GITEA_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/webhooks`,
|
|
{ headers: giteaAdminHeaders() }
|
|
);
|
|
if (!listResponse.ok) {
|
|
throw new ConvexError(
|
|
`Failed to list existing webhooks (${listResponse.status})`
|
|
);
|
|
}
|
|
const webhooks = (await listResponse.json()) as {
|
|
readonly url: string;
|
|
}[];
|
|
const exists = webhooks.some((webhook) => webhook.url === webhookUrl);
|
|
if (exists) {
|
|
return;
|
|
}
|
|
// Create the webhook.
|
|
const createResponse = await fetch(
|
|
`${GITEA_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/webhooks`,
|
|
{
|
|
body: JSON.stringify({
|
|
active: true,
|
|
content_type: "json",
|
|
events: ["push", "repository", "create", "delete", "fork"],
|
|
secret: env.GITEA_WEBHOOK_SECRET,
|
|
type: "gitea",
|
|
url: webhookUrl,
|
|
}),
|
|
headers: giteaAdminHeaders(),
|
|
method: "POST",
|
|
}
|
|
);
|
|
if (!createResponse.ok) {
|
|
const errorBody = await createResponse.text();
|
|
throw new ConvexError(
|
|
`Failed to create Puter webhook (${createResponse.status}): ${errorBody}`
|
|
);
|
|
}
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// createPuterUser
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const createPuterUser = action({
|
|
args: { puterUsername: v.string() },
|
|
handler: async (ctx, args) => {
|
|
const { organizationId, userId } = await resolveOwnerContext(ctx);
|
|
const authUser = await getBetterAuthUser(ctx);
|
|
|
|
// Validate the requested username
|
|
await Effect.runPromise(validatePuterUsername(args.puterUsername));
|
|
|
|
// Provision the user via Gitea admin API
|
|
const tempPassword = generateTempPassword();
|
|
const response = await fetch(`${GITEA_API}/admin/users`, {
|
|
body: JSON.stringify({
|
|
change_passwd: true,
|
|
email: authUser.email,
|
|
full_name: authUser.name,
|
|
login_name: args.puterUsername,
|
|
must_change_password: true,
|
|
password: tempPassword,
|
|
send_notify: true,
|
|
source_id: 0,
|
|
username: args.puterUsername,
|
|
}),
|
|
headers: giteaAdminHeaders(),
|
|
method: "POST",
|
|
});
|
|
|
|
let externalUserId: string;
|
|
let alreadyExisted = false;
|
|
|
|
if (response.status === 422) {
|
|
// User may already exist — verify it matches the authenticated email
|
|
const existingUser = await fetchExistingUserDetails(args.puterUsername);
|
|
if (existingUser && existingUser.email === authUser.email) {
|
|
externalUserId = existingUser.id;
|
|
alreadyExisted = true;
|
|
} else if (existingUser) {
|
|
throw new ConvexError(
|
|
"A different user already owns this Puter username"
|
|
);
|
|
} else {
|
|
throw new ConvexError(
|
|
`Gitea user creation rejected (${response.status})`
|
|
);
|
|
}
|
|
} else if (response.ok) {
|
|
const created = (await response.json()) as { readonly id: number };
|
|
externalUserId = String(created.id);
|
|
} else {
|
|
throw new ConvexError(`Gitea user creation failed (${response.status})`);
|
|
}
|
|
|
|
// Persist the external account as pending-auth
|
|
const providerAccountId = await ctx.runMutation(
|
|
persistPuterUserAccountRef,
|
|
{
|
|
betterAuthEmail: authUser.email,
|
|
betterAuthName: authUser.name,
|
|
externalUserId,
|
|
organizationId,
|
|
puterUsername: args.puterUsername,
|
|
userId,
|
|
}
|
|
);
|
|
|
|
return {
|
|
alreadyExisted,
|
|
externalUserId,
|
|
providerAccountId: String(providerAccountId),
|
|
puterUsername: args.puterUsername,
|
|
};
|
|
},
|
|
});
|
|
|
|
const fetchExistingUserDetails = async (
|
|
username: string
|
|
): Promise<{ readonly email: string; readonly id: string } | null> => {
|
|
const response = await fetch(
|
|
`${GITEA_API}/users/${encodeURIComponent(username)}`,
|
|
{ headers: giteaAdminHeaders() }
|
|
);
|
|
if (!response.ok) {
|
|
return null;
|
|
}
|
|
const user = (await response.json()) as {
|
|
readonly email: string;
|
|
readonly id: number;
|
|
};
|
|
return { email: user.email, id: String(user.id) };
|
|
};
|
|
|
|
const generateTempPassword = (): string => {
|
|
const chars =
|
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
let password = "";
|
|
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
|
for (let i = 0; i < 32; i += 1) {
|
|
password += chars[bytes[i]! % chars.length];
|
|
}
|
|
return password;
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// createPuterOrganization
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const createPuterOrganization = action({
|
|
args: {
|
|
displayName: v.optional(v.string()),
|
|
providerAccountId: v.id("gitProviderAccounts"),
|
|
puterOrgName: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const { organizationId } = await resolveOwnerContext(ctx);
|
|
|
|
await Effect.runPromise(validatePuterOrgName(args.puterOrgName));
|
|
|
|
const account = await ctx.runQuery(findProviderAccountRef, {
|
|
providerAccountId: args.providerAccountId,
|
|
});
|
|
if (!account || account.organizationId !== organizationId) {
|
|
throw new ConvexError("Provider account not found");
|
|
}
|
|
await Effect.runPromise(
|
|
assertOwnerSafeProvisioningInput(
|
|
{ providerAccountId: account.externalAccountId },
|
|
{
|
|
externalAccountId: account.externalAccountId,
|
|
externalEmail: account.externalEmail,
|
|
externalUsername: account.externalUsername,
|
|
provider: account.provider,
|
|
serverUrl: account.serverUrl,
|
|
}
|
|
)
|
|
);
|
|
|
|
const response = await fetch(`${GITEA_API}/orgs`, {
|
|
body: JSON.stringify({
|
|
description: args.displayName ?? args.puterOrgName,
|
|
username: args.puterOrgName,
|
|
visibility: "private",
|
|
}),
|
|
headers: giteaAdminHeaders(),
|
|
method: "POST",
|
|
});
|
|
|
|
let externalOrgId: string;
|
|
let alreadyExisted = false;
|
|
|
|
if (response.status === 422) {
|
|
const existing = await fetch(
|
|
`${GITEA_API}/orgs/${encodeURIComponent(args.puterOrgName)}`,
|
|
{ headers: giteaAdminHeaders() }
|
|
);
|
|
if (!existing.ok) {
|
|
throw new ConvexError(
|
|
`Gitea org creation rejected (${response.status})`
|
|
);
|
|
}
|
|
const org = (await existing.json()) as { readonly id: number };
|
|
externalOrgId = String(org.id);
|
|
alreadyExisted = true;
|
|
} else if (response.ok) {
|
|
const created = (await response.json()) as { readonly id: number };
|
|
externalOrgId = String(created.id);
|
|
} else {
|
|
throw new ConvexError(`Gitea org creation failed (${response.status})`);
|
|
}
|
|
|
|
// Ensure the provider account user is a member of the organization with
|
|
// owner role. When the admin token creates the org, the admin user is
|
|
// the default owner but the provider account user is not automatically
|
|
// added. We must explicitly add them as an owner, otherwise the org
|
|
// mapping is unsafe to use for repository/migration target binding.
|
|
const addMemberResponse = await fetch(
|
|
`${GITEA_API}/orgs/${encodeURIComponent(args.puterOrgName)}/members/${encodeURIComponent(account.externalUsername)}`,
|
|
{
|
|
body: JSON.stringify({ role: "admin" }),
|
|
headers: giteaAdminHeaders(),
|
|
method: "PUT",
|
|
}
|
|
);
|
|
// 204 = added; 409 = already a member. Both are acceptable.
|
|
if (addMemberResponse.status !== 204 && addMemberResponse.status !== 409) {
|
|
throw new ConvexError(
|
|
`Failed to add provider account as org owner (${addMemberResponse.status})`
|
|
);
|
|
}
|
|
|
|
// Verify the provider account user is now a member.
|
|
const verifyMember = await fetch(
|
|
`${GITEA_API}/orgs/${encodeURIComponent(args.puterOrgName)}/members/${encodeURIComponent(account.externalUsername)}`,
|
|
{ headers: giteaAdminHeaders() }
|
|
);
|
|
if (!verifyMember.ok) {
|
|
throw new ConvexError(
|
|
"Could not verify provider account membership after org creation"
|
|
);
|
|
}
|
|
|
|
await ctx.runMutation(persistPuterOrgRef, {
|
|
displayName: args.displayName,
|
|
externalOrgId,
|
|
gitProviderAccountId: args.providerAccountId,
|
|
organizationId,
|
|
puterOrgName: args.puterOrgName,
|
|
});
|
|
|
|
return { alreadyExisted, externalOrgId, puterOrgName: args.puterOrgName };
|
|
},
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// createPuterRepository
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const createPuterRepository = action({
|
|
args: {
|
|
autoInit: v.boolean(),
|
|
defaultBranch: v.string(),
|
|
description: v.optional(v.string()),
|
|
owner: v.string(),
|
|
privacy: v.union(v.literal("public"), v.literal("private")),
|
|
providerAccountId: v.id("gitProviderAccounts"),
|
|
repositoryName: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const { organizationId } = await resolveOwnerContext(ctx);
|
|
|
|
await Effect.runPromise(validateRepositoryName(args.repositoryName));
|
|
|
|
const account = await ctx.runQuery(findProviderAccountRef, {
|
|
providerAccountId: args.providerAccountId,
|
|
});
|
|
if (!account || account.organizationId !== organizationId) {
|
|
throw new ConvexError("Provider account not found");
|
|
}
|
|
await Effect.runPromise(
|
|
assertOwnerSafeProvisioningInput(
|
|
{ providerAccountId: account.externalAccountId },
|
|
{
|
|
externalAccountId: account.externalAccountId,
|
|
externalEmail: account.externalEmail,
|
|
externalUsername: account.externalUsername,
|
|
provider: account.provider,
|
|
serverUrl: account.serverUrl,
|
|
}
|
|
)
|
|
);
|
|
|
|
// Bind the owner to the authenticated account or a persisted org mapping.
|
|
// The caller-supplied owner must match either the provider account's
|
|
// external username (personal repo) or a gitProviderOrganizations record
|
|
// owned by the same provider account (org repo). Arbitrary Puter users
|
|
// or orgs are rejected.
|
|
const isOrgOwner = args.owner !== account.externalUsername;
|
|
let validatedOrgId: string | undefined;
|
|
if (isOrgOwner) {
|
|
const orgMapping = await ctx.runQuery(findProviderOrgByAccountRef, {
|
|
externalSlug: args.owner,
|
|
gitProviderAccountId: args.providerAccountId,
|
|
});
|
|
if (!orgMapping) {
|
|
throw new ConvexError(
|
|
"Repository owner is not the authenticated Puter account or a mapped organization"
|
|
);
|
|
}
|
|
validatedOrgId = orgMapping.externalSlug;
|
|
}
|
|
const validatedOwner = isOrgOwner
|
|
? validatedOrgId!
|
|
: account.externalUsername;
|
|
|
|
const createEndpoint = isOrgOwner
|
|
? `${GITEA_API}/orgs/${encodeURIComponent(validatedOwner)}/repos`
|
|
: `${GITEA_API}/admin/users/${encodeURIComponent(validatedOwner)}/repos`;
|
|
|
|
const response = await fetch(createEndpoint, {
|
|
body: JSON.stringify({
|
|
auto_init: args.autoInit,
|
|
default_branch: args.defaultBranch,
|
|
description: args.description,
|
|
name: args.repositoryName,
|
|
private: args.privacy === "private",
|
|
}),
|
|
headers: giteaAdminHeaders(),
|
|
method: "POST",
|
|
});
|
|
|
|
let repoData: {
|
|
readonly clone_url: string;
|
|
readonly id: number;
|
|
readonly full_name: string;
|
|
readonly html_url: string;
|
|
readonly name: string;
|
|
readonly owner: { readonly login: string };
|
|
};
|
|
let alreadyExisted = false;
|
|
|
|
if (response.status === 409) {
|
|
const existing = await fetch(
|
|
`${GITEA_API}/repos/${encodeURIComponent(validatedOwner)}/${encodeURIComponent(args.repositoryName)}`,
|
|
{ headers: giteaAdminHeaders() }
|
|
);
|
|
if (existing.ok) {
|
|
repoData = (await existing.json()) as typeof repoData;
|
|
alreadyExisted = true;
|
|
} else {
|
|
throw new ConvexError(
|
|
`Gitea repo creation conflict (${response.status})`
|
|
);
|
|
}
|
|
} else if (response.ok) {
|
|
repoData = (await response.json()) as typeof repoData;
|
|
} else {
|
|
throw new ConvexError(`Gitea repo creation failed (${response.status})`);
|
|
}
|
|
|
|
const repositoryId = await ctx.runMutation(upsertRepositoryRef, {
|
|
cloneUrl: repoData.clone_url,
|
|
defaultBranch: args.defaultBranch,
|
|
fullName: repoData.full_name,
|
|
gitProviderAccountId: args.providerAccountId,
|
|
lfsCapability: "unknown",
|
|
name: repoData.name,
|
|
organizationId,
|
|
owner: repoData.owner.login,
|
|
permissionsAdmin: true,
|
|
permissionsMaintain: true,
|
|
permissionsPull: true,
|
|
permissionsPush: true,
|
|
permissionsTriage: true,
|
|
privacy: args.privacy,
|
|
provider: "gitea",
|
|
providerRepositoryId: String(repoData.id),
|
|
serverUrl: PUTER_GIT_SERVER_URL,
|
|
webUrl: repoData.html_url,
|
|
});
|
|
|
|
// Ensure a webhook is configured for the new repository.
|
|
try {
|
|
await ensurePuterWebhook(repoData.owner.login, repoData.name);
|
|
await ctx.runMutation(updateWebhookStateRef, {
|
|
repositoryId,
|
|
webhookState: "active",
|
|
});
|
|
} catch {
|
|
await ctx.runMutation(updateWebhookStateRef, {
|
|
repositoryId,
|
|
webhookState: "failed",
|
|
});
|
|
throw new ConvexError("Repository created but webhook setup failed");
|
|
}
|
|
|
|
return {
|
|
alreadyExisted,
|
|
cloneUrl: repoData.clone_url,
|
|
externalRepositoryId: String(repoData.id),
|
|
fullName: repoData.full_name,
|
|
name: repoData.name,
|
|
owner: repoData.owner.login,
|
|
repositoryId: String(repositoryId),
|
|
webUrl: repoData.html_url,
|
|
};
|
|
},
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// startGithubMigration
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const startGithubMigration = action({
|
|
args: {
|
|
githubConnectionId: v.id("gitConnections"),
|
|
includeLfs: v.boolean(),
|
|
puterConnectionId: v.id("gitConnections"),
|
|
sourceIsPrivate: v.boolean(),
|
|
sourceRepositoryUrl: v.string(),
|
|
targetOwner: v.string(),
|
|
targetRepositoryName: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const { organizationId } = await resolveOwnerContext(ctx);
|
|
|
|
// Verify connections belong to the org
|
|
const githubConn = await ctx.runQuery(getConnectionRef, {
|
|
connectionId: args.githubConnectionId,
|
|
});
|
|
const puterConn = await ctx.runQuery(getConnectionRef, {
|
|
connectionId: args.puterConnectionId,
|
|
});
|
|
if (
|
|
!githubConn ||
|
|
githubConn.organizationId !== organizationId ||
|
|
githubConn.provider !== "github"
|
|
) {
|
|
throw new ConvexError("GitHub connection not found");
|
|
}
|
|
if (
|
|
!puterConn ||
|
|
puterConn.organizationId !== organizationId ||
|
|
puterConn.provider !== "gitea"
|
|
) {
|
|
throw new ConvexError("Puter connection not found");
|
|
}
|
|
// Bind targetOwner to the Puter connection's provider account username
|
|
// or a persisted org mapping belonging to that account.
|
|
const puterAccount = await ctx.runQuery(findProviderAccountRef, {
|
|
providerAccountId: puterConn.gitProviderAccountId!,
|
|
});
|
|
if (!puterAccount || puterAccount.organizationId !== organizationId) {
|
|
throw new ConvexError("Puter provider account not found");
|
|
}
|
|
if (args.targetOwner !== puterAccount.externalUsername) {
|
|
const orgMapping = await ctx.runQuery(findProviderOrgByAccountRef, {
|
|
externalSlug: args.targetOwner,
|
|
gitProviderAccountId: puterConn.gitProviderAccountId!,
|
|
});
|
|
if (!orgMapping) {
|
|
throw new ConvexError(
|
|
"Migration target owner is not the authenticated Puter account or a mapped organization"
|
|
);
|
|
}
|
|
}
|
|
|
|
const key = idempotencyKeyFor({
|
|
_tag: "MigrateGitHubRepository",
|
|
githubConnectionId: String(args.githubConnectionId),
|
|
includeLfs: args.includeLfs,
|
|
organizationId: String(organizationId),
|
|
puterConnectionId: String(args.puterConnectionId),
|
|
sourceIsPrivate: args.sourceIsPrivate,
|
|
sourceRepositoryUrl: args.sourceRepositoryUrl,
|
|
targetOwner: args.targetOwner,
|
|
targetRepositoryName: args.targetRepositoryName,
|
|
});
|
|
|
|
// Check for existing migration (idempotency)
|
|
const existing = await ctx.runQuery(findMigrationRef, {
|
|
idempotencyKey: key,
|
|
});
|
|
if (existing) {
|
|
return { migrationId: String(existing._id), status: existing.status };
|
|
}
|
|
|
|
const migrationId = await ctx.runMutation(createMigrationRef, {
|
|
githubConnectionId: args.githubConnectionId,
|
|
idempotencyKey: key,
|
|
includeLfs: args.includeLfs,
|
|
organizationId,
|
|
puterConnectionId: args.puterConnectionId,
|
|
sourceIsPrivate: args.sourceIsPrivate,
|
|
sourceRepositoryUrl: args.sourceRepositoryUrl,
|
|
targetOwner: args.targetOwner,
|
|
targetRepositoryName: args.targetRepositoryName,
|
|
});
|
|
|
|
// Schedule the migration as a durable action so it survives worker restarts.
|
|
await ctx.scheduler.runAfter(0, runMigrationRef, {
|
|
githubConnectionId: args.githubConnectionId,
|
|
includeLfs: args.includeLfs,
|
|
migrationId,
|
|
puterConnectionId: args.puterConnectionId,
|
|
sourceIsPrivate: args.sourceIsPrivate,
|
|
sourceRepositoryUrl: args.sourceRepositoryUrl,
|
|
targetOwner: args.targetOwner,
|
|
targetRepositoryName: args.targetRepositoryName,
|
|
});
|
|
|
|
return { migrationId: String(migrationId), status: "queued" };
|
|
},
|
|
});
|
|
|
|
const getMigrationInternalRef = makeFunctionReference<
|
|
"query",
|
|
{ migrationId: Id<"gitMigrations"> },
|
|
Doc<"gitMigrations"> | null
|
|
>("gitProvisioning:getMigrationInternal");
|
|
|
|
const runMigrationRef = makeFunctionReference<
|
|
"action",
|
|
{
|
|
githubConnectionId: Id<"gitConnections">;
|
|
includeLfs: boolean;
|
|
migrationId: Id<"gitMigrations">;
|
|
puterConnectionId: Id<"gitConnections">;
|
|
sourceIsPrivate: boolean;
|
|
sourceRepositoryUrl: string;
|
|
targetOwner: string;
|
|
targetRepositoryName: string;
|
|
},
|
|
null
|
|
>("gitProvisioning:runMigration");
|
|
|
|
export const runMigration = internalAction({
|
|
args: {
|
|
githubConnectionId: v.id("gitConnections"),
|
|
includeLfs: v.boolean(),
|
|
migrationId: v.id("gitMigrations"),
|
|
puterConnectionId: v.id("gitConnections"),
|
|
sourceIsPrivate: v.boolean(),
|
|
sourceRepositoryUrl: v.string(),
|
|
targetOwner: v.string(),
|
|
targetRepositoryName: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
await ctx.runMutation(updateMigrationStatusRef, {
|
|
migrationId: args.migrationId,
|
|
status: "running",
|
|
});
|
|
|
|
try {
|
|
const githubConn = await ctx.runQuery(getConnectionRef, {
|
|
connectionId: args.githubConnectionId,
|
|
});
|
|
const puterConn = await ctx.runQuery(getConnectionRef, {
|
|
connectionId: args.puterConnectionId,
|
|
});
|
|
if (!githubConn || !puterConn) {
|
|
throw new ConvexError("Connection not found during migration");
|
|
}
|
|
// Verify both connections belong to the migration's organization.
|
|
const migration = await ctx.runQuery(getMigrationInternalRef, {
|
|
migrationId: args.migrationId,
|
|
});
|
|
if (!migration) {
|
|
throw new ConvexError("Migration record not found");
|
|
}
|
|
if (
|
|
githubConn.organizationId !== migration.organizationId ||
|
|
puterConn.organizationId !== migration.organizationId
|
|
) {
|
|
throw new ConvexError(
|
|
"Connection does not belong to this organization"
|
|
);
|
|
}
|
|
if (githubConn.provider !== "github" || puterConn.provider !== "gitea") {
|
|
throw new ConvexError("Connection providers do not match migration");
|
|
}
|
|
|
|
let githubToken = "";
|
|
if (args.sourceIsPrivate) {
|
|
const { decryptCredential } = await import("./gitConnections");
|
|
githubToken = await decryptCredential(
|
|
githubConn.credentialCiphertext,
|
|
githubConn.credentialIv
|
|
);
|
|
}
|
|
|
|
const migrateResponse = await fetch(`${GITEA_API}/repos/migrate`, {
|
|
body: JSON.stringify({
|
|
...(githubToken ? { auth_token: githubToken } : {}),
|
|
clone_addr: args.sourceRepositoryUrl,
|
|
description: `Migrated from ${args.sourceRepositoryUrl}`,
|
|
issues: false,
|
|
labels: false,
|
|
lfs: args.includeLfs,
|
|
milestones: false,
|
|
mirror: false,
|
|
private: true,
|
|
pull_requests: false,
|
|
releases: false,
|
|
repo_name: args.targetRepositoryName,
|
|
repo_owner: args.targetOwner,
|
|
service: "github",
|
|
wiki: false,
|
|
}),
|
|
headers: giteaAdminHeaders(),
|
|
method: "POST",
|
|
});
|
|
|
|
if (!migrateResponse.ok) {
|
|
const errorBody = await migrateResponse.text();
|
|
throw new ConvexError(
|
|
`Gitea migration failed (${migrateResponse.status}): ${errorBody}`
|
|
);
|
|
}
|
|
|
|
const result = (await migrateResponse.json()) as {
|
|
readonly clone_url: string;
|
|
readonly default_branch: string;
|
|
readonly full_name: string;
|
|
readonly html_url: string;
|
|
readonly id: number;
|
|
readonly name: string;
|
|
readonly owner: { readonly login: string };
|
|
readonly private: boolean;
|
|
};
|
|
|
|
const repoId = await ctx.runMutation(upsertRepositoryRef, {
|
|
cloneUrl: result.clone_url,
|
|
defaultBranch: result.default_branch,
|
|
fullName: result.full_name,
|
|
gitProviderAccountId: (puterConn.gitProviderAccountId ??
|
|
"") as Id<"gitProviderAccounts">,
|
|
lfsCapability: args.includeLfs ? "supported" : "unknown",
|
|
name: result.name,
|
|
organizationId: puterConn.organizationId,
|
|
owner: result.owner.login,
|
|
permissionsAdmin: true,
|
|
permissionsMaintain: true,
|
|
permissionsPull: true,
|
|
permissionsPush: true,
|
|
permissionsTriage: true,
|
|
privacy: result.private ? "private" : "public",
|
|
provider: "gitea",
|
|
providerRepositoryId: String(result.id),
|
|
serverUrl: PUTER_GIT_SERVER_URL,
|
|
sourceMigrationId: args.migrationId,
|
|
webUrl: result.html_url,
|
|
});
|
|
|
|
// Ensure a webhook is configured for the migrated repository.
|
|
try {
|
|
await ensurePuterWebhook(result.owner.login, result.name);
|
|
await ctx.runMutation(updateWebhookStateRef, {
|
|
repositoryId: repoId,
|
|
webhookState: "active",
|
|
});
|
|
} catch {
|
|
await ctx.runMutation(updateWebhookStateRef, {
|
|
repositoryId: repoId,
|
|
webhookState: "failed",
|
|
});
|
|
throw new ConvexError("Migration succeeded but webhook setup failed");
|
|
}
|
|
|
|
await ctx.runMutation(updateMigrationStatusRef, {
|
|
migrationId: args.migrationId,
|
|
resultingRepositoryId: repoId,
|
|
status: "succeeded",
|
|
});
|
|
} catch (error) {
|
|
const failureReason =
|
|
error instanceof Error ? error.message : "Migration failed";
|
|
await ctx.runMutation(updateMigrationStatusRef, {
|
|
failureReason,
|
|
migrationId: args.migrationId,
|
|
status: "failed",
|
|
});
|
|
throw error;
|
|
}
|
|
return null;
|
|
},
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// getMigration
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const getMigrationInternal = internalQuery({
|
|
args: { migrationId: v.id("gitMigrations") },
|
|
handler: async (ctx, args) => await ctx.db.get(args.migrationId),
|
|
});
|
|
|
|
export const getMigration = query({
|
|
args: { migrationId: v.id("gitMigrations") },
|
|
handler: async (ctx, args) => {
|
|
const { organizationId } = await requireCurrentOrganization(ctx);
|
|
const migration = await ctx.db.get(args.migrationId);
|
|
if (!migration || migration.organizationId !== organizationId) {
|
|
throw new ConvexError("Migration not found");
|
|
}
|
|
return migration;
|
|
},
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Migration execution
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Internal mutations and queries
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const persistPuterUserAccount = internalMutation({
|
|
args: {
|
|
betterAuthEmail: v.string(),
|
|
betterAuthName: v.string(),
|
|
externalUserId: v.string(),
|
|
organizationId: v.id("organizations"),
|
|
puterUsername: v.string(),
|
|
userId: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const existing = await ctx.db
|
|
.query("gitProviderAccounts")
|
|
.withIndex("by_userId_and_provider_and_serverUrl", (q) =>
|
|
q
|
|
.eq("userId", args.userId)
|
|
.eq("provider", "gitea")
|
|
.eq("serverUrl", PUTER_GIT_SERVER_URL)
|
|
)
|
|
.unique();
|
|
const timestamp = Date.now();
|
|
if (existing) {
|
|
await ctx.db.patch(existing._id, {
|
|
externalAccountId: args.externalUserId,
|
|
externalEmail: args.betterAuthEmail,
|
|
externalUsername: args.puterUsername,
|
|
status: "pending-auth",
|
|
updatedAt: timestamp,
|
|
});
|
|
return existing._id;
|
|
}
|
|
return await ctx.db.insert("gitProviderAccounts", {
|
|
createdAt: timestamp,
|
|
externalAccountId: args.externalUserId,
|
|
externalEmail: args.betterAuthEmail,
|
|
externalUsername: args.puterUsername,
|
|
organizationId: args.organizationId,
|
|
provider: "gitea",
|
|
serverUrl: PUTER_GIT_SERVER_URL,
|
|
status: "pending-auth",
|
|
updatedAt: timestamp,
|
|
userId: args.userId,
|
|
});
|
|
},
|
|
});
|
|
|
|
export const persistPuterOrg = internalMutation({
|
|
args: {
|
|
displayName: v.optional(v.string()),
|
|
externalOrgId: v.string(),
|
|
gitProviderAccountId: v.id("gitProviderAccounts"),
|
|
organizationId: v.id("organizations"),
|
|
puterOrgName: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const existing = await ctx.db
|
|
.query("gitProviderOrganizations")
|
|
.withIndex("by_gitProviderAccountId", (q) =>
|
|
q.eq("gitProviderAccountId", args.gitProviderAccountId)
|
|
)
|
|
.filter((q) => q.eq(q.field("externalSlug"), args.puterOrgName))
|
|
.unique();
|
|
const timestamp = Date.now();
|
|
if (existing) {
|
|
await ctx.db.patch(existing._id, {
|
|
displayName: args.displayName,
|
|
updatedAt: timestamp,
|
|
verificationState: "verified",
|
|
});
|
|
return existing._id;
|
|
}
|
|
return await ctx.db.insert("gitProviderOrganizations", {
|
|
createdAt: timestamp,
|
|
displayName: args.displayName,
|
|
externalId: args.externalOrgId,
|
|
externalSlug: args.puterOrgName,
|
|
gitProviderAccountId: args.gitProviderAccountId,
|
|
organizationId: args.organizationId,
|
|
serverUrl: PUTER_GIT_SERVER_URL,
|
|
updatedAt: timestamp,
|
|
verificationState: "verified",
|
|
visibility: "private",
|
|
});
|
|
},
|
|
});
|
|
|
|
export const findProviderOrgByAccount = internalQuery({
|
|
args: {
|
|
externalSlug: v.string(),
|
|
gitProviderAccountId: v.id("gitProviderAccounts"),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const rows = await ctx.db
|
|
.query("gitProviderOrganizations")
|
|
.withIndex("by_gitProviderAccountId", (q) =>
|
|
q.eq("gitProviderAccountId", args.gitProviderAccountId)
|
|
)
|
|
.collect();
|
|
const match = rows.find((r) => r.externalSlug === args.externalSlug);
|
|
return match ? { _id: match._id, externalSlug: match.externalSlug } : null;
|
|
},
|
|
});
|
|
|
|
export const findProviderAccount = internalQuery({
|
|
args: { providerAccountId: v.id("gitProviderAccounts") },
|
|
handler: async (ctx, args) => await ctx.db.get(args.providerAccountId),
|
|
});
|
|
|
|
export const getConnection = internalQuery({
|
|
args: { connectionId: v.id("gitConnections") },
|
|
handler: async (ctx, args) => await ctx.db.get(args.connectionId),
|
|
});
|
|
|
|
export const getRepository = internalQuery({
|
|
args: { repositoryId: v.id("gitRepositories") },
|
|
handler: async (ctx, args) => await ctx.db.get(args.repositoryId),
|
|
});
|
|
|
|
export const findMigration = internalQuery({
|
|
args: { idempotencyKey: v.string() },
|
|
handler: async (ctx, args) =>
|
|
await ctx.db
|
|
.query("gitMigrations")
|
|
.withIndex("by_idempotencyKey", (q) =>
|
|
q.eq("idempotencyKey", args.idempotencyKey)
|
|
)
|
|
.unique(),
|
|
});
|
|
|
|
export const createMigration = internalMutation({
|
|
args: {
|
|
githubConnectionId: v.id("gitConnections"),
|
|
idempotencyKey: v.string(),
|
|
includeLfs: v.boolean(),
|
|
organizationId: v.id("organizations"),
|
|
puterConnectionId: v.id("gitConnections"),
|
|
sourceIsPrivate: v.boolean(),
|
|
sourceRepositoryUrl: v.string(),
|
|
targetOwner: v.string(),
|
|
targetRepositoryName: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const timestamp = Date.now();
|
|
return await ctx.db.insert("gitMigrations", {
|
|
createdAt: timestamp,
|
|
githubConnectionId: args.githubConnectionId,
|
|
idempotencyKey: args.idempotencyKey,
|
|
includeLfs: args.includeLfs,
|
|
organizationId: args.organizationId,
|
|
puterConnectionId: args.puterConnectionId,
|
|
sourceIsPrivate: args.sourceIsPrivate,
|
|
sourceRepositoryUrl: args.sourceRepositoryUrl,
|
|
status: "queued",
|
|
targetOwner: args.targetOwner,
|
|
targetRepositoryName: args.targetRepositoryName,
|
|
updatedAt: timestamp,
|
|
});
|
|
},
|
|
});
|
|
|
|
export const updateMigrationStatus = internalMutation({
|
|
args: {
|
|
failureReason: v.optional(v.string()),
|
|
migrationId: v.id("gitMigrations"),
|
|
resultingRepositoryId: v.optional(v.id("gitRepositories")),
|
|
status: v.union(
|
|
v.literal("queued"),
|
|
v.literal("running"),
|
|
v.literal("succeeded"),
|
|
v.literal("failed"),
|
|
v.literal("cancelled")
|
|
),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
await ctx.db.patch(args.migrationId, {
|
|
...(args.failureReason ? { failureReason: args.failureReason } : {}),
|
|
...(args.resultingRepositoryId
|
|
? { resultingRepositoryId: args.resultingRepositoryId }
|
|
: {}),
|
|
status: args.status,
|
|
updatedAt: Date.now(),
|
|
});
|
|
return null;
|
|
},
|
|
});
|
|
|
|
export const upsertRepository = internalMutation({
|
|
args: {
|
|
cloneUrl: v.string(),
|
|
defaultBranch: v.string(),
|
|
fullName: v.string(),
|
|
gitProviderAccountId: v.id("gitProviderAccounts"),
|
|
lfsCapability: v.union(
|
|
v.literal("unknown"),
|
|
v.literal("supported"),
|
|
v.literal("unsupported")
|
|
),
|
|
name: v.string(),
|
|
organizationId: v.id("organizations"),
|
|
owner: v.string(),
|
|
permissionsAdmin: v.boolean(),
|
|
permissionsMaintain: v.boolean(),
|
|
permissionsPull: v.boolean(),
|
|
permissionsPush: v.boolean(),
|
|
permissionsTriage: v.boolean(),
|
|
privacy: v.union(v.literal("public"), v.literal("private")),
|
|
provider: v.union(v.literal("github"), v.literal("gitea")),
|
|
providerRepositoryId: v.string(),
|
|
serverUrl: v.string(),
|
|
sourceMigrationId: v.optional(v.id("gitMigrations")),
|
|
webUrl: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const existing = await ctx.db
|
|
.query("gitRepositories")
|
|
.withIndex("by_provider_and_serverUrl_and_externalRepositoryId", (q) =>
|
|
q
|
|
.eq("provider", args.provider)
|
|
.eq("serverUrl", args.serverUrl)
|
|
.eq("providerRepositoryId", args.providerRepositoryId)
|
|
)
|
|
.unique();
|
|
const timestamp = Date.now();
|
|
if (existing) {
|
|
await ctx.db.patch(existing._id, {
|
|
cloneUrl: args.cloneUrl,
|
|
defaultBranch: args.defaultBranch,
|
|
fullName: args.fullName,
|
|
lfsCapability: args.lfsCapability,
|
|
name: args.name,
|
|
owner: args.owner,
|
|
permissionsAdmin: args.permissionsAdmin,
|
|
permissionsMaintain: args.permissionsMaintain,
|
|
permissionsPull: args.permissionsPull,
|
|
permissionsPush: args.permissionsPush,
|
|
permissionsTriage: args.permissionsTriage,
|
|
privacy: args.privacy,
|
|
...(args.sourceMigrationId
|
|
? { sourceMigrationId: args.sourceMigrationId }
|
|
: {}),
|
|
updatedAt: timestamp,
|
|
webUrl: args.webUrl,
|
|
});
|
|
return existing._id;
|
|
}
|
|
return await ctx.db.insert("gitRepositories", {
|
|
cloneUrl: args.cloneUrl,
|
|
createdAt: timestamp,
|
|
defaultBranch: args.defaultBranch,
|
|
fullName: args.fullName,
|
|
gitProviderAccountId: args.gitProviderAccountId,
|
|
lfsCapability: args.lfsCapability,
|
|
name: args.name,
|
|
organizationId: args.organizationId,
|
|
owner: args.owner,
|
|
permissionsAdmin: args.permissionsAdmin,
|
|
permissionsMaintain: args.permissionsMaintain,
|
|
permissionsPull: args.permissionsPull,
|
|
permissionsPush: args.permissionsPush,
|
|
permissionsTriage: args.permissionsTriage,
|
|
privacy: args.privacy,
|
|
provider: args.provider,
|
|
providerRepositoryId: args.providerRepositoryId,
|
|
serverUrl: args.serverUrl,
|
|
...(args.sourceMigrationId
|
|
? { sourceMigrationId: args.sourceMigrationId }
|
|
: {}),
|
|
updatedAt: timestamp,
|
|
webUrl: args.webUrl,
|
|
webhookState: "none",
|
|
});
|
|
},
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Project instructions update
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const updateProjectInstructions = internalMutation({
|
|
args: {
|
|
instructions: v.string(),
|
|
projectId: v.id("projects"),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
await Effect.runPromise(validateProjectInstructions(args.instructions));
|
|
await ctx.db.patch(args.projectId, {
|
|
instructions: args.instructions,
|
|
updatedAt: Date.now(),
|
|
});
|
|
},
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const resolveOwnerContext = async (
|
|
ctx: unknown
|
|
): Promise<{
|
|
organizationId: Id<"organizations">;
|
|
userId: string;
|
|
}> => {
|
|
const identity = await (
|
|
ctx as {
|
|
auth: {
|
|
getUserIdentity: () => Promise<{
|
|
readonly tokenIdentifier: string;
|
|
} | null>;
|
|
};
|
|
}
|
|
).auth.getUserIdentity();
|
|
if (!identity) {
|
|
throw new ConvexError("Authentication required");
|
|
}
|
|
const org = await (
|
|
ctx as {
|
|
runQuery: (
|
|
ref: unknown,
|
|
args: Record<string, unknown>
|
|
) => Promise<{ organizationId: Id<"organizations">; userId: string }>;
|
|
}
|
|
).runQuery(currentOrgForOwnerRef, {});
|
|
return org;
|
|
};
|
|
|
|
export const recordWebhookDelivery = internalMutation({
|
|
args: {
|
|
deliveryId: v.string(),
|
|
event: v.string(),
|
|
externalRepositoryId: v.optional(v.string()),
|
|
payloadHash: v.string(),
|
|
provider: v.union(v.literal("github"), v.literal("gitea")),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
// Check for duplicate delivery (idempotency)
|
|
const existing = await ctx.db
|
|
.query("gitWebhookDeliveries")
|
|
.withIndex("by_provider_and_deliveryId", (q) =>
|
|
q.eq("provider", args.provider).eq("deliveryId", args.deliveryId)
|
|
)
|
|
.unique();
|
|
if (existing) {
|
|
return { deliveryId: existing._id, duplicate: true };
|
|
}
|
|
const timestamp = Date.now();
|
|
const deliveryId = await ctx.db.insert("gitWebhookDeliveries", {
|
|
createdAt: timestamp,
|
|
deliveryId: args.deliveryId,
|
|
event: args.event,
|
|
externalRepositoryId: args.externalRepositoryId,
|
|
payloadHash: args.payloadHash,
|
|
processingState: "received",
|
|
provider: args.provider,
|
|
updatedAt: timestamp,
|
|
});
|
|
return { deliveryId, duplicate: false };
|
|
},
|
|
});
|
|
|
|
export const resolveRepositoryByExternalId = internalQuery({
|
|
args: {
|
|
externalRepositoryId: v.string(),
|
|
provider: v.union(v.literal("github"), v.literal("gitea")),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const rows = await ctx.db
|
|
.query("gitRepositories")
|
|
.withIndex("by_provider_and_serverUrl_and_externalRepositoryId", (q) =>
|
|
q.eq("provider", args.provider)
|
|
)
|
|
.collect();
|
|
return (
|
|
rows.find((r) => r.providerRepositoryId === args.externalRepositoryId) ??
|
|
null
|
|
);
|
|
},
|
|
});
|
|
|
|
export const updateWebhookState = internalMutation({
|
|
args: {
|
|
repositoryId: v.id("gitRepositories"),
|
|
webhookState: v.union(
|
|
v.literal("none"),
|
|
v.literal("pending"),
|
|
v.literal("active"),
|
|
v.literal("failed")
|
|
),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
await ctx.db.patch(args.repositoryId, {
|
|
updatedAt: Date.now(),
|
|
webhookState: args.webhookState,
|
|
});
|
|
return null;
|
|
},
|
|
});
|
|
|
|
export const markDeliveryProcessed = internalMutation({
|
|
args: {
|
|
deliveryId: v.id("gitWebhookDeliveries"),
|
|
repositoryRef: v.optional(v.id("gitRepositories")),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
await ctx.db.patch(args.deliveryId, {
|
|
...(args.repositoryRef ? { repositoryRef: args.repositoryRef } : {}),
|
|
processingState: "processed",
|
|
updatedAt: Date.now(),
|
|
});
|
|
return null;
|
|
},
|
|
});
|
|
|
|
const recordWebhookDeliveryRef = makeFunctionReference<
|
|
"mutation",
|
|
{
|
|
deliveryId: string;
|
|
event: string;
|
|
externalRepositoryId?: string;
|
|
payloadHash: string;
|
|
provider: "github" | "gitea";
|
|
},
|
|
{ deliveryId: Id<"gitWebhookDeliveries">; duplicate: boolean }
|
|
>("gitProvisioning:recordWebhookDelivery");
|
|
|
|
export const getRecordWebhookDeliveryRef = () => recordWebhookDeliveryRef;
|
|
|
|
export const resolveCurrentOrgForOwner = internalQuery({
|
|
args: {},
|
|
handler: async (ctx) => {
|
|
const { organizationId } = await requireCurrentOrganization(ctx);
|
|
const userId = await requireCurrentOrganizationOwner(ctx, organizationId);
|
|
return { organizationId, userId };
|
|
},
|
|
});
|
|
|
|
export const listProviderAccounts = query({
|
|
args: {},
|
|
handler: async (ctx) => {
|
|
const { organizationId } = await requireCurrentOrganization(ctx);
|
|
const connections = await ctx.db
|
|
.query("gitConnections")
|
|
.withIndex("by_organizationId", (q) =>
|
|
q.eq("organizationId", organizationId)
|
|
)
|
|
.collect();
|
|
|
|
const results = await Promise.all(
|
|
connections.map(async (connection) => {
|
|
const providerAccount = connection.gitProviderAccountId
|
|
? await ctx.db.get(connection.gitProviderAccountId)
|
|
: null;
|
|
const externalUsername =
|
|
providerAccount?.externalUsername ??
|
|
connection.username ??
|
|
"connected";
|
|
|
|
return {
|
|
externalUsername,
|
|
grantedScopesJson: connection.grantedScopesJson,
|
|
id: String(connection._id),
|
|
provider: connection.provider,
|
|
serverUrl: connection.serverUrl,
|
|
status: connection.state ?? "active",
|
|
};
|
|
})
|
|
);
|
|
|
|
return results;
|
|
},
|
|
});
|
|
|
|
export const listRepositories = query({
|
|
args: {},
|
|
handler: async (ctx) => {
|
|
const { organizationId } = await requireCurrentOrganization(ctx);
|
|
const repos = await ctx.db
|
|
.query("gitRepositories")
|
|
.withIndex("by_organizationId", (q) =>
|
|
q.eq("organizationId", organizationId)
|
|
)
|
|
.collect();
|
|
return repos.map((repo) => ({
|
|
cloneUrl: repo.cloneUrl,
|
|
defaultBranch: repo.defaultBranch,
|
|
fullName: repo.fullName,
|
|
id: String(repo._id),
|
|
name: repo.name,
|
|
owner: repo.owner,
|
|
privacy: repo.privacy,
|
|
provider: repo.provider,
|
|
webUrl: repo.webUrl,
|
|
}));
|
|
},
|
|
});
|
|
|
|
export const createProjectFromRepository = mutation({
|
|
args: {
|
|
gitRepositoryId: v.id("gitRepositories"),
|
|
name: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const { organizationId } = await requireCurrentOrganization(ctx);
|
|
const repo = await ctx.db.get(args.gitRepositoryId);
|
|
if (!repo || repo.organizationId !== organizationId) {
|
|
throw new ConvexError("Repository not found");
|
|
}
|
|
// Find the active connection for this repository's provider account.
|
|
// The connection must exist and be active for the Project to be executable.
|
|
const connection = await ctx.db
|
|
.query("gitConnections")
|
|
.withIndex("by_gitProviderAccountId", (q) =>
|
|
q.eq("gitProviderAccountId", repo.gitProviderAccountId)
|
|
)
|
|
.unique();
|
|
if (!connection) {
|
|
throw new ConvexError(
|
|
"No Git connection found for this repository's provider account"
|
|
);
|
|
}
|
|
if (connection.state !== undefined && connection.state !== "active") {
|
|
throw new ConvexError(
|
|
`Git connection is ${connection.state}; reconnect credentials before creating a Project`
|
|
);
|
|
}
|
|
const now = Date.now();
|
|
if (
|
|
connection.lastVerifiedAt === undefined ||
|
|
now - connection.lastVerifiedAt > CREDENTIAL_FRESHNESS_MS
|
|
) {
|
|
throw new ConvexError(
|
|
"Git credentials have not been verified recently; run a connection health check before creating a Project"
|
|
);
|
|
}
|
|
const timestamp = now;
|
|
const projectId = await ctx.db.insert("projects", {
|
|
createdAt: timestamp,
|
|
defaultBranch: repo.defaultBranch,
|
|
gitConnectionId: connection?._id,
|
|
gitRepositoryId: args.gitRepositoryId,
|
|
name: args.name,
|
|
normalizedSourceUrl: repo.webUrl,
|
|
organizationId,
|
|
repositoryPath: repo.fullName,
|
|
sourceHost: new URL(repo.serverUrl).hostname,
|
|
sourceUrl: repo.webUrl,
|
|
updatedAt: timestamp,
|
|
});
|
|
return { projectId };
|
|
},
|
|
});
|
|
|
|
export const attachRepositoryToProject = internalMutation({
|
|
args: {
|
|
gitRepositoryId: v.id("gitRepositories"),
|
|
projectId: v.id("projects"),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const project = await ctx.db.get(args.projectId);
|
|
if (!project) {
|
|
throw new ConvexError("Project not found");
|
|
}
|
|
const repo = await ctx.db.get(args.gitRepositoryId);
|
|
if (!repo || repo.organizationId !== project.organizationId) {
|
|
throw new ConvexError("Repository not found");
|
|
}
|
|
await ctx.db.patch(args.projectId, {
|
|
defaultBranch: repo.defaultBranch,
|
|
gitRepositoryId: args.gitRepositoryId,
|
|
normalizedSourceUrl: repo.webUrl,
|
|
repositoryPath: repo.fullName,
|
|
sourceHost: new URL(repo.serverUrl).hostname,
|
|
sourceUrl: repo.webUrl,
|
|
updatedAt: Date.now(),
|
|
});
|
|
return { attached: true };
|
|
},
|
|
});
|
|
|
|
export const syncRepositoriesBatch = internalMutation({
|
|
args: {
|
|
organizationId: v.id("organizations"),
|
|
provider: v.union(v.literal("github"), v.literal("gitea")),
|
|
providerAccountId: v.id("gitProviderAccounts"),
|
|
repos: v.array(
|
|
v.object({
|
|
cloneUrl: v.string(),
|
|
defaultBranch: v.string(),
|
|
fullName: v.string(),
|
|
name: v.string(),
|
|
owner: v.string(),
|
|
private: v.boolean(),
|
|
providerRepositoryId: v.string(),
|
|
serverUrl: v.string(),
|
|
webUrl: v.string(),
|
|
})
|
|
),
|
|
serverUrl: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
// `organizationId` is passed explicitly because this internal mutation is
|
|
// invoked from an action via `ctx.runMutation`, which does not propagate
|
|
// the authenticated identity (so `ctx.auth.getUserIdentity()` is null).
|
|
// The caller (`syncRepositories`) resolves the identity in the action and
|
|
// verifies the connection belongs to the caller before reaching here.
|
|
const { organizationId } = args;
|
|
let upserted = 0;
|
|
for (const repo of args.repos) {
|
|
const existing = await ctx.db
|
|
.query("gitRepositories")
|
|
.withIndex("by_provider_and_serverUrl_and_externalRepositoryId", (q) =>
|
|
q
|
|
.eq("provider", args.provider)
|
|
.eq("serverUrl", repo.serverUrl)
|
|
.eq("providerRepositoryId", repo.providerRepositoryId)
|
|
)
|
|
.unique();
|
|
const timestamp = Date.now();
|
|
const fields = {
|
|
cloneUrl: repo.cloneUrl,
|
|
defaultBranch: repo.defaultBranch,
|
|
fullName: repo.fullName,
|
|
gitProviderAccountId: args.providerAccountId,
|
|
lfsCapability: "unknown" as const,
|
|
name: repo.name,
|
|
owner: repo.owner,
|
|
permissionsAdmin: true,
|
|
permissionsMaintain: true,
|
|
permissionsPull: true,
|
|
permissionsPush: true,
|
|
permissionsTriage: true,
|
|
privacy: repo.private ? ("private" as const) : ("public" as const),
|
|
provider: args.provider,
|
|
updatedAt: timestamp,
|
|
webUrl: repo.webUrl,
|
|
};
|
|
await (existing
|
|
? ctx.db.patch(existing._id, fields)
|
|
: ctx.db.insert("gitRepositories", {
|
|
...fields,
|
|
createdAt: timestamp,
|
|
organizationId,
|
|
providerRepositoryId: repo.providerRepositoryId,
|
|
serverUrl: repo.serverUrl,
|
|
webhookState: "none",
|
|
}));
|
|
upserted += 1;
|
|
}
|
|
return upserted;
|
|
},
|
|
});
|