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 }; }; 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 >['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 => { 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>['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; };