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:
gsxdsm
2026-03-29 20:52:07 -07:00
parent 919af827e0
commit 5794c301b2
13 changed files with 1718 additions and 256 deletions

View File

@@ -1,9 +1,17 @@
import { execFileSync } from "node:child_process";
import type { PrInfo } from "@kb/core";
import {
isGhAvailable,
isGhAuthenticated,
runGhJson,
runGhJsonAsync,
getGhErrorMessage,
getCurrentRepo,
runGh,
} from "@kb/core";
export interface CreatePrParams {
owner: string;
repo: string;
owner?: string;
repo?: string;
title: string;
body?: string;
head: string;
@@ -19,10 +27,40 @@ export interface PrComment {
html_url: string;
}
// gh CLI JSON output types
interface GhPrViewJson {
number: number;
url: string;
title: string;
state: "OPEN" | "CLOSED" | "MERGED";
baseRefName: string;
headRefName: string;
comments: Array<{
id: string;
body: string;
author: { login: string };
createdAt: string;
updatedAt: string;
url: string;
}>;
}
interface GhIssueViewJson {
number: number;
url: string;
title: string;
state: "OPEN" | "CLOSED";
stateReason?: "completed" | "not_planned" | "reopened";
}
export class GitHubClient {
private token: string | undefined;
private baseUrl = "https://api.github.com";
/**
* Create a GitHub client.
* @param token Optional GitHub token for REST API fallback when gh CLI is unavailable
*/
constructor(token?: string) {
this.token = token;
}
@@ -33,16 +71,45 @@ export class GitHubClient {
*/
async createPr(params: CreatePrParams): Promise<PrInfo> {
// Try gh CLI first (preferred for auth handling)
try {
return this.createPrWithGh(params);
} catch {
// Fall back to REST API
if (isGhAvailable() && isGhAuthenticated()) {
try {
return this.createPrWithGh(params);
} catch (err) {
// If gh CLI fails and we have a token, fall back to REST API
if (this.token) {
return this.createPrWithApi(params);
}
throw new Error(getGhErrorMessage(err));
}
}
// Fall back to REST API
if (this.token) {
return this.createPrWithApi(params);
}
throw new Error("GitHub CLI (gh) is not available or not authenticated, and no GITHUB_TOKEN provided. Run 'gh auth login' or set GITHUB_TOKEN.");
}
private createPrWithGh(params: CreatePrParams): PrInfo {
const { owner, repo, title, body, head, base } = params;
const { owner: paramOwner, repo: paramRepo, title, body, head, base } = params;
// Get owner/repo from params or current repo context
let owner = paramOwner;
let repo = paramRepo;
if (!owner || !repo) {
const currentRepo = getCurrentRepo();
if (!currentRepo) {
throw new Error("Could not determine repository. Specify owner/repo in params or run from a git repository with a GitHub remote.");
}
owner = currentRepo.owner;
repo = currentRepo.repo;
}
// Type guard: owner and repo are now guaranteed to be strings
if (!owner || !repo) {
throw new Error("Could not determine repository.");
}
// Build gh pr create command arguments (as array for safety)
const args = [
@@ -59,11 +126,8 @@ export class GitHubClient {
args.push("--base", base);
}
// Execute gh command using execFileSync for proper argument handling
const result = execFileSync("gh", args, {
encoding: "utf-8",
stdio: ["pipe", "pipe", "ignore"],
});
// Use gh-cli module to execute
const result = runGh(args);
// Extract PR URL from output (gh outputs the PR URL on success)
const prUrl = result.trim();
@@ -86,7 +150,25 @@ export class GitHubClient {
}
private async createPrWithApi(params: CreatePrParams): Promise<PrInfo> {
const { owner, repo, title, body, head, base = "main" } = params;
const { owner: paramOwner, repo: paramRepo, title, body, head, base = "main" } = params;
// Get owner/repo from params or current repo context
let owner = paramOwner;
let repo = paramRepo;
if (!owner || !repo) {
const currentRepo = getCurrentRepo();
if (!currentRepo) {
throw new Error("Could not determine repository. Specify owner/repo in params or run from a git repository with a GitHub remote.");
}
owner = currentRepo.owner;
repo = currentRepo.repo;
}
// Type guard: owner and repo are now guaranteed to be strings
if (!owner || !repo) {
throw new Error("Could not determine repository.");
}
const url = `${this.baseUrl}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls`;
@@ -130,9 +212,45 @@ export class GitHubClient {
}
/**
* Fetch current PR status from GitHub API.
* Fetch current PR status using gh CLI if available, otherwise REST API.
*/
async getPrStatus(owner: string, repo: string, number: number): Promise<PrInfo> {
if (isGhAvailable() && isGhAuthenticated()) {
try {
return await this.getPrStatusWithGh(owner, repo, number);
} catch (err) {
if (this.token) {
return this.getPrStatusWithApi(owner, repo, number);
}
throw new Error(getGhErrorMessage(err));
}
}
if (this.token) {
return this.getPrStatusWithApi(owner, repo, number);
}
throw new Error("GitHub CLI (gh) is not available or not authenticated, and no GITHUB_TOKEN provided.");
}
private async getPrStatusWithGh(owner: string, repo: string, number: number): Promise<PrInfo> {
const pr = await runGhJsonAsync<GhPrViewJson>([
"pr", "view", String(number),
"--repo", `${owner}/${repo}`,
"--json", "number,url,title,state,baseRefName,headRefName",
]);
return {
url: pr.url,
number: pr.number,
status: this.mapGhPrState(pr.state),
title: pr.title,
headBranch: pr.headRefName,
baseBranch: pr.baseRefName,
commentCount: 0, // Would need separate API call for comment count
};
}
private async getPrStatusWithApi(owner: string, repo: string, number: number): Promise<PrInfo> {
const url = `${this.baseUrl}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${number}`;
const headers = this.buildHeaders();
@@ -172,13 +290,66 @@ export class GitHubClient {
}
/**
* List PR comments since a specific timestamp.
* List PR comments using gh CLI if available, otherwise REST API.
*/
async listPrComments(
owner: string,
repo: string,
number: number,
since?: string,
): Promise<PrComment[]> {
if (isGhAvailable() && isGhAuthenticated()) {
try {
return await this.listPrCommentsWithGh(owner, repo, number, since);
} catch (err) {
if (this.token) {
return this.listPrCommentsWithApi(owner, repo, number, since);
}
throw new Error(getGhErrorMessage(err));
}
}
if (this.token) {
return this.listPrCommentsWithApi(owner, repo, number, since);
}
throw new Error("GitHub CLI (gh) is not available or not authenticated, and no GITHUB_TOKEN provided.");
}
private async listPrCommentsWithGh(
owner: string,
repo: string,
number: number,
since?: string,
): Promise<PrComment[]> {
const pr = await runGhJsonAsync<GhPrViewJson>([
"pr", "view", String(number),
"--repo", `${owner}/${repo}`,
"--json", "comments",
]);
let comments = pr.comments.map((c: GhPrViewJson["comments"][number]) => ({
id: parseInt(c.id, 10),
body: c.body,
user: { login: c.author.login },
created_at: c.createdAt,
updated_at: c.updatedAt,
html_url: c.url,
}));
// Filter by timestamp if since is provided
if (since) {
const sinceDate = new Date(since);
comments = comments.filter((c: PrComment) => new Date(c.created_at) > sinceDate);
}
return comments;
}
private async listPrCommentsWithApi(
owner: string,
repo: string,
number: number,
since?: string,
): Promise<PrComment[]> {
const params = new URLSearchParams();
params.append("per_page", "100");
@@ -203,32 +374,65 @@ export class GitHubClient {
return response.json() as Promise<PrComment[]>;
}
private buildHeaders(): Record<string, string> {
const headers: Record<string, string> = {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "kb-dashboard/1.0",
};
if (this.token) {
headers.Authorization = `Bearer ${this.token}`;
}
return headers;
}
private mapPrState(state: string): "open" | "closed" {
return state === "open" ? "open" : "closed";
}
/**
* Fetch current issue status from GitHub API.
* Fetch current issue status using gh CLI if available, otherwise REST API.
* Returns null if the issue is not found or is a pull request.
*/
async getIssueStatus(
owner: string,
repo: string,
number: number,
): Promise<Omit<import("@kb/core").IssueInfo, "lastCheckedAt"> | null> {
if (isGhAvailable() && isGhAuthenticated()) {
try {
return await this.getIssueStatusWithGh(owner, repo, number);
} catch (err) {
if (this.token) {
return this.getIssueStatusWithApi(owner, repo, number);
}
throw new Error(getGhErrorMessage(err));
}
}
if (this.token) {
return this.getIssueStatusWithApi(owner, repo, number);
}
throw new Error("GitHub CLI (gh) is not available or not authenticated, and no GITHUB_TOKEN provided.");
}
private async getIssueStatusWithGh(
owner: string,
repo: string,
number: number,
): Promise<Omit<import("@kb/core").IssueInfo, "lastCheckedAt"> | null> {
try {
const issue = await runGhJsonAsync<GhIssueViewJson>([
"issue", "view", String(number),
"--repo", `${owner}/${repo}`,
"--json", "number,url,title,state,stateReason",
]);
return {
url: issue.url,
number: issue.number,
state: this.mapGhIssueState(issue.state),
title: issue.title,
stateReason: issue.stateReason,
};
} catch (err) {
// gh issue view returns error if the issue is actually a PR
// or if the issue doesn't exist
if (err instanceof Error && err.message.includes("Could not resolve to an issue")) {
return null;
}
throw err;
}
}
private async getIssueStatusWithApi(
owner: string,
repo: string,
number: number,
): Promise<Omit<import("@kb/core").IssueInfo, "lastCheckedAt"> | null> {
const url = `${this.baseUrl}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${number}`;
@@ -267,13 +471,303 @@ export class GitHubClient {
};
}
private buildHeaders(): Record<string, string> {
const headers: Record<string, string> = {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "kb-dashboard/1.0",
};
if (this.token) {
headers.Authorization = `Bearer ${this.token}`;
}
return headers;
}
private mapPrState(state: string): "open" | "closed" {
return state === "open" ? "open" : "closed";
}
private mapGhPrState(state: "OPEN" | "CLOSED" | "MERGED"): "open" | "closed" | "merged" {
switch (state) {
case "OPEN":
return "open";
case "CLOSED":
return "closed";
case "MERGED":
return "merged";
default:
return "closed";
}
}
private mapIssueState(state: string): "open" | "closed" {
return state === "open" ? "open" : "closed";
}
private mapGhIssueState(state: "OPEN" | "CLOSED"): "open" | "closed" {
return state === "OPEN" ? "open" : "closed";
}
/**
* List open issues from a repository.
* Uses gh CLI if available, otherwise falls back to REST API.
*/
async listIssues(
owner: string,
repo: string,
options?: { limit?: number; labels?: string[] }
): Promise<Array<{
number: number;
title: string;
body: string | null;
html_url: string;
labels: Array<{ name: string }>;
}>> {
if (isGhAvailable() && isGhAuthenticated()) {
try {
return await this.listIssuesWithGh(owner, repo, options);
} catch (err) {
if (this.token) {
return this.listIssuesWithApi(owner, repo, options);
}
throw new Error(getGhErrorMessage(err));
}
}
if (this.token) {
return this.listIssuesWithApi(owner, repo, options);
}
throw new Error("GitHub CLI (gh) is not available or not authenticated, and no GITHUB_TOKEN provided. Run 'gh auth login' to authenticate.");
}
private async listIssuesWithGh(
owner: string,
repo: string,
options?: { limit?: number; labels?: string[] }
): Promise<Array<{
number: number;
title: string;
body: string | null;
html_url: string;
labels: Array<{ name: string }>;
}>> {
const limit = options?.limit ?? 30;
// gh issue list doesn't support label filtering directly, so we fetch and filter client-side
const issues = await runGhJsonAsync<Array<{
number: number;
title: string;
body: string;
url: string;
labels: Array<{ name: string }>;
}>>([
"issue", "list",
"--repo", `${owner}/${repo}`,
"--state", "open",
"--limit", String(Math.min(limit, 100)),
"--json", "number,title,body,url,labels",
]);
let result = issues.map((issue) => ({
number: issue.number,
title: issue.title,
body: issue.body,
html_url: issue.url,
labels: issue.labels,
}));
// Filter by labels if specified (client-side filtering)
if (options?.labels && options.labels.length > 0) {
result = result.filter((issue) =>
options.labels!.some((label) =>
issue.labels.some((l) => l.name === label)
)
);
}
return result.slice(0, limit);
}
private async listIssuesWithApi(
owner: string,
repo: string,
options?: { limit?: number; labels?: string[] }
): Promise<Array<{
number: number;
title: string;
body: string | null;
html_url: string;
labels: Array<{ name: string }>;
}>> {
const limit = options?.limit ?? 30;
const params = new URLSearchParams();
params.append("state", "open");
params.append("per_page", String(Math.min(limit, 100)));
if (options?.labels && options.labels.length > 0) {
params.append("labels", options.labels.join(","));
}
const url = `${this.baseUrl}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?${params}`;
const headers = this.buildHeaders();
const response = await fetch(url, { headers });
if (!response.ok) {
if (response.status === 404) {
throw new Error(`Repository not found: ${owner}/${repo}`);
}
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
}
const data = (await response.json()) as Array<{
number: number;
title: string;
body: string | null;
html_url: string;
labels: Array<{ name: string }>;
pull_request?: unknown;
}>;
// Filter out pull requests (they have a pull_request property)
return data.filter((issue) => !issue.pull_request).slice(0, limit);
}
/**
* Fetch a single issue by number.
* Uses gh CLI if available, otherwise falls back to REST API.
* Returns null if the issue is not found or is a pull request.
*/
async getIssue(
owner: string,
repo: string,
number: number,
): Promise<{
number: number;
title: string;
body: string | null;
html_url: string;
state: "open" | "closed";
stateReason?: "completed" | "not_planned" | "reopened";
} | null> {
if (isGhAvailable() && isGhAuthenticated()) {
try {
return await this.getIssueWithGh(owner, repo, number);
} catch (err) {
if (this.token) {
return this.getIssueWithApi(owner, repo, number);
}
throw new Error(getGhErrorMessage(err));
}
}
if (this.token) {
return this.getIssueWithApi(owner, repo, number);
}
throw new Error("GitHub CLI (gh) is not available or not authenticated, and no GITHUB_TOKEN provided. Run 'gh auth login' to authenticate.");
}
private async getIssueWithGh(
owner: string,
repo: string,
number: number,
): Promise<{
number: number;
title: string;
body: string | null;
html_url: string;
state: "open" | "closed";
stateReason?: "completed" | "not_planned" | "reopened";
} | null> {
try {
const issue = await runGhJsonAsync<{
number: number;
title: string;
body: string;
url: string;
state: "OPEN" | "CLOSED";
stateReason?: "completed" | "not_planned" | "reopened";
}>([
"issue", "view", String(number),
"--repo", `${owner}/${repo}`,
"--json", "number,title,body,url,state,stateReason",
]);
return {
number: issue.number,
title: issue.title,
body: issue.body,
html_url: issue.url,
state: this.mapGhIssueState(issue.state),
stateReason: issue.stateReason,
};
} catch (err) {
// gh issue view returns error if the issue is actually a PR
// or if the issue doesn't exist
if (err instanceof Error &&
(err.message.includes("Could not resolve to an issue") ||
err.message.includes("not found"))) {
return null;
}
throw err;
}
}
private async getIssueWithApi(
owner: string,
repo: string,
number: number,
): Promise<{
number: number;
title: string;
body: string | null;
html_url: string;
state: "open" | "closed";
stateReason?: "completed" | "not_planned" | "reopened";
} | null> {
const url = `${this.baseUrl}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${number}`;
const headers = this.buildHeaders();
const response = await fetch(url, { headers });
if (!response.ok) {
if (response.status === 404) {
return null;
}
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
}
const data = (await response.json()) as {
number: number;
title: string;
body: string | null;
html_url: string;
state: string;
state_reason?: "completed" | "not_planned" | "reopened";
pull_request?: unknown;
};
// Filter out pull requests - this endpoint returns both issues and PRs
if (data.pull_request) {
return null;
}
return {
html_url: data.html_url,
number: data.number,
title: data.title,
body: data.body,
state: this.mapIssueState(data.state),
stateReason: data.state_reason,
};
}
}
/**
* Extract owner/repo from a GitHub remote URL or return null if not a GitHub remote.
* @deprecated Use parseRepoFromRemote from gh-cli.ts instead
*/
export function parseGitHubRemote(remoteUrl: string): { owner: string; repo: string } | null {
// Handle HTTPS: https://github.com/owner/repo.git or https://github.com/owner/repo
@@ -293,8 +787,10 @@ export function parseGitHubRemote(remoteUrl: string): { owner: string; repo: str
/**
* Get the current GitHub remote owner/repo from the git config.
* @deprecated Use getCurrentRepo from gh-cli.ts instead
*/
export function getCurrentGitHubRepo(cwd: string): { owner: string; repo: string } | null {
const { execFileSync } = require("node:child_process");
try {
const remoteUrl = execFileSync("git", ["remote", "get-url", "origin"], {
cwd,