'use node'; import { createSign } from 'node:crypto'; import { getAuthUserId } from '@convex-dev/auth/server'; import { ConvexError, v } from 'convex/values'; import type { Id } from './_generated/dataModel'; import type { ActionCtx } from './_generated/server'; import { internal } from './_generated/api'; import { action } from './_generated/server'; type GitHubInstallationAccount = { id?: number; login?: string; avatar_url?: string; }; type GitHubInstallation = { id: number; account?: GitHubInstallationAccount; }; type GitHubRepository = { id: number; name: string; full_name: string; private: boolean; fork: boolean; html_url: string; default_branch: string; owner: { login: string; }; description?: string | null; }; type GitHubListRepositoriesResponse = { repositories: GitHubRepository[]; }; const base64Url = (value: string | Buffer) => Buffer.from(value) .toString('base64') .replaceAll('+', '-') .replaceAll('/', '_') .replaceAll('=', ''); const getEnv = (name: string) => { const value = process.env[name]?.trim(); if (!value) throw new ConvexError(`${name} is not configured.`); return value; }; const normalizePrivateKey = (value: string) => value.replaceAll('\\n', '\n'); const firstText = (...values: (string | null | undefined)[]) => values.map((value) => value?.trim()).find((value) => value); const createGitHubAppJwt = () => { const appId = getEnv('GITHUB_APP_ID'); const privateKey = normalizePrivateKey(getEnv('GITHUB_APP_PRIVATE_KEY')); const now = Math.floor(Date.now() / 1000); const header = base64Url(JSON.stringify({ alg: 'RS256', typ: 'JWT' })); const payload = base64Url( JSON.stringify({ iat: now - 60, exp: now + 9 * 60, iss: appId, }), ); const body = `${header}.${payload}`; const signature = createSign('RSA-SHA256').update(body).sign(privateKey); return `${body}.${base64Url(signature)}`; }; const githubFetch = async ( path: string, token: string, init: RequestInit = {}, ): Promise => { const response = await fetch(`https://api.github.com${path}`, { ...init, headers: { Accept: 'application/vnd.github+json', Authorization: `Bearer ${token}`, 'User-Agent': 'Spoon', 'X-GitHub-Api-Version': '2022-11-28', ...init.headers, }, }); if (!response.ok) { const body = await response.text(); throw new ConvexError( `GitHub API request failed (${response.status}): ${body}`, ); } return (await response.json()) as T; }; const createInstallationToken = async (installationId: string) => { const jwt = createGitHubAppJwt(); const result = await githubFetch<{ token: string; expires_at: string }>( `/app/installations/${installationId}/access_tokens`, jwt, { method: 'POST' }, ); return result.token; }; const getRequiredUserId = async (ctx: ActionCtx) => { const userId = await getAuthUserId(ctx); if (!userId) throw new ConvexError('Not authenticated.'); return userId; }; export const syncConfiguredInstallation = action({ args: {}, handler: async (ctx): Promise> => { const userId = await getRequiredUserId(ctx); const installationId = getEnv('GITHUB_APP_INSTALLATION_ID'); const jwt = createGitHubAppJwt(); const installation = await githubFetch( `/app/installations/${installationId}`, jwt, ); const account = installation.account; const displayName = account?.login ?? `GitHub installation ${installationId}`; return await ctx.runMutation(internal.github.upsertConnectionForUser, { userId, providerAccountId: account?.id?.toString(), displayName, username: account?.login, avatarUrl: account?.avatar_url, installationId: installation.id.toString(), }); }, }); export const listInstallationRepositories = action({ args: {}, handler: async ( ctx, ): Promise< { id: number; name: string; fullName: string; owner: string; private: boolean; fork: boolean; url: string; defaultBranch: string; description?: string; }[] > => { const userId = await getRequiredUserId(ctx); const connection = await ctx.runQuery( internal.github.getConnectionForUser, { userId, }, ); if (!connection?.installationId) { throw new ConvexError('Connect a GitHub App installation first.'); } const token = await createInstallationToken(connection.installationId); const result = await githubFetch( '/installation/repositories?per_page=100', token, ); return result.repositories.map((repo) => ({ id: repo.id, name: repo.name, fullName: repo.full_name, owner: repo.owner.login, private: repo.private, fork: repo.fork, url: repo.html_url, defaultBranch: repo.default_branch, description: repo.description ?? undefined, })); }, }); export const createFork = action({ args: { upstreamOwner: v.string(), upstreamRepo: v.string(), name: v.optional(v.string()), description: v.optional(v.string()), organization: v.optional(v.string()), }, handler: async (ctx, args): Promise> => { const userId = await getRequiredUserId(ctx); const connection = await ctx.runQuery( internal.github.getConnectionForUser, { userId, }, ); if (!connection?.installationId) { throw new ConvexError('Connect a GitHub App installation first.'); } const upstreamOwner = args.upstreamOwner.trim(); const upstreamRepo = args.upstreamRepo.trim(); if (!upstreamOwner || !upstreamRepo) { throw new ConvexError('Upstream owner and repository are required.'); } const token = await createInstallationToken(connection.installationId); const upstream = await githubFetch( `/repos/${encodeURIComponent(upstreamOwner)}/${encodeURIComponent( upstreamRepo, )}`, token, ); const body: Record = { default_branch_only: false, }; const forkName = args.name?.trim(); if (forkName) body.name = forkName; const organization = args.organization?.trim(); if (organization) body.organization = organization; const fork = await githubFetch( `/repos/${encodeURIComponent(upstreamOwner)}/${encodeURIComponent( upstreamRepo, )}/forks`, token, { method: 'POST', body: JSON.stringify(body), }, ); const description = firstText(args.description, upstream.description); return await ctx.runMutation(internal.github.createForkSpoonRecord, { ownerId: userId, name: fork.name, description, upstreamOwner, upstreamRepo, upstreamDefaultBranch: upstream.default_branch, upstreamUrl: upstream.html_url, forkOwner: fork.owner.login, forkRepo: fork.name, forkDefaultBranch: fork.default_branch, forkUrl: fork.html_url, visibility: fork.private ? 'private' : 'public', connectionId: connection._id, }); }, });