Files
spoon/packages/backend/convex/githubClient.ts
T
Gabriel Brown 2dfa97ee4f
Build and Push Next App / quality (push) Failing after 48s
Build and Push Next App / build-next (push) Has been skipped
Add agent workflows & stuff
2026-06-21 21:15:15 -05:00

186 lines
4.8 KiB
TypeScript

import { createAppAuth } from '@octokit/auth-app';
import { Octokit } from '@octokit/rest';
import { ConvexError } from 'convex/values';
import type { Doc } from './_generated/dataModel';
export type GitHubCommitSummary = {
sha: string;
message: string;
authorName?: string;
authorEmail?: string;
authorLogin?: string;
committedAt?: number;
htmlUrl?: string;
filesChanged?: number;
additions?: number;
deletions?: number;
};
export type GitHubPullRequestSummary = {
githubId: number;
number: number;
repoFullName: string;
title: string;
state: 'open' | 'closed' | 'merged';
draft: boolean;
authorLogin?: string;
baseRef: string;
headRef: string;
headRepoFullName?: string;
htmlUrl: string;
createdAtGithub?: number;
updatedAtGithub?: number;
mergedAtGithub?: number;
};
export type GitHubCompareSummary = {
aheadBy: number;
mergeBaseSha?: string;
headSha?: string;
baseSha?: string;
htmlUrl?: string;
commits: GitHubCommitSummary[];
};
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');
export const getInstallationOctokit = (installationId: string) =>
new Octokit({
authStrategy: createAppAuth,
auth: {
appId: getEnv('GITHUB_APP_ID'),
privateKey: normalizePrivateKey(getEnv('GITHUB_APP_PRIVATE_KEY')),
installationId,
},
userAgent: 'Spoon',
request: {
headers: {
'X-GitHub-Api-Version': '2022-11-28',
},
},
});
export const getSpoonInstallationId = (
spoon: Doc<'spoons'>,
connection?: Doc<'gitConnections'> | null,
) => {
const installationId =
spoon.githubInstallationId ?? connection?.installationId ?? undefined;
if (!installationId) {
throw new ConvexError('Connect a GitHub App installation first.');
}
return installationId;
};
export const getRepository = async (
octokit: Octokit,
owner: string,
repo: string,
) => {
const result = await octokit.rest.repos.get({ owner, repo });
return result.data;
};
const toMillis = (value?: string | null) =>
value ? new Date(value).getTime() : undefined;
const normalizeCompareCommit = (
commit: Awaited<
ReturnType<Octokit['rest']['repos']['compareCommitsWithBasehead']>
>['data']['commits'][number],
): GitHubCommitSummary => ({
sha: commit.sha,
message: commit.commit.message,
authorName: commit.commit.author?.name ?? undefined,
authorEmail: commit.commit.author?.email ?? undefined,
authorLogin: commit.author?.login ?? undefined,
committedAt: toMillis(
commit.commit.author?.date ?? commit.commit.committer?.date,
),
htmlUrl: commit.html_url,
});
export const compareAcrossForkNetwork = async (
octokit: Octokit,
args: {
owner: string;
repo: string;
baseOwner: string;
baseBranch: string;
headOwner: string;
headBranch: string;
},
): Promise<GitHubCompareSummary> => {
const basehead = `${args.baseOwner}:${args.baseBranch}...${args.headOwner}:${args.headBranch}`;
const result = await octokit.rest.repos.compareCommitsWithBasehead({
owner: args.owner,
repo: args.repo,
basehead,
per_page: 100,
});
const commits = result.data.commits.map(normalizeCompareCommit);
return {
aheadBy: result.data.ahead_by,
mergeBaseSha: result.data.merge_base_commit.sha,
headSha: commits[commits.length - 1]?.sha,
baseSha: result.data.base_commit.sha,
htmlUrl: result.data.html_url,
commits,
};
};
const normalizePullRequest = (
repoFullName: string,
pull: Awaited<ReturnType<Octokit['rest']['pulls']['list']>>['data'][number],
): GitHubPullRequestSummary => ({
githubId: pull.id,
number: pull.number,
repoFullName,
title: pull.title,
state: pull.merged_at ? 'merged' : pull.state === 'open' ? 'open' : 'closed',
draft: pull.draft === true,
authorLogin: pull.user?.login ?? undefined,
baseRef: pull.base.ref,
headRef: pull.head.ref,
headRepoFullName: pull.head.repo.full_name,
htmlUrl: pull.html_url,
createdAtGithub: toMillis(pull.created_at),
updatedAtGithub: toMillis(pull.updated_at),
mergedAtGithub: toMillis(pull.merged_at),
});
export const listPullRequests = async (
octokit: Octokit,
args: { owner: string; repo: string; head?: string },
) => {
const result = await octokit.rest.pulls.list({
owner: args.owner,
repo: args.repo,
state: 'all',
per_page: 100,
head: args.head,
});
return result.data.map((pull) =>
normalizePullRequest(`${args.owner}/${args.repo}`, pull),
);
};
export const syncForkBranch = async (
octokit: Octokit,
args: { forkOwner: string; forkRepo: string; branch: string },
) => {
const result = await octokit.rest.repos.mergeUpstream({
owner: args.forkOwner,
repo: args.forkRepo,
branch: args.branch,
});
return result.data;
};