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("@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"); }); }); });