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 6f43bdcdf4
commit 0e26d1df30
13 changed files with 1718 additions and 256 deletions

View File

@@ -0,0 +1,576 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { GitHubClient, CreatePrParams, PrComment } from "./github.js";
// Mock the gh-cli module from @kb/core
vi.mock("@kb/core", async () => {
const actual = await vi.importActual<typeof import("@kb/core")>("@kb/core");
return {
...actual,
isGhAvailable: vi.fn(),
isGhAuthenticated: vi.fn(),
runGh: vi.fn(),
runGhAsync: vi.fn(),
runGhJson: vi.fn(),
runGhJsonAsync: vi.fn(),
getGhErrorMessage: vi.fn((err) => err instanceof Error ? err.message : String(err)),
getCurrentRepo: vi.fn(),
};
});
import {
isGhAvailable,
isGhAuthenticated,
runGh,
runGhAsync,
runGhJson,
runGhJsonAsync,
getCurrentRepo,
} from "@kb/core";
const mockIsGhAvailable = vi.mocked(isGhAvailable);
const mockIsGhAuthenticated = vi.mocked(isGhAuthenticated);
const mockRunGh = vi.mocked(runGh);
const mockRunGhAsync = vi.mocked(runGhAsync);
const mockRunGhJson = vi.mocked(runGhJson);
const mockRunGhJsonAsync = vi.mocked(runGhJsonAsync);
const mockGetCurrentRepo = vi.mocked(getCurrentRepo);
describe("GitHubClient", () => {
let client: GitHubClient;
beforeEach(() => {
vi.clearAllMocks();
mockIsGhAvailable.mockReturnValue(true);
mockIsGhAuthenticated.mockReturnValue(true);
// Create client after mocks are set up
client = new GitHubClient();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("constructor", () => {
it("can be created without token (gh CLI auth preferred)", () => {
expect(() => new GitHubClient()).not.toThrow();
});
it("can be created with token for REST API fallback", () => {
expect(() => new GitHubClient("ghp_token123")).not.toThrow();
});
});
describe("createPr", () => {
const mockPrParams: CreatePrParams = {
owner: "test-owner",
repo: "test-repo",
title: "Test PR",
body: "Test body",
head: "feature-branch",
base: "main",
};
it("creates PR using gh CLI when available", async () => {
mockRunGh.mockReturnValue("https://github.com/test-owner/test-repo/pull/42\n");
const result = await client.createPr(mockPrParams);
expect(mockRunGh).toHaveBeenCalledWith([
"pr", "create",
"--repo", "test-owner/test-repo",
"--title", "Test PR",
"--head", "feature-branch",
"--body", "Test body",
"--base", "main",
]);
expect(result.number).toBe(42);
expect(result.url).toBe("https://github.com/test-owner/test-repo/pull/42");
expect(result.status).toBe("open");
});
it("creates PR without body when not provided", async () => {
mockRunGh.mockReturnValue("https://github.com/test-owner/test-repo/pull/42\n");
const paramsWithoutBody: CreatePrParams = {
owner: "test-owner",
repo: "test-repo",
title: "Test PR",
head: "feature-branch",
// body and base not provided
};
await client.createPr(paramsWithoutBody);
expect(mockRunGh).toHaveBeenCalledWith([
"pr", "create",
"--repo", "test-owner/test-repo",
"--title", "Test PR",
"--head", "feature-branch",
]);
// Should not include --body or --base when not provided
const callArgs = mockRunGh.mock.calls[0][0];
expect(callArgs).not.toContain("--body");
expect(callArgs).not.toContain("--base");
});
it("uses current repo context when owner/repo not specified", async () => {
mockGetCurrentRepo.mockReturnValue({ owner: "current-owner", repo: "current-repo" });
mockRunGh.mockReturnValue("https://github.com/current-owner/current-repo/pull/5\n");
const paramsWithoutRepo = {
title: "Test PR",
head: "feature-branch",
};
const result = await client.createPr(paramsWithoutRepo);
expect(mockGetCurrentRepo).toHaveBeenCalled();
expect(mockRunGh).toHaveBeenCalledWith([
"pr", "create",
"--repo", "current-owner/current-repo",
"--title", "Test PR",
"--head", "feature-branch",
]);
expect(result.number).toBe(5);
});
it("throws error when repo cannot be determined", async () => {
mockGetCurrentRepo.mockReturnValue(null);
const paramsWithoutRepo = {
title: "Test PR",
head: "feature-branch",
};
await expect(client.createPr(paramsWithoutRepo)).rejects.toThrow("Could not determine repository");
});
it("falls back to REST API when gh CLI fails and token is available", async () => {
mockRunGh.mockImplementation(() => {
throw new Error("gh command failed");
});
// Create client with token for fallback
const clientWithToken = new GitHubClient("ghp_fallback_token");
// Mock global fetch for REST API fallback
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({
number: 42,
html_url: "https://github.com/test-owner/test-repo/pull/42",
title: "Test PR",
state: "open",
head: { ref: "feature-branch" },
base: { ref: "main" },
comments: 0,
}),
});
global.fetch = mockFetch as any;
const result = await clientWithToken.createPr(mockPrParams);
expect(mockFetch).toHaveBeenCalled();
expect(result.number).toBe(42);
// Restore fetch
vi.restoreAllMocks();
});
it("throws error when gh CLI fails and no token available", async () => {
mockRunGh.mockImplementation(() => {
throw new Error("gh command failed: not authenticated");
});
await expect(client.createPr(mockPrParams)).rejects.toThrow();
});
});
describe("getPrStatus", () => {
it("fetches PR status using gh CLI", async () => {
mockRunGhJsonAsync.mockResolvedValue({
number: 42,
url: "https://github.com/owner/repo/pull/42",
title: "Test PR",
state: "OPEN",
baseRefName: "main",
headRefName: "feature-branch",
});
const result = await client.getPrStatus("owner", "repo", 42);
expect(mockRunGhJsonAsync).toHaveBeenCalledWith([
"pr", "view", "42",
"--repo", "owner/repo",
"--json", "number,url,title,state,baseRefName,headRefName",
]);
expect(result.number).toBe(42);
expect(result.status).toBe("open");
expect(result.title).toBe("Test PR");
});
it("maps gh CLI states correctly", async () => {
const states = [
{ input: "OPEN", expected: "open" },
{ input: "CLOSED", expected: "closed" },
{ input: "MERGED", expected: "merged" },
];
for (const { input, expected } of states) {
vi.clearAllMocks();
mockRunGhJsonAsync.mockResolvedValue({
number: 42,
url: "https://github.com/owner/repo/pull/42",
title: "Test PR",
state: input,
baseRefName: "main",
headRefName: "feature-branch",
});
const result = await client.getPrStatus("owner", "repo", 42);
expect(result.status).toBe(expected);
}
});
it("falls back to REST API when gh CLI fails and token is available", async () => {
mockRunGhJsonAsync.mockRejectedValue(new Error("gh failed"));
const clientWithToken = new GitHubClient("ghp_token");
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({
number: 42,
html_url: "https://github.com/owner/repo/pull/42",
title: "Test PR",
state: "open",
merged: false,
head: { ref: "feature-branch" },
base: { ref: "main" },
comments: 5,
updated_at: "2024-01-01T00:00:00Z",
}),
});
global.fetch = mockFetch as any;
const result = await clientWithToken.getPrStatus("owner", "repo", 42);
expect(mockFetch).toHaveBeenCalled();
expect(result.number).toBe(42);
vi.restoreAllMocks();
});
});
describe("listPrComments", () => {
const mockComments = [
{
id: "100",
body: "First comment",
author: { login: "user1" },
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
url: "https://github.com/owner/repo/pull/42#issuecomment-100",
},
{
id: "200",
body: "Second comment",
author: { login: "user2" },
createdAt: "2024-01-02T00:00:00Z",
updatedAt: "2024-01-02T00:00:00Z",
url: "https://github.com/owner/repo/pull/42#issuecomment-200",
},
];
it("fetches PR comments using gh CLI", async () => {
mockRunGhJsonAsync.mockResolvedValue({ comments: mockComments });
const result = await client.listPrComments("owner", "repo", 42);
expect(mockRunGhJsonAsync).toHaveBeenCalledWith([
"pr", "view", "42",
"--repo", "owner/repo",
"--json", "comments",
]);
expect(result).toHaveLength(2);
expect(result[0].id).toBe(100);
expect(result[0].body).toBe("First comment");
expect(result[0].user.login).toBe("user1");
});
it("filters comments by timestamp when since is provided", async () => {
mockRunGhJsonAsync.mockResolvedValue({ comments: mockComments });
const result = await client.listPrComments("owner", "repo", 42, "2024-01-01T12:00:00Z");
expect(result).toHaveLength(1);
expect(result[0].id).toBe(200);
});
it("returns empty array when no comments", async () => {
mockRunGhJsonAsync.mockResolvedValue({ comments: [] });
const result = await client.listPrComments("owner", "repo", 42);
expect(result).toEqual([]);
});
it("falls back to REST API when gh CLI fails and token is available", async () => {
mockRunGhJsonAsync.mockRejectedValue(new Error("gh failed"));
const clientWithToken = new GitHubClient("ghp_token");
const apiComments: PrComment[] = [
{
id: 100,
body: "API comment",
user: { login: "user1" },
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
html_url: "https://github.com/owner/repo/pull/42#issuecomment-100",
},
];
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(apiComments),
});
global.fetch = mockFetch as any;
const result = await clientWithToken.listPrComments("owner", "repo", 42);
expect(mockFetch).toHaveBeenCalled();
expect(result).toHaveLength(1);
vi.restoreAllMocks();
});
});
describe("getIssueStatus", () => {
it("fetches issue status using gh CLI", async () => {
mockRunGhJsonAsync.mockResolvedValue({
number: 1,
url: "https://github.com/owner/repo/issues/1",
title: "Test Issue",
state: "OPEN",
});
const result = await client.getIssueStatus("owner", "repo", 1);
expect(mockRunGhJsonAsync).toHaveBeenCalledWith([
"issue", "view", "1",
"--repo", "owner/repo",
"--json", "number,url,title,state,stateReason",
]);
expect(result).not.toBeNull();
expect(result?.number).toBe(1);
expect(result?.state).toBe("open");
});
it("returns null for PRs (not issues)", async () => {
mockRunGhJsonAsync.mockRejectedValue(
new Error("Could not resolve to an issue with the number 1")
);
const result = await client.getIssueStatus("owner", "repo", 1);
expect(result).toBeNull();
});
it("falls back to REST API when gh CLI fails and token is available", async () => {
mockRunGhJsonAsync.mockRejectedValue(new Error("gh failed"));
const clientWithToken = new GitHubClient("ghp_token");
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({
number: 1,
html_url: "https://github.com/owner/repo/issues/1",
title: "Test Issue",
state: "open",
state_reason: null,
}),
});
global.fetch = mockFetch as any;
const result = await clientWithToken.getIssueStatus("owner", "repo", 1);
expect(mockFetch).toHaveBeenCalled();
expect(result?.number).toBe(1);
vi.restoreAllMocks();
});
});
describe("listIssues", () => {
const mockIssues = [
{
number: 1,
title: "Issue 1",
body: "Body 1",
url: "https://github.com/owner/repo/issues/1",
labels: [{ name: "bug" }],
},
{
number: 2,
title: "Issue 2",
body: "Body 2",
url: "https://github.com/owner/repo/issues/2",
labels: [{ name: "feature" }],
},
];
it("lists open issues using gh CLI", async () => {
mockRunGhJsonAsync.mockResolvedValue(mockIssues);
const result = await client.listIssues("owner", "repo");
expect(mockRunGhJsonAsync).toHaveBeenCalledWith([
"issue", "list",
"--repo", "owner/repo",
"--state", "open",
"--limit", "30",
"--json", "number,title,body,url,labels",
]);
expect(result).toHaveLength(2);
expect(result[0].number).toBe(1);
});
it("respects limit parameter", async () => {
mockRunGhJsonAsync.mockResolvedValue(mockIssues.slice(0, 1));
await client.listIssues("owner", "repo", { limit: 10 });
expect(mockRunGhJsonAsync).toHaveBeenCalledWith(
expect.arrayContaining(["--limit", "10"])
);
});
it("filters by labels client-side", async () => {
mockRunGhJsonAsync.mockResolvedValue(mockIssues);
const result = await client.listIssues("owner", "repo", { labels: ["bug"] });
expect(result).toHaveLength(1);
expect(result[0].number).toBe(1);
});
it("falls back to REST API when gh CLI fails and token is available", async () => {
mockRunGhJsonAsync.mockRejectedValue(new Error("gh failed"));
const clientWithToken = new GitHubClient("ghp_token");
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve([
{
number: 1,
title: "API Issue",
body: "API body",
html_url: "https://github.com/owner/repo/issues/1",
labels: [{ name: "api" }],
},
]),
});
global.fetch = mockFetch as any;
const result = await clientWithToken.listIssues("owner", "repo");
expect(mockFetch).toHaveBeenCalled();
expect(result).toHaveLength(1);
vi.restoreAllMocks();
});
});
describe("getIssue", () => {
it("fetches single issue using gh CLI", async () => {
mockRunGhJsonAsync.mockResolvedValue({
number: 1,
title: "Test Issue",
body: "Test body",
url: "https://github.com/owner/repo/issues/1",
state: "OPEN",
stateReason: "reopened",
});
const result = await client.getIssue("owner", "repo", 1);
expect(mockRunGhJsonAsync).toHaveBeenCalledWith([
"issue", "view", "1",
"--repo", "owner/repo",
"--json", "number,title,body,url,state,stateReason",
]);
expect(result).not.toBeNull();
expect(result?.number).toBe(1);
expect(result?.state).toBe("open");
expect(result?.stateReason).toBe("reopened");
});
it("returns null for non-existent issues", async () => {
mockRunGhJsonAsync.mockRejectedValue(
new Error("HTTP 404: not found")
);
const result = await client.getIssue("owner", "repo", 999);
expect(result).toBeNull();
});
it("returns null for PRs", async () => {
mockRunGhJsonAsync.mockRejectedValue(
new Error("Could not resolve to an issue")
);
const result = await client.getIssue("owner", "repo", 1);
expect(result).toBeNull();
});
it("falls back to REST API when gh CLI fails and token is available", async () => {
mockRunGhJsonAsync.mockRejectedValue(new Error("gh failed"));
const clientWithToken = new GitHubClient("ghp_token");
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({
number: 1,
title: "API Issue",
body: "API body",
html_url: "https://github.com/owner/repo/issues/1",
state: "open",
state_reason: null,
}),
});
global.fetch = mockFetch as any;
const result = await clientWithToken.getIssue("owner", "repo", 1);
expect(mockFetch).toHaveBeenCalled();
expect(result?.number).toBe(1);
vi.restoreAllMocks();
});
});
describe("error handling when gh CLI not available", () => {
it("throws error when gh CLI not available and no token", async () => {
mockIsGhAvailable.mockReturnValue(false);
await expect(client.createPr({
title: "Test",
head: "branch",
})).rejects.toThrow("GitHub CLI (gh) is not available");
});
it("throws error when gh not authenticated and no token", async () => {
mockIsGhAuthenticated.mockReturnValue(false);
await expect(client.createPr({
title: "Test",
head: "branch",
})).rejects.toThrow("GitHub CLI (gh) is not available or not authenticated");
});
});
});

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,

View File

@@ -1102,12 +1102,15 @@ describe("Pause/Unpause endpoints", () => {
expect(res.body.error).toContain("title is required");
});
it("returns 429 when rate limit exceeded", { timeout: 15000 }, async () => {
it("no longer has in-app rate limiter (gh CLI handles rate limiting)", async () => {
// Previously this test checked for a 429 response from an in-memory rate limiter.
// Now gh CLI handles rate limiting internally, so multiple rapid requests
// are allowed (gh CLI has its own rate limiting and caching).
// Set up GITHUB_REPOSITORY env to bypass git lookup
const originalEnv = process.env.GITHUB_REPOSITORY;
process.env.GITHUB_REPOSITORY = "owner/rate-test";
// Create a fresh store mock for this test to isolate rate limit state
// Create a fresh store mock for this test
const freshStore = createMockStore({
getTask: vi.fn(),
updatePrInfo: vi.fn(),
@@ -1122,40 +1125,25 @@ describe("Pause/Unpause endpoints", () => {
return app;
}
// Make 60 requests to hit the rate limit
// Make multiple rapid requests - should not be rate limited by our code
// (gh CLI handles rate limiting with GitHub)
const app = buildFreshApp();
for (let i = 0; i < 60; i++) {
for (let i = 0; i < 5; i++) {
(freshStore.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...mockInReviewTask,
id: `KB-RATE-${i}`,
});
await REQUEST(
const res = await REQUEST(
app,
"POST",
`/api/tasks/KB-RATE-${i}/pr/create`,
JSON.stringify({ title: `Test PR ${i}` }),
{ "Content-Type": "application/json" }
);
// Should not get 429 from our code (may get 500 from gh CLI not being available in test)
expect(res.status).not.toBe(429);
}
// 61st request should be rate limited
(freshStore.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...mockInReviewTask,
id: "KB-RATE-61",
});
const res = await REQUEST(
app,
"POST",
"/api/tasks/KB-RATE-61/pr/create",
JSON.stringify({ title: "Test PR 61" }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(429);
expect(res.body.error).toContain("rate limit exceeded");
expect(res.body.resetAt).toBeDefined();
// Restore env
if (originalEnv) {
process.env.GITHUB_REPOSITORY = originalEnv;

View File

@@ -515,45 +515,10 @@ function pushGitBranch(): GitPushResult {
}
}
/**
* Per-repo GitHub API rate limiter.
* Tracks requests per repo and enforces 60 requests per hour per repo.
*/
class GitHubRateLimiter {
private requests = new Map<string, number[]>();
private readonly maxRequests = 60;
private readonly windowMs = 60 * 60 * 1000; // 1 hour
canMakeRequest(repo: string): boolean {
const now = Date.now();
const timestamps = this.requests.get(repo) || [];
// Remove timestamps outside the window
const validTimestamps = timestamps.filter((ts) => now - ts < this.windowMs);
if (validTimestamps.length >= this.maxRequests) {
return false;
}
validTimestamps.push(now);
this.requests.set(repo, validTimestamps);
return true;
}
getResetTime(repo: string): Date | null {
const timestamps = this.requests.get(repo);
if (!timestamps || timestamps.length === 0) return null;
const oldest = Math.min(...timestamps);
return new Date(oldest + this.windowMs);
}
}
export function createApiRoutes(store: TaskStore, options?: ServerOptions): Router {
const router = Router();
const ghRateLimiter = new GitHubRateLimiter();
// Get GitHub token from options or env
// Get GitHub token from options or env (for REST API fallback)
const githubToken = options?.githubToken ?? process.env.GITHUB_TOKEN;
// Scheduler config (includes persisted settings)
@@ -1505,17 +1470,6 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
repo = gitRepo.repo;
}
// Check rate limit
const repoKey = `${owner}/${repo}`;
if (!ghRateLimiter.canMakeRequest(repoKey)) {
const resetTime = ghRateLimiter.getResetTime(repoKey);
res.status(429).json({
error: "GitHub API rate limit exceeded for this repository",
resetAt: resetTime?.toISOString(),
});
return;
}
// Create the PR
const client = new GitHubClient(githubToken);
@@ -1617,17 +1571,6 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
repo = gitRepo.repo;
}
// Check rate limit
const repoKey = `${owner}/${repo}`;
if (!ghRateLimiter.canMakeRequest(repoKey)) {
const resetTime = ghRateLimiter.getResetTime(repoKey);
res.status(429).json({
error: "GitHub API rate limit exceeded for this repository",
resetAt: resetTime?.toISOString(),
});
return;
}
// Fetch fresh PR status
const client = new GitHubClient(githubToken);
@@ -1707,17 +1650,6 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
return;
}
// Check rate limit before fetching
const repoKey = `${parsed.owner}/${parsed.repo}`;
if (!ghRateLimiter.canMakeRequest(repoKey)) {
const resetTime = ghRateLimiter.getResetTime(repoKey);
res.status(429).json({
error: "GitHub API rate limit exceeded for this repository",
resetAt: resetTime?.toISOString(),
});
return;
}
// Fetch fresh issue status
const client = new GitHubClient(githubToken);
const issueData = await client.getIssueStatus(parsed.owner, parsed.repo, parsed.number);
@@ -1787,17 +1719,6 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
issueNumber = parsed.number;
}
// Check rate limit
const repoKey = `${owner}/${repo}`;
if (!ghRateLimiter.canMakeRequest(repoKey)) {
const resetTime = ghRateLimiter.getResetTime(repoKey);
res.status(429).json({
error: "GitHub API rate limit exceeded for this repository",
resetAt: resetTime?.toISOString(),
});
return;
}
// Fetch fresh issue status
const client = new GitHubClient(githubToken);
const issueData = await client.getIssueStatus(owner, repo, issueNumber);