Files
zopu-code/packages/backend/convex/gitProvisioning.ts
-Puter ce16813fbc feat(github): live repo search with /user/repos pagination and connection gear UI
- 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)
2026-08-01 20:33:55 +05:30

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;
},
});