feat(KB-052): refactor GitHub integration to use gh CLI
- Create gh-cli utility module with auth detection and command execution - Refactor GitHubClient to use gh CLI commands with REST API fallback - Refactor PR Monitor to use gh CLI for PR operations - Add listIssues() and getIssue() methods to GitHubClient - Remove in-app GitHubRateLimiter (gh CLI handles rate limiting) - Update all tests for gh CLI implementation - Update AGENTS.md and extension docs for gh CLI auth preference
This commit is contained in:
@@ -6,6 +6,10 @@
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"./gh-cli": {
|
||||
"import": "./dist/gh-cli.js",
|
||||
"types": "./dist/gh-cli.d.ts"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
|
||||
243
packages/core/src/gh-cli.test.ts
Normal file
243
packages/core/src/gh-cli.test.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
getGhErrorMessage,
|
||||
parseRepoFromRemote,
|
||||
} from "./gh-cli.js";
|
||||
|
||||
// Tests for pure functions (no child_process dependency)
|
||||
describe("getGhErrorMessage", () => {
|
||||
it("returns authentication error message for auth errors", () => {
|
||||
const error = new Error("not logged into any hosts");
|
||||
expect(getGhErrorMessage(error)).toContain("not authenticated");
|
||||
expect(getGhErrorMessage(error)).toContain("gh auth login");
|
||||
});
|
||||
|
||||
it("returns not found message for 404 errors", () => {
|
||||
const error = new Error("404 Not Found");
|
||||
expect(getGhErrorMessage(error)).toContain("not found");
|
||||
});
|
||||
|
||||
it("returns rate limit message for rate limit errors", () => {
|
||||
const error = new Error("API rate limit exceeded 403");
|
||||
expect(getGhErrorMessage(error)).toContain("rate limit");
|
||||
});
|
||||
|
||||
it("returns generic message for unknown errors", () => {
|
||||
const error = new Error("something went wrong");
|
||||
expect(getGhErrorMessage(error)).toBe("something went wrong");
|
||||
});
|
||||
|
||||
it("handles non-Error values", () => {
|
||||
expect(getGhErrorMessage("string error")).toBe("string error");
|
||||
expect(getGhErrorMessage(123)).toBe("123");
|
||||
expect(getGhErrorMessage(null)).toBe("null");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseRepoFromRemote", () => {
|
||||
it("parses HTTPS remote URLs", () => {
|
||||
expect(parseRepoFromRemote("https://github.com/owner/repo.git")).toEqual({
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
});
|
||||
expect(parseRepoFromRemote("https://github.com/owner/repo")).toEqual({
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses SSH remote URLs", () => {
|
||||
expect(parseRepoFromRemote("git@github.com:owner/repo.git")).toEqual({
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
});
|
||||
expect(parseRepoFromRemote("git@github.com:owner/repo")).toEqual({
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for non-GitHub URLs", () => {
|
||||
expect(parseRepoFromRemote("https://gitlab.com/owner/repo.git")).toBeNull();
|
||||
expect(parseRepoFromRemote("https://bitbucket.org/owner/repo.git")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for invalid URLs", () => {
|
||||
expect(parseRepoFromRemote("not-a-url")).toBeNull();
|
||||
expect(parseRepoFromRemote("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// Tests for functions that depend on child_process - using inline implementations
|
||||
describe("gh-cli functions (inline tests)", () => {
|
||||
// Inline implementation of getCurrentRepo logic for testing
|
||||
function getCurrentRepoLogic(
|
||||
execFileSyncFn: (cmd: string, args: string[], opts: unknown) => string | Buffer,
|
||||
cwd?: string
|
||||
) {
|
||||
try {
|
||||
const remoteUrl = execFileSyncFn("git", ["remote", "get-url", "origin"], {
|
||||
cwd,
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "ignore"],
|
||||
}).toString().trim();
|
||||
|
||||
return parseRepoFromRemote(remoteUrl);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
describe("getCurrentRepo logic", () => {
|
||||
it("returns owner/repo from git remote", () => {
|
||||
const mockExec = vi.fn().mockReturnValue("https://github.com/myorg/myrepo.git\n");
|
||||
const result = getCurrentRepoLogic(mockExec, "/repo/path");
|
||||
|
||||
expect(result).toEqual({ owner: "myorg", repo: "myrepo" });
|
||||
expect(mockExec).toHaveBeenCalledWith(
|
||||
"git",
|
||||
["remote", "get-url", "origin"],
|
||||
expect.objectContaining({ cwd: "/repo/path" })
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when git command fails", () => {
|
||||
const mockExec = vi.fn().mockImplementation(() => {
|
||||
throw new Error("not a git repository");
|
||||
});
|
||||
expect(getCurrentRepoLogic(mockExec)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when remote is not a GitHub URL", () => {
|
||||
const mockExec = vi.fn().mockReturnValue("https://gitlab.com/owner/repo.git\n");
|
||||
expect(getCurrentRepoLogic(mockExec)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// Inline implementation of isGhAvailable logic for testing
|
||||
function isGhAvailableLogic(execFileSyncFn: (cmd: string, args: string[], opts: unknown) => string | Buffer) {
|
||||
try {
|
||||
execFileSyncFn("gh", ["--version"], {
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "ignore"],
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
describe("isGhAvailable logic", () => {
|
||||
it("returns true when gh --version succeeds", () => {
|
||||
const mockExec = vi.fn().mockReturnValue("gh version 2.40.0");
|
||||
expect(isGhAvailableLogic(mockExec)).toBe(true);
|
||||
expect(mockExec).toHaveBeenCalledWith("gh", ["--version"], expect.any(Object));
|
||||
});
|
||||
|
||||
it("returns false when gh --version throws", () => {
|
||||
const mockExec = vi.fn().mockImplementation(() => {
|
||||
throw new Error("command not found: gh");
|
||||
});
|
||||
expect(isGhAvailableLogic(mockExec)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Inline implementation of isGhAuthenticated logic for testing
|
||||
function isGhAuthenticatedLogic(execFileSyncFn: (cmd: string, args: string[], opts: unknown) => string | Buffer) {
|
||||
try {
|
||||
const result = execFileSyncFn("gh", ["auth", "status"], {
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "ignore"],
|
||||
});
|
||||
return result.includes("Logged in") || result.includes("Authenticated");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
describe("isGhAuthenticated logic", () => {
|
||||
it("returns true when gh auth status shows logged in", () => {
|
||||
const mockExec = vi.fn().mockReturnValue("Logged in to github.com as user");
|
||||
expect(isGhAuthenticatedLogic(mockExec)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when gh auth status shows Authenticated", () => {
|
||||
const mockExec = vi.fn().mockReturnValue("✓ Authenticated with github.com");
|
||||
expect(isGhAuthenticatedLogic(mockExec)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when gh auth status throws", () => {
|
||||
const mockExec = vi.fn().mockImplementation(() => {
|
||||
throw new Error("not logged in");
|
||||
});
|
||||
expect(isGhAuthenticatedLogic(mockExec)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Inline implementation of runGh logic for testing
|
||||
interface GhError extends Error {
|
||||
code: number | null;
|
||||
stderr: string;
|
||||
stdout: string;
|
||||
}
|
||||
|
||||
function runGhLogic(
|
||||
execFileSyncFn: (cmd: string, args: string[], opts: unknown) => string | Buffer,
|
||||
args: string[],
|
||||
cwd?: string
|
||||
): string {
|
||||
try {
|
||||
const result = execFileSyncFn("gh", args, {
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
cwd,
|
||||
});
|
||||
return result.toString();
|
||||
} catch (err: unknown) {
|
||||
const execErr = err as Error & { code?: number | null; stdout?: string; stderr?: string };
|
||||
const error = new Error(`gh command failed: ${execErr.message}`) as GhError;
|
||||
error.code = execErr.code ?? null;
|
||||
error.stdout = execErr.stdout ?? "";
|
||||
error.stderr = execErr.stderr ?? "";
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
describe("runGh logic", () => {
|
||||
it("executes gh command with args and returns output", () => {
|
||||
const mockExec = vi.fn().mockReturnValue("command output\n");
|
||||
const result = runGhLogic(mockExec, ["pr", "list"]);
|
||||
expect(result).toBe("command output\n");
|
||||
expect(mockExec).toHaveBeenCalledWith("gh", ["pr", "list"], expect.any(Object));
|
||||
});
|
||||
|
||||
it("passes cwd option", () => {
|
||||
const mockExec = vi.fn().mockReturnValue("output");
|
||||
runGhLogic(mockExec, ["pr", "list"], "/some/path");
|
||||
expect(mockExec).toHaveBeenCalledWith("gh", ["pr", "list"], expect.objectContaining({
|
||||
cwd: "/some/path",
|
||||
}));
|
||||
});
|
||||
|
||||
it("throws GhError on command failure", () => {
|
||||
const execErr = new Error("command failed") as Error & { code: number; stdout: string; stderr: string };
|
||||
execErr.code = 1;
|
||||
execErr.stdout = "";
|
||||
execErr.stderr = "error message";
|
||||
|
||||
const mockExec = vi.fn().mockImplementation(() => {
|
||||
throw execErr;
|
||||
});
|
||||
|
||||
try {
|
||||
runGhLogic(mockExec, ["pr", "view", "999"]);
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
const ghErr = err as GhError;
|
||||
expect(ghErr.message).toContain("gh command failed");
|
||||
expect(ghErr.code).toBe(1);
|
||||
expect(ghErr.stderr).toBe("error message");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
206
packages/core/src/gh-cli.ts
Normal file
206
packages/core/src/gh-cli.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the `gh` CLI is installed and available.
|
||||
*/
|
||||
export function isGhAvailable(): boolean {
|
||||
try {
|
||||
execFileSync("gh", ["--version"], {
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "ignore"],
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the `gh` CLI is authenticated with GitHub.
|
||||
* Returns true if authenticated, false if not.
|
||||
*/
|
||||
export function isGhAuthenticated(): boolean {
|
||||
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
|
||||
return result.includes("Logged in") || result.includes("Authenticated");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function runGhAsync(args: string[], cwd?: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
"gh",
|
||||
args,
|
||||
{
|
||||
encoding: "utf-8",
|
||||
cwd,
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
const ghError = new Error(`gh command failed: ${error.message}`) as GhError;
|
||||
ghError.code = error.code ?? null;
|
||||
ghError.stdout = stdout ?? "";
|
||||
ghError.stderr = stderr ?? "";
|
||||
reject(ghError);
|
||||
} else {
|
||||
resolve(stdout ?? "");
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a gh CLI command and parse the JSON output.
|
||||
* Requires the command to support --json flag.
|
||||
*/
|
||||
export function runGhJson<T>(args: string[], cwd?: string): T {
|
||||
const jsonArgs = args.includes("--json") ? args : [...args, "--json"];
|
||||
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.
|
||||
* Requires the command to support --json flag.
|
||||
*/
|
||||
export async function runGhJsonAsync<T>(args: string[], cwd?: string): Promise<T> {
|
||||
const jsonArgs = args.includes("--json") ? args : [...args, "--json"];
|
||||
const output = await runGhAsync(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)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-readable error message from a gh CLI error.
|
||||
* Extracts the most relevant error information.
|
||||
*/
|
||||
export function getGhErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
// Check for common gh CLI error patterns
|
||||
const message = error.message;
|
||||
|
||||
// Authentication errors
|
||||
if (message.includes("not logged into") || message.includes("authentication required")) {
|
||||
return "GitHub CLI is not authenticated. Run 'gh auth login' to authenticate.";
|
||||
}
|
||||
|
||||
// Not found errors
|
||||
if (message.includes("not found") || message.includes("404")) {
|
||||
return "Resource not found. Check that the repository, PR, or issue exists and you have access.";
|
||||
}
|
||||
|
||||
// Rate limit errors
|
||||
if (message.includes("rate limit") || message.includes("403")) {
|
||||
return "GitHub API rate limit exceeded. Please try again later.";
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
return String(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -2,3 +2,16 @@ export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT
|
||||
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeResult, Settings, TaskStep, StepStatus, TaskLogEntry, ThinkingLevel, SteeringComment, ThemeMode, ColorTheme } from "./types.js";
|
||||
export { TaskStore } from "./store.js";
|
||||
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";
|
||||
export {
|
||||
isGhAvailable,
|
||||
isGhAuthenticated,
|
||||
runGh,
|
||||
runGhAsync,
|
||||
runGhJson,
|
||||
runGhJsonAsync,
|
||||
getGhErrorMessage,
|
||||
ensureGhAuth,
|
||||
parseRepoFromRemote,
|
||||
getCurrentRepo,
|
||||
type GhError,
|
||||
} from "./gh-cli.js";
|
||||
|
||||
Reference in New Issue
Block a user