Skip to content

Commit 0db14c0

Browse files
AlbinoGeekclaude
andcommitted
feat: GitHub auth and API client layer
Token resolution (GITHUB_TOKEN → GH_TOKEN → gh CLI fallback), cached Octokit REST + GraphQL clients, asyncPool for parallel API calls, local repo remote resolution from git origin. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e6ac234 commit 0db14c0

2 files changed

Lines changed: 166 additions & 0 deletions

File tree

src/server/github-auth.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { execFileSync } from "node:child_process";
2+
3+
interface AuthOk {
4+
ok: true;
5+
token: string;
6+
}
7+
8+
interface AuthError {
9+
ok: false;
10+
body: Record<string, unknown>;
11+
}
12+
13+
type AuthResult = AuthOk | AuthError;
14+
15+
let cached: AuthResult | undefined;
16+
17+
/**
18+
* Resolve a GitHub personal access token.
19+
*
20+
* Priority: GITHUB_TOKEN env → GH_TOKEN env → `gh auth token` subprocess.
21+
* Result is cached after first call.
22+
*/
23+
export function gateAuth(): AuthResult {
24+
if (cached) return cached;
25+
26+
const envToken = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
27+
if (envToken) {
28+
cached = { ok: true, token: envToken };
29+
return cached;
30+
}
31+
32+
// Fallback: try `gh auth token`
33+
try {
34+
const token = execFileSync("gh", ["auth", "token"], {
35+
encoding: "utf8",
36+
timeout: 5_000,
37+
stdio: ["ignore", "pipe", "ignore"],
38+
}).trim();
39+
if (token) {
40+
cached = { ok: true, token };
41+
return cached;
42+
}
43+
} catch {
44+
// gh not installed or not authenticated — fall through
45+
}
46+
47+
cached = {
48+
ok: false,
49+
body: {
50+
error: "github_auth_missing",
51+
hint: "Set GITHUB_TOKEN or GH_TOKEN, or run `gh auth login`.",
52+
},
53+
};
54+
return cached;
55+
}
56+
57+
/** Clear the cached auth result (useful for testing). */
58+
export function resetAuthCache(): void {
59+
cached = undefined;
60+
}

src/server/github-client.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
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

Comments
 (0)