262 lines
7.1 KiB
TypeScript
262 lines
7.1 KiB
TypeScript
'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 <T>(
|
|
path: string,
|
|
token: string,
|
|
init: RequestInit = {},
|
|
): Promise<T> => {
|
|
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<Id<'gitConnections'>> => {
|
|
const userId = await getRequiredUserId(ctx);
|
|
const installationId = getEnv('GITHUB_APP_INSTALLATION_ID');
|
|
const jwt = createGitHubAppJwt();
|
|
const installation = await githubFetch<GitHubInstallation>(
|
|
`/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<GitHubListRepositoriesResponse>(
|
|
'/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<Id<'spoons'>> => {
|
|
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<GitHubRepository>(
|
|
`/repos/${encodeURIComponent(upstreamOwner)}/${encodeURIComponent(
|
|
upstreamRepo,
|
|
)}`,
|
|
token,
|
|
);
|
|
|
|
const body: Record<string, string | boolean> = {
|
|
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<GitHubRepository>(
|
|
`/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,
|
|
});
|
|
},
|
|
});
|