|
| 1 | +import { execFileSync } from "node:child_process"; |
| 2 | +import { graphql as octokitGraphql } from "@octokit/graphql"; |
| 3 | +import { Octokit } from "@octokit/rest"; |
| 4 | + |
| 5 | +import { gateAuth } from "./github-auth.js"; |
| 6 | + |
| 7 | +let cachedOctokit: Octokit | undefined; |
| 8 | +let cachedGraphql: typeof octokitGraphql | undefined; |
| 9 | + |
| 10 | +function baseUrl(): string { |
| 11 | + return process.env.GITHUB_API_URL || "https://api.github.com"; |
| 12 | +} |
| 13 | + |
| 14 | +/** Get the shared Octokit REST client. Throws if auth is not available. */ |
| 15 | +export function getOctokit(): Octokit { |
| 16 | + if (cachedOctokit) return cachedOctokit; |
| 17 | + const auth = gateAuth(); |
| 18 | + if (!auth.ok) throw new Error("GitHub auth not available"); |
| 19 | + cachedOctokit = new Octokit({ auth: auth.token, baseUrl: baseUrl() }); |
| 20 | + return cachedOctokit; |
| 21 | +} |
| 22 | + |
| 23 | +/** Run a typed GraphQL query against the GitHub API. */ |
| 24 | +export async function graphqlQuery<T = Record<string, unknown>>( |
| 25 | + query: string, |
| 26 | + variables?: Record<string, unknown>, |
| 27 | +): Promise<T> { |
| 28 | + if (!cachedGraphql) { |
| 29 | + const auth = gateAuth(); |
| 30 | + if (!auth.ok) throw new Error("GitHub auth not available"); |
| 31 | + const graphqlUrl = process.env.GITHUB_GRAPHQL_URL; |
| 32 | + cachedGraphql = octokitGraphql.defaults({ |
| 33 | + headers: { authorization: `token ${auth.token}` }, |
| 34 | + ...(graphqlUrl ? { baseUrl: graphqlUrl } : {}), |
| 35 | + }); |
| 36 | + } |
| 37 | + return (await cachedGraphql(query, variables ?? {})) as T; |
| 38 | +} |
| 39 | + |
| 40 | +/** |
| 41 | + * Run up to `concurrency` async tasks in parallel from an iterable. |
| 42 | + * Identical pattern to mcp-multi-root-git's asyncPool. |
| 43 | + */ |
| 44 | +export async function asyncPool<T, R>( |
| 45 | + items: readonly T[], |
| 46 | + concurrency: number, |
| 47 | + fn: (item: T) => Promise<R>, |
| 48 | +): Promise<R[]> { |
| 49 | + const results: R[] = []; |
| 50 | + const executing = new Set<Promise<void>>(); |
| 51 | + |
| 52 | + for (const item of items) { |
| 53 | + const p = fn(item).then((r) => { |
| 54 | + results.push(r); |
| 55 | + }); |
| 56 | + const wrapped = p.then(() => { |
| 57 | + executing.delete(wrapped); |
| 58 | + }); |
| 59 | + executing.add(wrapped); |
| 60 | + |
| 61 | + if (executing.size >= concurrency) { |
| 62 | + await Promise.race(executing); |
| 63 | + } |
| 64 | + } |
| 65 | + |
| 66 | + await Promise.all(executing); |
| 67 | + return results; |
| 68 | +} |
| 69 | + |
| 70 | +const GITHUB_API_PARALLELISM = 4; |
| 71 | + |
| 72 | +/** Convenience: run API calls in parallel with default concurrency. */ |
| 73 | +export async function parallelApi<T, R>( |
| 74 | + items: readonly T[], |
| 75 | + fn: (item: T) => Promise<R>, |
| 76 | +): Promise<R[]> { |
| 77 | + return asyncPool(items, GITHUB_API_PARALLELISM, fn); |
| 78 | +} |
| 79 | + |
| 80 | +/** |
| 81 | + * Resolve a local git clone's GitHub remote to owner/repo. |
| 82 | + * Parses the `origin` remote URL. |
| 83 | + */ |
| 84 | +export function resolveLocalRepoRemote( |
| 85 | + localPath: string, |
| 86 | +): { owner: string; repo: string } | undefined { |
| 87 | + try { |
| 88 | + const url = execFileSync("git", ["remote", "get-url", "origin"], { |
| 89 | + cwd: localPath, |
| 90 | + encoding: "utf8", |
| 91 | + timeout: 5_000, |
| 92 | + stdio: ["ignore", "pipe", "ignore"], |
| 93 | + }).trim(); |
| 94 | + |
| 95 | + // SSH: git@github.com:owner/repo.git |
| 96 | + const ssh = /github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/.exec(url); |
| 97 | + if (ssh?.[1] && ssh[2]) return { owner: ssh[1], repo: ssh[2] }; |
| 98 | + |
| 99 | + // HTTPS: https://github.com/owner/repo.git |
| 100 | + const https = /github\.com\/([^/]+)\/([^/.]+?)(?:\.git)?$/.exec(url); |
| 101 | + if (https?.[1] && https[2]) return { owner: https[1], repo: https[2] }; |
| 102 | + } catch { |
| 103 | + // Not a git repo or no origin |
| 104 | + } |
| 105 | + return undefined; |
| 106 | +} |
0 commit comments