Files
fusion/packages/core/src/gh-cli.ts
gsxdsm d78fbccacc FN-5643: fix gh api --json flag handling for PR fetches
Fusion-Task-Id: FN-5643

Fusion-Task-Lineage: 2eaba342-1ac7-4ebf-886e-acd22af637ee
2026-05-28 22:59:18 -07:00

488 lines
16 KiB
TypeScript

import { execFileSync, execFile } from "node:child_process";
import type { ExecFileException } from "node:child_process";
export interface GhError extends Error {
code: string | number | null;
stderr: string;
stdout: string;
}
export type GhErrorCode =
| "not-installed"
| "not-authenticated"
| "rate-limited"
| "not-found"
| "network"
| "permission"
| "merge-conflict"
| "validation"
| "timeout"
| "unknown";
export interface StructuredGhError {
code: GhErrorCode;
message: string;
hint?: string;
action?: { kind: "shell"; command: string } | { kind: "retry" } | { kind: "open"; url: string };
retryable: boolean;
retryAfterMs?: number;
cause?: {
stderr?: string;
stdout?: string;
exitCode?: string | number | null;
};
}
export interface RunGhOptions {
cwd?: string;
/** External abort signal — propagated to the spawned `gh` process. */
signal?: AbortSignal;
/**
* Hard ceiling on `gh` runtime in milliseconds. The child process is killed
* when exceeded and the returned promise rejects with a `GhError`. Defaults
* to 30_000 (30 s); set to `0` or a negative value to disable.
*
* Without this, an upstream hang (network stall, hung credential helper,
* gh waiting on stdin) can leave the call pending forever — which in turn
* pins any AI session's `prompt()` that triggered the tool call.
*/
timeoutMs?: number;
}
const DEFAULT_GH_TIMEOUT_MS = 30_000;
function normalizeRunGhOptions(opts: string | RunGhOptions | undefined): RunGhOptions {
if (typeof opts === "string") return { cwd: opts };
return opts ?? {};
}
// Both isGhAvailable() and isGhAuthenticated() are deterministic for the
// lifetime of a process (the gh binary doesn't get uninstalled, and the
// auth state doesn't change without explicit user action) but they each
// shell out via execFileSync — `gh auth status` in particular performs a
// network roundtrip to GitHub. At startup the GitHubTrackingReconciler
// scans up to 200 done tasks and calls hasGhAuth() per-task, producing
// hundreds of synchronous spawns that pin the event loop for ~60s and
// make the dashboard unresponsive during cold start.
//
// Cache the result with a short TTL so callers still notice if the user
// runs `gh auth login` mid-session, but a tight loop of N callers in the
// same second pays for at most one spawn.
const GH_CHECK_TTL_MS = 60_000;
let cachedAvailable: { value: boolean; at: number } | undefined;
let cachedAuthenticated: { value: boolean; at: number } | undefined;
/**
* Reset the in-memory cache. Call after operations that legitimately change
* gh auth state (login/logout) so the next check observes the new state
* without waiting for the TTL.
*/
export function resetGhAvailabilityCache(): void {
cachedAvailable = undefined;
cachedAuthenticated = undefined;
}
/**
* Check if the `gh` CLI is installed and available.
*/
export function isGhAvailable(): boolean {
if (cachedAvailable && Date.now() - cachedAvailable.at < GH_CHECK_TTL_MS) {
return cachedAvailable.value;
}
let value = false;
try {
execFileSync("gh", ["--version"], {
encoding: "utf-8",
stdio: ["pipe", "pipe", "ignore"],
});
value = true;
} catch {
value = false;
}
cachedAvailable = { value, at: Date.now() };
return value;
}
/**
* Check if the `gh` CLI is authenticated with GitHub.
* Returns true if authenticated, false if not.
*/
export function isGhAuthenticated(): boolean {
if (cachedAuthenticated && Date.now() - cachedAuthenticated.at < GH_CHECK_TTL_MS) {
return cachedAuthenticated.value;
}
let value = false;
try {
const result = execFileSync("gh", ["auth", "status"], {
encoding: "utf-8",
stdio: ["pipe", "pipe", "ignore"],
});
// gh auth status returns 0 and outputs "Logged in" if authenticated
value = result.includes("Logged in") || result.includes("Authenticated");
} catch {
value = false;
}
cachedAuthenticated = { value, at: Date.now() };
return value;
}
/**
* Execute a gh CLI command synchronously.
* Throws GhError on failure with parsed error details.
*/
export function runGh(args: string[], cwd?: string): string {
try {
const result = execFileSync("gh", args, {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
cwd,
});
return result;
} catch (err: unknown) {
const execErr = err as ExecFileException & { stdout?: Buffer | string; stderr?: Buffer | string };
const error = new Error(`gh command failed: ${execErr.message}`) as GhError;
error.code = execErr.code ?? null;
error.stdout = execErr.stdout?.toString() ?? "";
error.stderr = execErr.stderr?.toString() ?? "";
throw error;
}
}
/**
* Execute a gh CLI command asynchronously.
*
* Returns a promise that resolves with the output or rejects with `GhError`.
*
* `cwdOrOptions` accepts either a string (cwd, legacy form) or a `RunGhOptions`
* object. The options form supports an external `AbortSignal` and a
* `timeoutMs` ceiling — both default to safe values that prevent indefinite
* hangs when `gh` stalls on the network or a credential helper.
*/
export function runGhAsync(args: string[], cwdOrOptions?: string | RunGhOptions): Promise<string> {
const { cwd, signal: externalSignal, timeoutMs = DEFAULT_GH_TIMEOUT_MS } =
normalizeRunGhOptions(cwdOrOptions);
return new Promise((resolve, reject) => {
if (externalSignal?.aborted) {
reject(makeGhError(`gh command aborted: ${describeAbortReason(externalSignal.reason)}`, "ABORT_ERR"));
return;
}
// Compose the abort sources so we can distinguish timeout from external
// abort in the rejection. Using a private controller keeps signal
// ownership inside this function.
const controller = new AbortController();
let timedOut = false;
let externalAborted = false;
const onExternalAbort = () => {
externalAborted = true;
controller.abort();
};
if (externalSignal) {
externalSignal.addEventListener("abort", onExternalAbort, { once: true });
}
const timer = timeoutMs > 0
? setTimeout(() => {
timedOut = true;
controller.abort();
}, timeoutMs)
: undefined;
const cleanup = () => {
if (timer) clearTimeout(timer);
if (externalSignal) externalSignal.removeEventListener("abort", onExternalAbort);
};
execFile(
"gh",
args,
{
encoding: "utf-8",
cwd,
signal: controller.signal,
},
(error, stdout, stderr) => {
cleanup();
if (error) {
const isAbort = (error as ExecFileException & { code?: string | number | null }).code === "ABORT_ERR"
|| error.name === "AbortError";
let message: string;
if (timedOut) {
message = `gh command timed out after ${timeoutMs}ms`;
} else if (isAbort && externalAborted) {
message = `gh command aborted: ${describeAbortReason(externalSignal?.reason)}`;
} else if (isAbort) {
message = "gh command aborted";
} else {
message = `gh command failed: ${error.message}`;
}
const ghError = new Error(message) as GhError;
ghError.code = (error as ExecFileException).code ?? (isAbort ? "ABORT_ERR" : null);
ghError.stdout = stdout ?? "";
ghError.stderr = stderr ?? "";
reject(ghError);
} else {
resolve(stdout ?? "");
}
}
);
});
}
function makeGhError(message: string, code: string | number | null): GhError {
const err = new Error(message) as GhError;
err.code = code;
err.stdout = "";
err.stderr = "";
return err;
}
function describeAbortReason(reason: unknown): string {
if (reason instanceof Error) return reason.message;
if (typeof reason === "string") return reason;
return "aborted";
}
function shouldAutoAppendJsonFlag(args: string[]): boolean {
return args[0] !== "api" && !args.includes("--json");
}
/**
* Execute a gh CLI command and parse the JSON output.
* Auto-appends `--json` except for `gh api`, which emits JSON natively and rejects that flag.
*/
export function runGhJson<T>(args: string[], cwd?: string): T {
const jsonArgs = shouldAutoAppendJsonFlag(args) ? [...args, "--json"] : args;
const output = runGh(jsonArgs, cwd);
try {
return JSON.parse(output) as T;
} catch (err) {
throw new Error(`Failed to parse gh JSON output: ${err instanceof Error ? err.message : String(err)}`);
}
}
/**
* Execute a gh CLI command asynchronously and parse the JSON output.
* Auto-appends `--json` except for `gh api`, which emits JSON natively and rejects that flag.
*
* Forwards `signal` / `timeoutMs` to the underlying `runGhAsync` so callers
* can bound the call from outside (e.g. an AI tool's `signal` argument).
*/
export async function runGhJsonAsync<T>(args: string[], cwdOrOptions?: string | RunGhOptions): Promise<T> {
const jsonArgs = shouldAutoAppendJsonFlag(args) ? [...args, "--json"] : args;
const output = await runGhAsync(jsonArgs, cwdOrOptions);
try {
return JSON.parse(output) as T;
} catch (err) {
throw new Error(`Failed to parse gh JSON output: ${err instanceof Error ? err.message : String(err)}`);
}
}
function parseRetryAfterMs(content: string): number | undefined {
const retryAfterMatch = content.match(/retry-after\s*[:=]\s*(\d+)/i);
if (retryAfterMatch) {
return Number.parseInt(retryAfterMatch[1], 10) * 1000;
}
const resetMatch = content.match(/x-ratelimit-reset\s*[:=]\s*(\d{10,})/i);
if (resetMatch) {
const resetEpochMs = Number.parseInt(resetMatch[1], 10) * 1000;
return Math.max(0, resetEpochMs - Date.now());
}
const waitMatch = content.match(/(?:try again|retry)\s+in\s+(\d+)\s*(second|seconds|sec|s|minute|minutes|min|m)/i);
if (waitMatch) {
const amount = Number.parseInt(waitMatch[1], 10);
const unit = waitMatch[2].toLowerCase();
if (unit.startsWith("m")) return amount * 60_000;
return amount * 1000;
}
return undefined;
}
function normalizeGhErrorParts(error: unknown): { message: string; stderr: string; stdout: string; exitCode: string | number | null } {
if (error && typeof error === "object") {
const withDetails = error as { message?: unknown; code?: unknown; stderr?: unknown; stdout?: unknown };
const rawMessage = withDetails.message;
return {
message: rawMessage && typeof rawMessage === "object" && rawMessage instanceof Error
? rawMessage.message
: String(rawMessage ?? error),
stderr: String(withDetails.stderr ?? ""),
stdout: String(withDetails.stdout ?? ""),
exitCode: (withDetails.code as string | number | null | undefined) ?? null,
};
}
return { message: String(error), stderr: "", stdout: "", exitCode: null };
}
export function classifyGhError(error: unknown): StructuredGhError {
const parts = normalizeGhErrorParts(error);
const haystack = `${parts.message}\n${parts.stderr}\n${parts.stdout}`.toLowerCase();
const baseCause = {
stderr: parts.stderr || undefined,
stdout: parts.stdout || undefined,
exitCode: parts.exitCode,
};
const withCause = (result: Omit<StructuredGhError, "cause">): StructuredGhError => ({ ...result, cause: baseCause });
if (haystack.includes("not logged into") || haystack.includes("authentication required") || /\b401\b/.test(haystack)) {
return withCause({
code: "not-authenticated",
message: "GitHub CLI is not authenticated. Run 'gh auth login' to authenticate.",
hint: "Run 'gh auth login' to authenticate with GitHub.",
action: { kind: "shell", command: "gh auth login" },
retryable: true,
});
}
if (haystack.includes("rate limit") || (/\b403\b/.test(haystack) && haystack.includes("rate"))) {
return withCause({
code: "rate-limited",
message: "GitHub API rate limit exceeded. Please try again later.",
retryable: true,
action: { kind: "retry" },
retryAfterMs: parseRetryAfterMs(`${parts.stderr}\n${parts.message}`),
});
}
if (haystack.includes("command not found") || haystack.includes("gh cli is not available") || haystack.includes("enoent") || parts.exitCode === "ENOENT") {
return withCause({
code: "not-installed",
message: "GitHub CLI (gh) is not installed. Install it from https://github.com/cli/cli#installation",
hint: "Install GitHub CLI from https://github.com/cli/cli#installation",
action: { kind: "open", url: "https://github.com/cli/cli#installation" },
retryable: false,
});
}
if (haystack.includes("etimedout") || haystack.includes("econnreset") || haystack.includes("enotfound") || haystack.includes("eai_again") || haystack.includes("getaddrinfo") || haystack.includes("network")) {
return withCause({
code: "network",
message: "Network error while talking to GitHub. Check connectivity and retry.",
action: { kind: "retry" },
retryable: true,
});
}
if (haystack.includes("timed out") || (parts.exitCode === "ABORT_ERR" && haystack.includes(`${DEFAULT_GH_TIMEOUT_MS}`))) {
return withCause({
code: "timeout",
message: parts.message,
action: { kind: "retry" },
retryable: true,
});
}
if (haystack.includes("merge conflict") || haystack.includes("not mergeable")) {
return withCause({
code: "merge-conflict",
message: "Pull request cannot be merged due to conflicts.",
retryable: false,
});
}
if (haystack.includes("validation failed") || /\b422\b/.test(haystack)) {
return withCause({
code: "validation",
message: "GitHub rejected the request due to validation errors.",
retryable: false,
});
}
if (haystack.includes("permission") || /\b403\b/.test(haystack)) {
return withCause({
code: "permission",
message: "GitHub denied access to this resource.",
retryable: false,
});
}
if (haystack.includes("not found") || /\b404\b/.test(haystack)) {
return withCause({
code: "not-found",
message: "Resource not found. Check that the repository, PR, or issue exists and you have access.",
retryable: false,
});
}
return withCause({
code: "unknown",
message: parts.message,
retryable: true,
action: { kind: "retry" },
});
}
/**
* Get a human-readable error message from a gh CLI error.
* Extracts the most relevant error information.
*/
export function getGhErrorMessage(error: unknown): string {
return classifyGhError(error).message;
}
/**
* Verify gh CLI is available and authenticated.
* Throws an error with helpful instructions if not.
*/
export function ensureGhAuth(): void {
if (!isGhAvailable()) {
throw new Error(
"GitHub CLI (gh) is not installed. " +
"Install it from https://github.com/cli/cli#installation"
);
}
if (!isGhAuthenticated()) {
throw new Error(
"GitHub CLI (gh) is not authenticated. " +
"Run 'gh auth login' to authenticate with GitHub."
);
}
}
/**
* Extract owner/repo from a GitHub remote URL.
* Used to determine the current repository context.
*/
export function parseRepoFromRemote(remoteUrl: string): { owner: string; repo: string } | null {
// HTTPS: https://github.com/owner/repo.git or https://github.com/owner/repo
const httpsMatch = remoteUrl.match(/github\.com\/([^/]+)\/([^/.]+)(?:\.git)?$/);
if (httpsMatch) {
return { owner: httpsMatch[1], repo: httpsMatch[2] };
}
// SSH: git@github.com:owner/repo.git or git@github.com:owner/repo
const sshMatch = remoteUrl.match(/github\.com:([^/]+)\/([^/.]+)(?:\.git)?$/);
if (sshMatch) {
return { owner: sshMatch[1], repo: sshMatch[2] };
}
return null;
}
/**
* Get the current repository context from git remote.
* Returns null if not in a git repository or no GitHub remote.
*/
export function getCurrentRepo(cwd?: string): { owner: string; repo: string } | null {
try {
const remoteUrl = execFileSync("git", ["remote", "get-url", "origin"], {
cwd,
encoding: "utf-8",
stdio: ["pipe", "pipe", "ignore"],
}).trim();
return parseRepoFromRemote(remoteUrl);
} catch {
return null;
}
}