263 lines
7.5 KiB
TypeScript
263 lines
7.5 KiB
TypeScript
import { createAppAuth } from '@octokit/auth-app';
|
|
import { retry } from '@octokit/plugin-retry';
|
|
import { throttling } from '@octokit/plugin-throttling';
|
|
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');
|
|
|
|
const SpoonOctokit = Octokit.plugin(retry, throttling);
|
|
|
|
const MAX_THROTTLE_RETRIES = 2;
|
|
|
|
export const getInstallationOctokit = (installationId: string) =>
|
|
new SpoonOctokit({
|
|
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',
|
|
},
|
|
},
|
|
throttle: {
|
|
onRateLimit: (retryAfter, options, _octokit, retryCount) => {
|
|
console.warn(
|
|
`[github] rate limit hit for ${options.method} ${options.url}; retry ${retryCount} after ${retryAfter}s`,
|
|
);
|
|
return retryCount < MAX_THROTTLE_RETRIES;
|
|
},
|
|
onSecondaryRateLimit: (retryAfter, options, _octokit, retryCount) => {
|
|
console.warn(
|
|
`[github] secondary rate limit hit for ${options.method} ${options.url}; retry ${retryCount} after ${retryAfter}s`,
|
|
);
|
|
return retryCount < MAX_THROTTLE_RETRIES;
|
|
},
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Pure predicate: does this thrown value look like a GitHub rate-limit /
|
|
* secondary-limit error? Detects the Octokit `RequestError` shape without
|
|
* importing node-only modules so it can be unit-tested directly.
|
|
*/
|
|
export const isRateLimitError = (error: unknown): boolean => {
|
|
if (typeof error !== 'object' || error === null) return false;
|
|
const err = error as {
|
|
status?: number;
|
|
message?: string;
|
|
response?: { headers?: Record<string, string | undefined> };
|
|
};
|
|
if (err.status !== 403 && err.status !== 429) return false;
|
|
if (err.status === 429) return true;
|
|
const message = typeof err.message === 'string' ? err.message.toLowerCase() : '';
|
|
if (message.includes('rate limit')) return true;
|
|
const headers = err.response?.headers ?? {};
|
|
if (headers['x-ratelimit-remaining'] === '0') return true;
|
|
if (headers['x-ratelimit-reset'] !== undefined) return true;
|
|
return false;
|
|
};
|
|
|
|
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;
|
|
};
|
|
|
|
export const resolveBranchHead = async (
|
|
octokit: Octokit,
|
|
owner: string,
|
|
repo: string,
|
|
branch: string,
|
|
): Promise<{ sha: string }> => {
|
|
const result = await octokit.rest.repos.getBranch({ owner, repo, branch });
|
|
return { sha: result.data.commit.sha };
|
|
};
|
|
|
|
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,
|
|
});
|
|
|
|
const COMPARE_PER_PAGE = 100;
|
|
const COMPARE_MAX_PAGES = 30; // bound: up to 3000 commits collected
|
|
|
|
export const compareAcrossForkNetwork = async (
|
|
octokit: Octokit,
|
|
args: {
|
|
owner: string;
|
|
repo: string;
|
|
baseOwner: string;
|
|
baseBranch: string;
|
|
headOwner: string;
|
|
headRepo: string;
|
|
headBranch: string;
|
|
},
|
|
): Promise<GitHubCompareSummary> => {
|
|
const basehead = `${args.baseOwner}:${args.baseBranch}...${args.headOwner}:${args.headBranch}`;
|
|
const first = await octokit.rest.repos.compareCommitsWithBasehead({
|
|
owner: args.owner,
|
|
repo: args.repo,
|
|
basehead,
|
|
per_page: COMPARE_PER_PAGE,
|
|
page: 1,
|
|
});
|
|
const rawCommits = [...first.data.commits];
|
|
const totalCommits = first.data.total_commits;
|
|
let page = 2;
|
|
while (rawCommits.length < totalCommits && page <= COMPARE_MAX_PAGES) {
|
|
const next = await octokit.rest.repos.compareCommitsWithBasehead({
|
|
owner: args.owner,
|
|
repo: args.repo,
|
|
basehead,
|
|
per_page: COMPARE_PER_PAGE,
|
|
page,
|
|
});
|
|
if (next.data.commits.length === 0) break;
|
|
rawCommits.push(...next.data.commits);
|
|
page += 1;
|
|
}
|
|
const head = await resolveBranchHead(
|
|
octokit,
|
|
args.headOwner,
|
|
args.headRepo,
|
|
args.headBranch,
|
|
);
|
|
return {
|
|
aheadBy: first.data.ahead_by,
|
|
mergeBaseSha: first.data.merge_base_commit.sha,
|
|
headSha: head.sha,
|
|
baseSha: first.data.base_commit.sha,
|
|
htmlUrl: first.data.html_url,
|
|
commits: rawCommits.map(normalizeCompareCommit),
|
|
};
|
|
};
|
|
|
|
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;
|
|
};
|