Add agent workflows & stuff
Build and Push Next App / quality (push) Failing after 48s
Build and Push Next App / build-next (push) Has been skipped

This commit is contained in:
Gabriel Brown
2026-06-21 21:15:15 -05:00
parent cf7ff2ee4e
commit 2dfa97ee4f
102 changed files with 8488 additions and 161 deletions
+261
View File
@@ -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,
});
},
});