Add agent workflows & stuff
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
'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,
|
||||
});
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user