Adds paginated PR review snapshots to the dashboard GitHub module with corresponding test coverage across the unit and route layers. Fusion-Task-Id: FN-5181
2130 lines
75 KiB
TypeScript
2130 lines
75 KiB
TypeScript
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { execSync } from "node:child_process";
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { GitHubClient, CreatePrParams, PrComment, isPrMergeReady } from "../github.js";
|
|
|
|
// Mock the gh-cli module from @fusion/core
|
|
vi.mock("@fusion/core", async () => {
|
|
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/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 "@fusion/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);
|
|
|
|
function createGraphQlBatchPayload(repository: Record<string, unknown>) {
|
|
return JSON.stringify({ data: { repository } });
|
|
}
|
|
|
|
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.useRealTimers();
|
|
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("createIssue", () => {
|
|
it("falls back to API when gh path fails and token is configured", async () => {
|
|
const clientWithToken = new GitHubClient("ghp_token");
|
|
mockRunGhAsync.mockRejectedValue(new Error("gh failed"));
|
|
const fetchSpy = vi.spyOn(global, "fetch" as any).mockResolvedValue({
|
|
ok: true,
|
|
status: 201,
|
|
json: async () => ({ number: 7, html_url: "https://github.com/o/r/issues/7", created_at: "2026-01-01T00:00:00Z" }),
|
|
} as any);
|
|
|
|
const issue = await clientWithToken.createIssue({ owner: "o", repo: "r", title: "t", body: "b" });
|
|
expect(issue).toEqual({ owner: "o", repo: "r", number: 7, htmlUrl: "https://github.com/o/r/issues/7", createdAt: "2026-01-01T00:00:00Z" });
|
|
expect(mockRunGhAsync).toHaveBeenCalled();
|
|
expect(mockRunGhJsonAsync).not.toHaveBeenCalled();
|
|
fetchSpy.mockRestore();
|
|
});
|
|
|
|
it("uses API path when gh auth is unavailable and token is configured", async () => {
|
|
mockIsGhAvailable.mockReturnValue(false);
|
|
const clientWithToken = new GitHubClient("ghp_token");
|
|
const fetchSpy = vi.spyOn(global, "fetch" as any).mockResolvedValue({
|
|
ok: true,
|
|
status: 201,
|
|
json: async () => ({ number: 9, html_url: "https://github.com/o/r/issues/9", created_at: "2026-01-03T00:00:00Z" }),
|
|
} as any);
|
|
|
|
const issue = await clientWithToken.createIssue({ owner: "o", repo: "r", title: "t", body: "b" });
|
|
expect(issue.number).toBe(9);
|
|
fetchSpy.mockRestore();
|
|
});
|
|
|
|
it("throws when gh auth unavailable and no token provided", async () => {
|
|
mockIsGhAvailable.mockReturnValue(false);
|
|
await expect(client.createIssue({ owner: "o", repo: "r", title: "t", body: "b" })).rejects.toThrow("GitHub CLI (gh) is not available");
|
|
});
|
|
|
|
it("surfaces 422 API failures with cause", async () => {
|
|
mockIsGhAvailable.mockReturnValue(false);
|
|
const clientWithToken = new GitHubClient("ghp_token");
|
|
vi.spyOn(global, "fetch" as any).mockResolvedValue({ ok: false, status: 422, statusText: "Unprocessable", json: async () => ({ message: "Validation Failed" }) } as any);
|
|
await expect(clientWithToken.createIssue({ owner: "o", repo: "r", title: "t", body: "b" })).rejects.toThrow("Failed to create GitHub issue");
|
|
});
|
|
|
|
it("surfaces 404 API failures", async () => {
|
|
mockIsGhAvailable.mockReturnValue(false);
|
|
const clientWithToken = new GitHubClient("ghp_token");
|
|
vi.spyOn(global, "fetch" as any).mockResolvedValue({ ok: false, status: 404, statusText: "Not Found", json: async () => ({ message: "Not Found" }) } as any);
|
|
await expect(clientWithToken.createIssue({ owner: "o", repo: "r", title: "t", body: "b" })).rejects.toThrow("Failed to create GitHub issue");
|
|
});
|
|
});
|
|
|
|
describe("GitHubClient.createIssue (gh-cli mode)", () => {
|
|
it("creates an issue from gh stdout and follow-up view metadata", async () => {
|
|
mockRunGhAsync.mockResolvedValue("https://github.com/acme/repo/issues/42\n");
|
|
mockRunGhJsonAsync.mockResolvedValue({
|
|
number: 42,
|
|
url: "https://github.com/acme/repo/issues/42",
|
|
createdAt: "2025-01-02T03:04:05Z",
|
|
} as any);
|
|
const ghClient = new GitHubClient({ forceMode: "gh-cli" });
|
|
|
|
const issue = await ghClient.createIssue({ owner: "acme", repo: "repo", title: "T", body: "B" });
|
|
|
|
expect(issue).toEqual({
|
|
owner: "acme",
|
|
repo: "repo",
|
|
number: 42,
|
|
htmlUrl: "https://github.com/acme/repo/issues/42",
|
|
createdAt: "2025-01-02T03:04:05Z",
|
|
});
|
|
expect(mockRunGhAsync).toHaveBeenCalledWith([
|
|
"issue",
|
|
"create",
|
|
"--repo",
|
|
"acme/repo",
|
|
"--title",
|
|
"T",
|
|
"--body",
|
|
"B",
|
|
]);
|
|
expect(mockRunGhAsync).toHaveBeenCalledWith(expect.not.arrayContaining(["--json"]));
|
|
expect(mockRunGhJsonAsync).toHaveBeenCalledWith([
|
|
"issue",
|
|
"view",
|
|
"https://github.com/acme/repo/issues/42",
|
|
"--json",
|
|
"number,url,createdAt",
|
|
]);
|
|
});
|
|
|
|
it("passes labels without adding --json to issue create", async () => {
|
|
mockRunGhAsync.mockResolvedValue("https://github.com/acme/repo/issues/42\n");
|
|
mockRunGhJsonAsync.mockResolvedValue({
|
|
number: 42,
|
|
url: "https://github.com/acme/repo/issues/42",
|
|
createdAt: "2025-01-02T03:04:05Z",
|
|
} as any);
|
|
const ghClient = new GitHubClient({ forceMode: "gh-cli" });
|
|
|
|
await ghClient.createIssue({ owner: "acme", repo: "repo", title: "T", body: "B", labels: ["a", "b"] });
|
|
|
|
expect(mockRunGhAsync).toHaveBeenCalledWith([
|
|
"issue",
|
|
"create",
|
|
"--repo",
|
|
"acme/repo",
|
|
"--title",
|
|
"T",
|
|
"--body",
|
|
"B",
|
|
"--label",
|
|
"a,b",
|
|
]);
|
|
expect(mockRunGhAsync).toHaveBeenCalledWith(expect.not.arrayContaining(["--json"]));
|
|
});
|
|
|
|
it("rejects malformed gh stdout without calling issue view", async () => {
|
|
mockRunGhAsync.mockResolvedValue("oops");
|
|
const ghClient = new GitHubClient({ forceMode: "gh-cli" });
|
|
|
|
await expect(ghClient.createIssue({ owner: "acme", repo: "repo", title: "T", body: "B" })).rejects.toThrow(
|
|
"Failed to parse issue URL from gh output",
|
|
);
|
|
expect(mockRunGhJsonAsync).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("createPr", () => {
|
|
const mockPrParams: CreatePrParams = {
|
|
owner: "test-owner",
|
|
repo: "test-repo",
|
|
title: "Test PR",
|
|
body: "Test body",
|
|
head: "feature-branch",
|
|
base: "main",
|
|
};
|
|
|
|
it("createPr succeeds with gh CLI only (no token)", async () => {
|
|
mockRunGh.mockReturnValue("https://github.com/test-owner/test-repo/pull/42\n");
|
|
const ghOnlyClient = new GitHubClient();
|
|
|
|
const result = await ghOnlyClient.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 gh-auth-focused error when gh CLI fails and no token is available", async () => {
|
|
mockRunGh.mockImplementation(() => {
|
|
throw new Error("GitHub CLI is not authenticated. Run 'gh auth login'.");
|
|
});
|
|
|
|
await expect(client.createPr(mockPrParams)).rejects.toThrow("gh auth login");
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "draft true with reviewers",
|
|
draft: true,
|
|
reviewers: ["alice", "bob"],
|
|
expectedFlags: ["--draft", "--reviewer", "alice,bob"],
|
|
},
|
|
{
|
|
name: "no draft and empty reviewers",
|
|
draft: undefined,
|
|
reviewers: [],
|
|
expectedFlags: [],
|
|
},
|
|
])("passes optional gh create flags: $name", async ({ draft, reviewers, expectedFlags }) => {
|
|
mockRunGh.mockReturnValue("https://github.com/test-owner/test-repo/pull/42\n");
|
|
|
|
await client.createPr({
|
|
...mockPrParams,
|
|
draft,
|
|
reviewers,
|
|
});
|
|
|
|
const callArgs = mockRunGh.mock.calls[0][0];
|
|
if (expectedFlags.length > 0) {
|
|
expect(callArgs).toEqual(expect.arrayContaining(expectedFlags));
|
|
} else {
|
|
expect(callArgs).not.toContain("--draft");
|
|
expect(callArgs).not.toContain("--reviewer");
|
|
}
|
|
});
|
|
|
|
it("sends draft body + reviewer request via REST API", async () => {
|
|
mockRunGh.mockImplementation(() => {
|
|
throw new Error("gh command failed");
|
|
});
|
|
const clientWithToken = new GitHubClient("ghp_token");
|
|
const fetchSpy = vi.spyOn(global, "fetch" as any)
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({
|
|
number: 42,
|
|
html_url: "https://github.com/test-owner/test-repo/pull/42",
|
|
title: "Test PR",
|
|
state: "open",
|
|
draft: true,
|
|
head: { ref: "feature-branch" },
|
|
base: { ref: "main" },
|
|
comments: 0,
|
|
}),
|
|
} as any)
|
|
.mockResolvedValueOnce({ ok: true, json: async () => ({}) } as any);
|
|
|
|
const result = await clientWithToken.createPr({
|
|
...mockPrParams,
|
|
draft: true,
|
|
reviewers: ["alice", "bob"],
|
|
});
|
|
|
|
const createPrRequest = fetchSpy.mock.calls[0];
|
|
expect(createPrRequest[0]).toContain("/repos/test-owner/test-repo/pulls");
|
|
expect(createPrRequest[1]?.body).toContain('"draft":true');
|
|
|
|
const reviewersRequest = fetchSpy.mock.calls[1];
|
|
expect(reviewersRequest[0]).toContain("/repos/test-owner/test-repo/pulls/42/requested_reviewers");
|
|
expect(reviewersRequest[1]?.body).toBe(JSON.stringify({ reviewers: ["alice", "bob"] }));
|
|
expect(result.draft).toBe(true);
|
|
|
|
fetchSpy.mockRestore();
|
|
});
|
|
|
|
it("returns PR result when REST reviewer request fails", async () => {
|
|
mockRunGh.mockImplementation(() => {
|
|
throw new Error("gh command failed");
|
|
});
|
|
const stderrSpy = vi.spyOn(process.stderr, "write").mockReturnValue(true);
|
|
const clientWithToken = new GitHubClient("ghp_token");
|
|
const fetchSpy = vi.spyOn(global, "fetch" as any)
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({
|
|
number: 99,
|
|
html_url: "https://github.com/test-owner/test-repo/pull/99",
|
|
title: "Test PR",
|
|
state: "open",
|
|
head: { ref: "feature-branch" },
|
|
base: { ref: "main" },
|
|
comments: 0,
|
|
}),
|
|
} as any)
|
|
.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 422,
|
|
statusText: "Unprocessable",
|
|
json: async () => ({ message: "Reviewers are invalid" }),
|
|
} as any);
|
|
|
|
const result = await clientWithToken.createPr({
|
|
...mockPrParams,
|
|
reviewers: ["alice", "bob"],
|
|
});
|
|
|
|
expect(result.number).toBe(99);
|
|
expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining("failed to request reviewers for PR #99"));
|
|
fetchSpy.mockRestore();
|
|
stderrSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
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",
|
|
isDraft: false,
|
|
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,isDraft,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("commentOnIssue", () => {
|
|
it("posts comment via gh CLI when auth is available", async () => {
|
|
mockRunGh.mockReturnValue("commented");
|
|
|
|
await client.commentOnIssue("owner", "repo", 123, "Done ✅");
|
|
|
|
expect(mockRunGh).toHaveBeenCalledWith([
|
|
"issue",
|
|
"comment",
|
|
"123",
|
|
"--repo",
|
|
"owner/repo",
|
|
"--body",
|
|
"Done ✅",
|
|
]);
|
|
});
|
|
|
|
it("falls back to REST API when gh CLI is unavailable and token exists", async () => {
|
|
mockIsGhAvailable.mockReturnValue(false);
|
|
const clientWithToken = new GitHubClient("ghp_token");
|
|
const fetchSpy = vi.spyOn(clientWithToken, "fetchThrottled").mockResolvedValue({
|
|
success: true,
|
|
data: { id: 77 },
|
|
});
|
|
|
|
await clientWithToken.commentOnIssue("owner", "repo", 77, "Completed");
|
|
|
|
expect(fetchSpy).toHaveBeenCalledWith(
|
|
"https://api.github.com/repos/owner/repo/issues/77/comments",
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ body: "Completed" }),
|
|
},
|
|
);
|
|
});
|
|
|
|
it("falls back to REST API when gh CLI call fails and token exists", async () => {
|
|
mockRunGh.mockImplementation(() => {
|
|
throw new Error("gh failed");
|
|
});
|
|
const clientWithToken = new GitHubClient("ghp_token");
|
|
const fetchSpy = vi.spyOn(clientWithToken, "fetchThrottled").mockResolvedValue({
|
|
success: true,
|
|
data: { id: 78 },
|
|
});
|
|
|
|
await clientWithToken.commentOnIssue("owner", "repo", 78, "Completed");
|
|
|
|
expect(fetchSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it("throws when neither gh auth nor token is available", async () => {
|
|
mockIsGhAvailable.mockReturnValue(false);
|
|
mockIsGhAuthenticated.mockReturnValue(false);
|
|
const unauthClient = new GitHubClient();
|
|
|
|
await expect(unauthClient.commentOnIssue("owner", "repo", 1, "Done")).rejects.toThrow(
|
|
"GitHub CLI (gh) is not available or not authenticated, and no GITHUB_TOKEN provided.",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("deleteIssue", () => {
|
|
it("deletes an issue via gh CLI", async () => {
|
|
mockRunGh.mockReturnValue("deleted");
|
|
const ghClient = new GitHubClient({ forceMode: "gh-cli" });
|
|
|
|
await ghClient.deleteIssue("owner", "repo", 123);
|
|
|
|
expect(mockRunGh).toHaveBeenCalledWith([
|
|
"issue",
|
|
"delete",
|
|
"123",
|
|
"--repo",
|
|
"owner/repo",
|
|
"--yes",
|
|
]);
|
|
});
|
|
|
|
it("surfaces gh CLI failures", async () => {
|
|
mockRunGh.mockImplementation(() => {
|
|
throw new Error("permission denied");
|
|
});
|
|
const ghClient = new GitHubClient({ forceMode: "gh-cli" });
|
|
|
|
await expect(ghClient.deleteIssue("owner", "repo", 124)).rejects.toThrow("permission denied");
|
|
});
|
|
|
|
it("rejects deletion in token-only mode with explanatory error", async () => {
|
|
const tokenClient = new GitHubClient({ token: "ghp_token", forceMode: "token" });
|
|
|
|
await expect(tokenClient.deleteIssue("owner", "repo", 125)).rejects.toThrow(
|
|
"Deleting GitHub issues requires gh CLI authentication. Token-only mode does not support issue deletion.",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("getBatchIssueStatus", () => {
|
|
it("uses the REST issues list endpoint for recent requested issues", async () => {
|
|
mockRunGhJsonAsync.mockResolvedValue([
|
|
{
|
|
number: 250,
|
|
html_url: "https://github.com/owner/repo/issues/250",
|
|
title: "Issue 250",
|
|
state: "open",
|
|
state_reason: null,
|
|
},
|
|
{
|
|
number: 120,
|
|
html_url: "https://github.com/owner/repo/issues/120",
|
|
title: "Issue 120",
|
|
state: "closed",
|
|
state_reason: "completed",
|
|
},
|
|
]);
|
|
|
|
const result = await client.getBatchIssueStatus("owner", "repo", [250, 120]);
|
|
|
|
expect(mockRunGhJsonAsync).toHaveBeenCalledWith([
|
|
"api",
|
|
"repos/owner/repo/issues?state=all&per_page=100",
|
|
]);
|
|
expect(mockRunGhAsync).not.toHaveBeenCalled();
|
|
expect(result.get(250)).toMatchObject({ number: 250, state: "open" });
|
|
expect(result.get(120)).toMatchObject({ number: 120, state: "closed", stateReason: "completed" });
|
|
});
|
|
|
|
it("falls back for requested issues missing from the REST list response", async () => {
|
|
mockRunGhJsonAsync.mockResolvedValue([
|
|
{
|
|
number: 250,
|
|
html_url: "https://github.com/owner/repo/issues/250",
|
|
title: "Issue 250",
|
|
state: "open",
|
|
state_reason: null,
|
|
},
|
|
]);
|
|
mockRunGhAsync.mockResolvedValue(
|
|
createGraphQlBatchPayload({
|
|
issue_120: {
|
|
number: 120,
|
|
url: "https://github.com/owner/repo/issues/120",
|
|
title: "Issue 120",
|
|
state: "CLOSED",
|
|
stateReason: "COMPLETED",
|
|
},
|
|
issue_100: null,
|
|
}),
|
|
);
|
|
|
|
const result = await client.getBatchIssueStatus("owner", "repo", [250, 120, 100]);
|
|
|
|
expect(mockRunGhJsonAsync).toHaveBeenCalledTimes(1);
|
|
expect(mockRunGhAsync).toHaveBeenCalledTimes(1);
|
|
expect(result.get(250)).toMatchObject({ number: 250, state: "open" });
|
|
expect(result.get(120)).toMatchObject({ number: 120, state: "closed", stateReason: "completed" });
|
|
expect(result.has(100)).toBe(false);
|
|
expect(result.size).toBe(2);
|
|
});
|
|
|
|
it("returns early for empty input", async () => {
|
|
const result = await client.getBatchIssueStatus("owner", "repo", []);
|
|
|
|
expect(result.size).toBe(0);
|
|
expect(mockRunGhJsonAsync).not.toHaveBeenCalled();
|
|
expect(mockRunGhAsync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("retries transient REST failures with a 5 second backoff", async () => {
|
|
vi.useFakeTimers();
|
|
mockRunGhJsonAsync
|
|
.mockRejectedValueOnce(new Error("secondary rate limit"))
|
|
.mockRejectedValueOnce(new Error("502 Bad Gateway"))
|
|
.mockResolvedValueOnce([
|
|
{
|
|
number: 5,
|
|
html_url: "https://github.com/owner/repo/issues/5",
|
|
title: "Issue 5",
|
|
state: "open",
|
|
state_reason: null,
|
|
},
|
|
]);
|
|
|
|
const promise = client.getBatchIssueStatus("owner", "repo", [5]);
|
|
await vi.advanceTimersByTimeAsync(10_000);
|
|
const result = await promise;
|
|
|
|
expect(mockRunGhJsonAsync).toHaveBeenCalledTimes(3);
|
|
expect(result.get(5)?.number).toBe(5);
|
|
});
|
|
|
|
it("stops retrying the REST batch call after 3 attempts", async () => {
|
|
vi.useFakeTimers();
|
|
mockRunGhJsonAsync.mockRejectedValue(new Error("secondary rate limit"));
|
|
|
|
const exhaustedPromise = client.getBatchIssueStatus("owner", "repo", [6]);
|
|
const rejection = expect(exhaustedPromise).rejects.toThrow("secondary rate limit");
|
|
await vi.advanceTimersByTimeAsync(10_000);
|
|
await rejection;
|
|
|
|
expect(mockRunGhJsonAsync).toHaveBeenCalledTimes(3);
|
|
});
|
|
});
|
|
|
|
describe("getBatchPrStatus", () => {
|
|
it("uses the REST pulls list endpoint and maps merged PRs correctly", async () => {
|
|
mockRunGhJsonAsync.mockResolvedValue([
|
|
{
|
|
number: 150,
|
|
html_url: "https://github.com/owner/repo/pull/150",
|
|
title: "PR 150",
|
|
state: "closed",
|
|
merged_at: "2026-03-30T12:00:00Z",
|
|
head: { ref: "feature/150" },
|
|
base: { ref: "main" },
|
|
comments: 2,
|
|
updated_at: "2026-03-30T11:00:00Z",
|
|
},
|
|
{
|
|
number: 147,
|
|
html_url: "https://github.com/owner/repo/pull/147",
|
|
title: "PR 147",
|
|
state: "closed",
|
|
merged_at: null,
|
|
head: { ref: "feature/147" },
|
|
base: { ref: "main" },
|
|
comments: 1,
|
|
updated_at: "2026-03-30T11:00:00Z",
|
|
},
|
|
]);
|
|
|
|
const result = await client.getBatchPrStatus("owner", "repo", [150, 147]);
|
|
|
|
expect(mockRunGhJsonAsync).toHaveBeenCalledWith([
|
|
"api",
|
|
"repos/owner/repo/pulls?state=all&per_page=100",
|
|
]);
|
|
expect(mockRunGhAsync).not.toHaveBeenCalled();
|
|
expect(result.get(150)?.status).toBe("merged");
|
|
expect(result.get(147)?.status).toBe("closed");
|
|
});
|
|
|
|
it("chunks fallback exact lookups when more than 100 requested PRs are missing from the REST list", async () => {
|
|
mockRunGhJsonAsync.mockResolvedValue([]);
|
|
mockRunGhAsync
|
|
.mockResolvedValueOnce(
|
|
createGraphQlBatchPayload(
|
|
Object.fromEntries(
|
|
Array.from({ length: 100 }, (_, index) => {
|
|
const number = 150 - index;
|
|
return [`pr_${number}`, {
|
|
number,
|
|
url: `https://github.com/owner/repo/pull/${number}`,
|
|
title: `PR ${number}`,
|
|
state: number === 150 ? "MERGED" : number === 147 ? "CLOSED" : "OPEN",
|
|
baseRefName: "main",
|
|
headRefName: `feature/${number}`,
|
|
comments: { totalCount: number % 4, nodes: [{ updatedAt: "2026-03-30T11:00:00Z" }] },
|
|
}];
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
createGraphQlBatchPayload({
|
|
pr_50: {
|
|
number: 50,
|
|
url: "https://github.com/owner/repo/pull/50",
|
|
title: "PR 50",
|
|
state: "OPEN",
|
|
baseRefName: "main",
|
|
headRefName: "feature/50",
|
|
comments: { totalCount: 2, nodes: [{ updatedAt: "2026-03-30T11:00:00Z" }] },
|
|
},
|
|
}),
|
|
);
|
|
|
|
const requestedNumbers = Array.from({ length: 101 }, (_, index) => 150 - index);
|
|
const result = await client.getBatchPrStatus("owner", "repo", requestedNumbers);
|
|
|
|
expect(mockRunGhJsonAsync).toHaveBeenCalledTimes(1);
|
|
expect(mockRunGhAsync).toHaveBeenCalledTimes(2);
|
|
expect(result.size).toBe(101);
|
|
expect(result.get(150)?.status).toBe("merged");
|
|
expect(result.get(149)?.status).toBe("open");
|
|
expect(result.get(147)?.status).toBe("closed");
|
|
});
|
|
|
|
it("falls back to REST auth when gh REST batch fetch fails and a token is available", async () => {
|
|
mockRunGhJsonAsync.mockRejectedValueOnce(new Error("gh failed"));
|
|
const clientWithToken = new GitHubClient("ghp_token");
|
|
const mockFetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
statusText: "OK",
|
|
json: () => Promise.resolve([
|
|
{
|
|
number: 42,
|
|
html_url: "https://github.com/owner/repo/pull/42",
|
|
title: "PR 42",
|
|
state: "open",
|
|
merged_at: null,
|
|
head: { ref: "feature/42" },
|
|
base: { ref: "main" },
|
|
comments: 1,
|
|
updated_at: "2026-03-30T11:00:00Z",
|
|
},
|
|
]),
|
|
});
|
|
global.fetch = mockFetch as any;
|
|
|
|
const result = await clientWithToken.getBatchPrStatus("owner", "repo", [42]);
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
"https://api.github.com/repos/owner/repo/pulls?state=all&per_page=100",
|
|
expect.objectContaining({ headers: expect.any(Object) }),
|
|
);
|
|
expect(result.get(42)?.number).toBe(42);
|
|
});
|
|
});
|
|
|
|
describe("listIssues", () => {
|
|
const mockIssues = [
|
|
{
|
|
number: 1,
|
|
title: "Issue 1",
|
|
body: "Body 1",
|
|
url: "https://github.com/owner/repo/issues/1",
|
|
labels: [{ name: "bug" }],
|
|
state: "OPEN" as const,
|
|
updatedAt: "2026-05-16T08:00:00Z",
|
|
},
|
|
{
|
|
number: 2,
|
|
title: "Issue 2",
|
|
body: "Body 2",
|
|
url: "https://github.com/owner/repo/issues/2",
|
|
labels: [{ name: "feature" }],
|
|
state: "CLOSED" as const,
|
|
updatedAt: "2026-05-15T08:00:00Z",
|
|
},
|
|
];
|
|
|
|
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,state,updatedAt",
|
|
]);
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0].number).toBe(1);
|
|
expect(result[0].state).toBe("open");
|
|
expect(result[0].updatedAt).toBe("2026-05-16T08:00:00Z");
|
|
});
|
|
|
|
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("supports explicit all-state issue listings", async () => {
|
|
mockRunGhJsonAsync.mockResolvedValue(mockIssues);
|
|
|
|
await client.listIssues("owner", "repo", { state: "all" });
|
|
|
|
expect(mockRunGhJsonAsync).toHaveBeenCalledWith(
|
|
expect.arrayContaining(["--state", "all"]),
|
|
);
|
|
});
|
|
|
|
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" }],
|
|
state: "open",
|
|
updated_at: "2026-05-16T10:00:00Z",
|
|
},
|
|
{
|
|
number: 3,
|
|
title: "API Pull Request",
|
|
body: "PR body",
|
|
html_url: "https://github.com/owner/repo/issues/3",
|
|
labels: [{ name: "api" }],
|
|
state: "open",
|
|
updated_at: "2026-05-16T10:00:00Z",
|
|
pull_request: {},
|
|
},
|
|
]),
|
|
});
|
|
global.fetch = mockFetch as any;
|
|
|
|
const result = await clientWithToken.listIssues("owner", "repo");
|
|
|
|
expect(mockFetch).toHaveBeenCalled();
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].state).toBe("open");
|
|
expect(result[0].updatedAt).toBe("2026-05-16T10:00:00Z");
|
|
|
|
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("findPrForBranch", () => {
|
|
it("finds an existing PR for a head branch via gh CLI", async () => {
|
|
mockRunGhJsonAsync.mockResolvedValue([
|
|
{
|
|
number: 42,
|
|
url: "https://github.com/owner/repo/pull/42",
|
|
title: "Existing PR",
|
|
state: "OPEN",
|
|
baseRefName: "main",
|
|
headRefName: "fusion/fn-093",
|
|
mergedAt: null,
|
|
},
|
|
]);
|
|
|
|
const result = await client.findPrForBranch({ owner: "owner", repo: "repo", head: "fusion/fn-093", state: "all" });
|
|
|
|
expect(mockRunGhJsonAsync).toHaveBeenCalledWith([
|
|
"pr", "list",
|
|
"--repo", "owner/repo",
|
|
"--head", "fusion/fn-093",
|
|
"--state", "all",
|
|
"--json", "number,url,title,state,baseRefName,headRefName,mergedAt",
|
|
]);
|
|
expect(result).toEqual(expect.objectContaining({ number: 42, status: "open" }));
|
|
});
|
|
|
|
it("falls back to REST API for branch lookup 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: 5,
|
|
html_url: "https://github.com/owner/repo/pull/5",
|
|
title: "API PR",
|
|
state: "open",
|
|
merged_at: null,
|
|
head: { ref: "fusion/fn-093" },
|
|
base: { ref: "main" },
|
|
comments: 2,
|
|
},
|
|
]),
|
|
});
|
|
global.fetch = mockFetch as any;
|
|
|
|
const result = await clientWithToken.findPrForBranch({ owner: "owner", repo: "repo", head: "fusion/fn-093" });
|
|
|
|
expect(mockFetch).toHaveBeenCalled();
|
|
expect(result).toEqual(expect.objectContaining({ number: 5, commentCount: 2 }));
|
|
vi.restoreAllMocks();
|
|
});
|
|
});
|
|
|
|
describe("getPrConflictDiagnostics", () => {
|
|
function createConflictRepo(): string {
|
|
const repoRoot = mkdtempSync(join(tmpdir(), "fn-4966-conflict-"));
|
|
execSync("git init -b main", { cwd: repoRoot, stdio: "ignore" });
|
|
execSync("git config user.email 'test@example.com'", { cwd: repoRoot, stdio: "ignore" });
|
|
execSync("git config user.name 'Test User'", { cwd: repoRoot, stdio: "ignore" });
|
|
writeFileSync(join(repoRoot, "conflict.txt"), "base\n");
|
|
execSync("git add conflict.txt && git commit -m 'base'", { cwd: repoRoot, stdio: "ignore" });
|
|
execSync("git checkout -b feature", { cwd: repoRoot, stdio: "ignore" });
|
|
writeFileSync(join(repoRoot, "conflict.txt"), "feature\n");
|
|
execSync("git add conflict.txt && git commit -m 'feature'", { cwd: repoRoot, stdio: "ignore" });
|
|
execSync("git checkout main", { cwd: repoRoot, stdio: "ignore" });
|
|
writeFileSync(join(repoRoot, "conflict.txt"), "main\n");
|
|
execSync("git add conflict.txt && git commit -m 'main'", { cwd: repoRoot, stdio: "ignore" });
|
|
execSync("git update-ref refs/remotes/origin/main main", { cwd: repoRoot, stdio: "ignore" });
|
|
execSync("git update-ref refs/remotes/origin/feature feature", { cwd: repoRoot, stdio: "ignore" });
|
|
return repoRoot;
|
|
}
|
|
|
|
it("returns conflicting files from local git path", async () => {
|
|
const repoRoot = createConflictRepo();
|
|
const diagnostics = await client.getPrConflictDiagnostics("owner", "repo", 42, {
|
|
baseBranch: "main",
|
|
headBranch: "feature",
|
|
repoRoot,
|
|
});
|
|
|
|
expect(diagnostics.conflictingFiles).toContain("conflict.txt");
|
|
expect(diagnostics.suggestedCommands).toEqual([
|
|
"git fetch origin",
|
|
"git checkout feature",
|
|
"git rebase origin/main",
|
|
"# Resolve conflicts then: git add <files> && git rebase --continue",
|
|
]);
|
|
expect(mockRunGhJsonAsync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("falls back to gh compare files when local repo path fails", async () => {
|
|
mockRunGhJsonAsync.mockResolvedValueOnce({ files: [{ filename: "a.ts" }, { filename: "b.ts" }] });
|
|
const diagnostics = await client.getPrConflictDiagnostics("owner", "repo", 42, {
|
|
baseBranch: "main",
|
|
headBranch: "feature",
|
|
repoRoot: "/does/not/exist",
|
|
});
|
|
|
|
expect(diagnostics.conflictingFiles).toEqual(["a.ts", "b.ts"]);
|
|
expect(diagnostics.suggestedCommands.at(-1)).toContain("file list reflects PR changes");
|
|
});
|
|
|
|
it("composes commands for each direct-merge strategy", async () => {
|
|
mockRunGhJsonAsync.mockResolvedValue({ files: [] });
|
|
|
|
const squash = await client.getPrConflictDiagnostics("owner", "repo", 1, {
|
|
baseBranch: "main",
|
|
headBranch: "feature",
|
|
directMergeCommitStrategy: "always-squash",
|
|
});
|
|
expect(squash.suggestedCommands).toEqual([
|
|
"git fetch origin",
|
|
"git checkout feature",
|
|
"git merge origin/main",
|
|
"# Resolve conflicts then: git add <files> && git commit",
|
|
]);
|
|
|
|
const rebase = await client.getPrConflictDiagnostics("owner", "repo", 1, {
|
|
baseBranch: "main",
|
|
headBranch: "feature",
|
|
directMergeCommitStrategy: "always-rebase",
|
|
});
|
|
expect(rebase.suggestedCommands[2]).toBe("git rebase origin/main");
|
|
|
|
const auto = await client.getPrConflictDiagnostics("owner", "repo", 1, {
|
|
baseBranch: "main",
|
|
headBranch: "feature",
|
|
directMergeCommitStrategy: "auto",
|
|
});
|
|
expect(auto.suggestedCommands[2]).toBe("git rebase origin/main");
|
|
});
|
|
|
|
it("returns non-throwing defaults when both detection paths fail", async () => {
|
|
mockRunGhJsonAsync.mockRejectedValueOnce(new Error("gh compare failed"));
|
|
const diagnostics = await client.getPrConflictDiagnostics("owner", "repo", 42, {
|
|
baseBranch: "main",
|
|
headBranch: "feature",
|
|
repoRoot: "/does/not/exist",
|
|
});
|
|
|
|
expect(diagnostics.conflictingFiles).toEqual([]);
|
|
expect(diagnostics.suggestedCommands).toEqual([
|
|
"git fetch origin",
|
|
"git checkout feature",
|
|
"git rebase origin/main",
|
|
"# Resolve conflicts then: git add <files> && git rebase --continue",
|
|
]);
|
|
expect(() => new Date(diagnostics.capturedAt)).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("getPrMergeStatus", () => {
|
|
it("returns merge-ready status only when required checks pass and review is non-blocking", async () => {
|
|
mockRunGhJsonAsync
|
|
.mockResolvedValueOnce({
|
|
number: 42,
|
|
url: "https://github.com/owner/repo/pull/42",
|
|
title: "Ready PR",
|
|
state: "OPEN",
|
|
reviewDecision: "APPROVED",
|
|
mergeable: "MERGEABLE",
|
|
mergeStateStatus: "CLEAN",
|
|
baseRefName: "main",
|
|
headRefName: "fusion/fn-093",
|
|
})
|
|
.mockResolvedValueOnce([
|
|
{ name: "ci", state: "SUCCESS", link: "https://github.com/owner/repo/actions/runs/1", startedAt: "2026-01-01T00:00:00Z", completedAt: "2026-01-01T00:01:00Z" },
|
|
{ name: "lint", state: "SUCCESS" },
|
|
]);
|
|
|
|
const result = await client.getPrMergeStatus("owner", "repo", 42);
|
|
|
|
expect(result.mergeReady).toBe(true);
|
|
expect(result.mergeable).toBe("clean");
|
|
expect(result.prInfo.mergeable).toBe("clean");
|
|
expect(result.blockingReasons).toEqual([]);
|
|
expect(result.checks).toEqual([
|
|
{
|
|
name: "ci",
|
|
required: true,
|
|
state: "success",
|
|
detailsUrl: "https://github.com/owner/repo/actions/runs/1",
|
|
startedAt: "2026-01-01T00:00:00Z",
|
|
completedAt: "2026-01-01T00:01:00Z",
|
|
},
|
|
{ name: "lint", required: true, state: "success", detailsUrl: undefined, startedAt: undefined, completedAt: undefined },
|
|
]);
|
|
});
|
|
|
|
it("maps DIRTY merge-state to conflicting even when mergeable is unknown", async () => {
|
|
mockRunGhJsonAsync
|
|
.mockResolvedValueOnce({
|
|
number: 42,
|
|
url: "https://github.com/owner/repo/pull/42",
|
|
title: "Dirty PR",
|
|
state: "OPEN",
|
|
reviewDecision: "APPROVED",
|
|
mergeable: "UNKNOWN",
|
|
mergeStateStatus: "DIRTY",
|
|
baseRefName: "main",
|
|
headRefName: "fusion/fn-093",
|
|
})
|
|
.mockResolvedValueOnce([{ name: "ci", state: "SUCCESS" }]);
|
|
|
|
const result = await client.getPrMergeStatus("owner", "repo", 42);
|
|
expect(result.mergeable).toBe("conflicting");
|
|
expect(result.prInfo.mergeable).toBe("conflicting");
|
|
});
|
|
|
|
it("maps BEHIND merge-state to behind", async () => {
|
|
mockRunGhJsonAsync
|
|
.mockResolvedValueOnce({
|
|
number: 42,
|
|
url: "https://github.com/owner/repo/pull/42",
|
|
title: "Behind PR",
|
|
state: "OPEN",
|
|
reviewDecision: "APPROVED",
|
|
mergeStateStatus: "BEHIND",
|
|
baseRefName: "main",
|
|
headRefName: "fusion/fn-093",
|
|
})
|
|
.mockResolvedValueOnce([{ name: "ci", state: "SUCCESS" }]);
|
|
|
|
const result = await client.getPrMergeStatus("owner", "repo", 42);
|
|
expect(result.mergeable).toBe("behind");
|
|
expect(result.prInfo.mergeable).toBe("behind");
|
|
});
|
|
|
|
it("maps BLOCKED merge-state to blocked", async () => {
|
|
mockRunGhJsonAsync
|
|
.mockResolvedValueOnce({
|
|
number: 42,
|
|
url: "https://github.com/owner/repo/pull/42",
|
|
title: "Blocked PR",
|
|
state: "OPEN",
|
|
reviewDecision: "APPROVED",
|
|
mergeStateStatus: "BLOCKED",
|
|
baseRefName: "main",
|
|
headRefName: "fusion/fn-093",
|
|
})
|
|
.mockResolvedValueOnce([{ name: "ci", state: "SUCCESS" }]);
|
|
|
|
const result = await client.getPrMergeStatus("owner", "repo", 42);
|
|
expect(result.mergeable).toBe("blocked");
|
|
expect(result.prInfo.mergeable).toBe("blocked");
|
|
});
|
|
|
|
it("maps missing mergeability fields to unknown", async () => {
|
|
mockRunGhJsonAsync
|
|
.mockResolvedValueOnce({
|
|
number: 42,
|
|
url: "https://github.com/owner/repo/pull/42",
|
|
title: "Unknown PR",
|
|
state: "OPEN",
|
|
reviewDecision: "APPROVED",
|
|
baseRefName: "main",
|
|
headRefName: "fusion/fn-093",
|
|
})
|
|
.mockResolvedValueOnce([{ name: "ci", state: "SUCCESS" }]);
|
|
|
|
const result = await client.getPrMergeStatus("owner", "repo", 42);
|
|
expect(result.mergeable).toBe("unknown");
|
|
expect(result.prInfo.mergeable).toBe("unknown");
|
|
});
|
|
|
|
it("falls back to GraphQL API when gh CLI merge-status lookup 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({
|
|
data: {
|
|
repository: {
|
|
pullRequest: {
|
|
number: 42,
|
|
url: "https://github.com/owner/repo/pull/42",
|
|
title: "Fallback PR",
|
|
state: "OPEN",
|
|
reviewDecision: null,
|
|
mergeable: "CONFLICTING",
|
|
mergeStateStatus: "DIRTY",
|
|
baseRefName: "main",
|
|
headRefName: "fusion/fn-093",
|
|
comments: { totalCount: 0 },
|
|
commits: {
|
|
nodes: [
|
|
{
|
|
commit: {
|
|
statusCheckRollup: {
|
|
contexts: {
|
|
nodes: [
|
|
{
|
|
__typename: "CheckRun",
|
|
name: "ci",
|
|
status: "COMPLETED",
|
|
conclusion: "SUCCESS",
|
|
detailsUrl: "https://github.com/owner/repo/actions/runs/2",
|
|
startedAt: "2026-01-01T00:00:00Z",
|
|
completedAt: "2026-01-01T00:02:00Z",
|
|
isRequired: true,
|
|
},
|
|
{
|
|
__typename: "CheckRun",
|
|
name: "optional-preview",
|
|
status: "COMPLETED",
|
|
conclusion: "FAILURE",
|
|
detailsUrl: "https://github.com/owner/repo/actions/runs/3",
|
|
isRequired: false,
|
|
},
|
|
{
|
|
__typename: "StatusContext",
|
|
context: "legacy-status",
|
|
state: "SUCCESS",
|
|
targetUrl: "https://github.com/owner/repo/commit/status",
|
|
isRequired: true,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
global.fetch = mockFetch as any;
|
|
|
|
const result = await clientWithToken.getPrMergeStatus("owner", "repo", 42);
|
|
|
|
expect(result.mergeReady).toBe(true);
|
|
expect(result.mergeable).toBe("conflicting");
|
|
expect(result.prInfo.mergeable).toBe("conflicting");
|
|
expect(result.checks).toEqual([
|
|
{
|
|
name: "ci",
|
|
required: true,
|
|
state: "success",
|
|
detailsUrl: "https://github.com/owner/repo/actions/runs/2",
|
|
startedAt: "2026-01-01T00:00:00Z",
|
|
completedAt: "2026-01-01T00:02:00Z",
|
|
},
|
|
{
|
|
name: "legacy-status",
|
|
required: true,
|
|
state: "success",
|
|
detailsUrl: "https://github.com/owner/repo/commit/status",
|
|
},
|
|
]);
|
|
vi.restoreAllMocks();
|
|
});
|
|
});
|
|
|
|
describe("getAllPrChecks", () => {
|
|
it("returns required and non-required checks in gh mode and computes rollup from required checks", async () => {
|
|
mockRunGhJsonAsync.mockResolvedValueOnce([
|
|
{ name: "required-ci", state: "SUCCESS", link: "https://example.com/ci", bucket: "pass" },
|
|
{ name: "optional-preview", state: "FAILURE", link: "https://example.com/preview", bucket: "none" },
|
|
]);
|
|
|
|
const result = await client.getAllPrChecks("owner", "repo", 42);
|
|
|
|
expect(result.rollupRequired).toBe("success");
|
|
expect(result.checks).toEqual([
|
|
{ name: "required-ci", required: true, state: "success", detailsUrl: "https://example.com/ci", startedAt: undefined, completedAt: undefined },
|
|
{ name: "optional-preview", required: false, state: "failure", detailsUrl: "https://example.com/preview", startedAt: undefined, completedAt: undefined },
|
|
]);
|
|
});
|
|
|
|
it("returns all checks in API mode and ignores non-required failures for rollup", async () => {
|
|
const clientWithToken = new GitHubClient("ghp_token");
|
|
mockRunGhJsonAsync.mockRejectedValue(new Error("gh failed"));
|
|
const mockFetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
data: {
|
|
repository: {
|
|
pullRequest: {
|
|
commits: {
|
|
nodes: [
|
|
{
|
|
commit: {
|
|
statusCheckRollup: {
|
|
contexts: {
|
|
nodes: [
|
|
{
|
|
__typename: "CheckRun",
|
|
name: "required-ci",
|
|
status: "COMPLETED",
|
|
conclusion: "SUCCESS",
|
|
detailsUrl: "https://example.com/required",
|
|
isRequired: true,
|
|
},
|
|
{
|
|
__typename: "StatusContext",
|
|
context: "optional-legacy",
|
|
state: "FAILURE",
|
|
targetUrl: "https://example.com/optional",
|
|
isRequired: false,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
global.fetch = mockFetch as any;
|
|
|
|
const result = await clientWithToken.getAllPrChecks("owner", "repo", 42);
|
|
|
|
expect(result.rollupRequired).toBe("success");
|
|
expect(result.checks).toEqual([
|
|
{ name: "required-ci", required: true, state: "success", detailsUrl: "https://example.com/required", startedAt: undefined, completedAt: undefined },
|
|
{ name: "optional-legacy", required: false, state: "failure", detailsUrl: "https://example.com/optional" },
|
|
]);
|
|
vi.restoreAllMocks();
|
|
});
|
|
});
|
|
|
|
describe("FN-5181 PR review pagination", () => {
|
|
it("FN-5181 paginates GraphQL review details across comment and review pages", async () => {
|
|
mockIsGhAvailable.mockReturnValue(false);
|
|
const clientWithToken = new GitHubClient("ghp_token");
|
|
const fetchSpy = vi.spyOn(global, "fetch" as any)
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
statusText: "OK",
|
|
json: async () => ({
|
|
data: {
|
|
repository: {
|
|
pullRequest: {
|
|
reviewDecision: "CHANGES_REQUESTED",
|
|
comments: {
|
|
nodes: [
|
|
{ id: "C_1", body: "first comment", createdAt: "2024-01-01T00:00:00Z", updatedAt: "2024-01-01T00:00:01Z", url: "https://example.com/c1", author: { login: "alice" } },
|
|
],
|
|
pageInfo: { hasNextPage: true, endCursor: "comment-cursor-1" },
|
|
},
|
|
reviews: {
|
|
nodes: [
|
|
{ id: "R_1", state: "COMMENTED", body: "first review", submittedAt: "2024-01-01T00:00:02Z", url: "https://example.com/r1", author: { login: "reviewer-1" } },
|
|
],
|
|
pageInfo: { hasNextPage: true, endCursor: "review-cursor-1" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
} as any)
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
statusText: "OK",
|
|
json: async () => ({
|
|
data: {
|
|
repository: {
|
|
pullRequest: {
|
|
reviewDecision: "CHANGES_REQUESTED",
|
|
comments: {
|
|
nodes: [
|
|
{ id: "C_2", body: "second comment", createdAt: "2024-01-01T00:00:03Z", updatedAt: "2024-01-01T00:00:04Z", url: "https://example.com/c2", author: { login: "bob" } },
|
|
],
|
|
pageInfo: { hasNextPage: false, endCursor: "comment-cursor-2" },
|
|
},
|
|
reviews: {
|
|
nodes: [
|
|
{ id: "R_2", state: "APPROVED", body: "second review", submittedAt: "2024-01-01T00:00:05Z", url: "https://example.com/r2", author: { login: "reviewer-2" } },
|
|
],
|
|
pageInfo: { hasNextPage: false, endCursor: "review-cursor-2" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
} as any);
|
|
|
|
const details = await (clientWithToken as any).getPrReviewDetailsWithApi("owner", "repo", 1);
|
|
|
|
expect(details.reviewDecision).toBe("CHANGES_REQUESTED");
|
|
expect(details.comments.map((comment: any) => comment.id)).toEqual(["C_1", "C_2"]);
|
|
expect(details.reviews.map((review: any) => review.id)).toEqual(["R_1", "R_2"]);
|
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
const firstBody = JSON.parse(String(fetchSpy.mock.calls[0]?.[1]?.body));
|
|
const secondBody = JSON.parse(String(fetchSpy.mock.calls[1]?.[1]?.body));
|
|
expect(firstBody.variables).toEqual(expect.objectContaining({ commentsAfter: null, reviewsAfter: null, fetchComments: true, fetchReviews: true }));
|
|
expect(secondBody.variables).toEqual(expect.objectContaining({ commentsAfter: "comment-cursor-1", reviewsAfter: "review-cursor-1", fetchComments: true, fetchReviews: true }));
|
|
fetchSpy.mockRestore();
|
|
});
|
|
|
|
it("FN-5181 paginates gh review details across issue comments, inline comments, and review pages", async () => {
|
|
const issueCommentPageOne = Array.from({ length: 100 }, (_, index) => ({
|
|
id: `issue-${index + 1}`,
|
|
body: `issue comment ${index + 1}`,
|
|
author: { login: `issue-author-${index + 1}` },
|
|
createdAt: new Date(Date.UTC(2024, 0, 1, 0, 0, index)).toISOString(),
|
|
updatedAt: new Date(Date.UTC(2024, 0, 1, 0, 1, index)).toISOString(),
|
|
url: `https://example.com/issue-${index + 1}`,
|
|
}));
|
|
const reviewPageOne = Array.from({ length: 100 }, (_, index) => ({
|
|
id: `review-${index + 1}`,
|
|
state: "COMMENTED",
|
|
body: `review ${index + 1}`,
|
|
submittedAt: new Date(Date.UTC(2024, 0, 1, 0, 2, index)).toISOString(),
|
|
url: `https://example.com/review-${index + 1}`,
|
|
author: { login: `reviewer-${index + 1}` },
|
|
}));
|
|
|
|
mockRunGhJsonAsync
|
|
.mockResolvedValueOnce({ reviewDecision: "APPROVED" })
|
|
.mockResolvedValueOnce(issueCommentPageOne)
|
|
.mockResolvedValueOnce([
|
|
{ id: "issue-101", body: "issue comment 101", author: { login: "bob" }, createdAt: "2024-01-01T00:10:01Z", updatedAt: "2024-01-01T00:10:02Z", url: "https://example.com/issue-101" },
|
|
])
|
|
.mockResolvedValueOnce([
|
|
{ id: 301, body: "inline comment", user: { login: "carol" }, created_at: "2024-01-01T00:10:03Z", updated_at: "2024-01-01T00:10:04Z", html_url: "https://example.com/inline-301" },
|
|
])
|
|
.mockResolvedValueOnce(reviewPageOne)
|
|
.mockResolvedValueOnce([
|
|
{ id: "review-101", state: "APPROVED", body: "review 101", submittedAt: "2024-01-01T00:10:05Z", url: "https://example.com/review-101", author: { login: "erin" } },
|
|
]);
|
|
|
|
const details = await (client as any).getPrReviewDetailsWithGh("owner", "repo", 1);
|
|
|
|
expect(details.reviewDecision).toBe("APPROVED");
|
|
expect(details.comments).toHaveLength(102);
|
|
expect(details.comments[0]?.id).toBe("issue-1");
|
|
expect(details.comments.at(-1)?.id).toBe("301");
|
|
expect(details.comments.at(-1)?.author.login).toBe("carol");
|
|
expect(details.reviews).toHaveLength(101);
|
|
expect(details.reviews[0]?.id).toBe("review-1");
|
|
expect(details.reviews.at(-1)?.id).toBe("review-101");
|
|
expect(mockRunGhJsonAsync).toHaveBeenNthCalledWith(2, ["api", "repos/owner/repo/issues/1/comments?per_page=100&page=1"]);
|
|
expect(mockRunGhJsonAsync).toHaveBeenNthCalledWith(3, ["api", "repos/owner/repo/issues/1/comments?per_page=100&page=2"]);
|
|
expect(mockRunGhJsonAsync).toHaveBeenNthCalledWith(4, ["api", "repos/owner/repo/pulls/1/comments?per_page=100&page=1"]);
|
|
expect(mockRunGhJsonAsync).toHaveBeenNthCalledWith(5, ["api", "repos/owner/repo/pulls/1/reviews?per_page=100&page=1"]);
|
|
expect(mockRunGhJsonAsync).toHaveBeenNthCalledWith(6, ["api", "repos/owner/repo/pulls/1/reviews?per_page=100&page=2"]);
|
|
});
|
|
});
|
|
|
|
describe("getPrReviewSnapshot", () => {
|
|
it("normalizes reviews/comments into review-state items and summary", async () => {
|
|
mockRunGhJsonAsync
|
|
.mockResolvedValueOnce({ reviewDecision: "CHANGES_REQUESTED" })
|
|
.mockResolvedValueOnce([
|
|
{ id: "c1", body: "nit", createdAt: "2024-01-01T00:00:00Z", updatedAt: "2024-01-01T00:00:01Z", author: { login: "reviewer" }, url: "https://github.com/owner/repo/pull/1#issuecomment-c1" },
|
|
])
|
|
.mockResolvedValueOnce([])
|
|
.mockResolvedValueOnce([
|
|
{ id: "r1", state: "CHANGES_REQUESTED", body: "please fix", submittedAt: "2024-01-01T00:00:00Z", author: { login: "octocat" }, url: "https://github.com/owner/repo/pull/1#review-r1" },
|
|
])
|
|
.mockResolvedValueOnce({
|
|
number: 1,
|
|
url: "https://github.com/owner/repo/pull/1",
|
|
title: "PR",
|
|
state: "OPEN",
|
|
reviewDecision: "CHANGES_REQUESTED",
|
|
baseRefName: "main",
|
|
headRefName: "fn/fn-1",
|
|
})
|
|
.mockResolvedValueOnce([]);
|
|
|
|
const snapshot = await client.getPrReviewSnapshot("owner", "repo", 1);
|
|
expect(snapshot.items).toHaveLength(2);
|
|
expect(snapshot.summary?.reviewDecision).toBe("CHANGES_REQUESTED");
|
|
expect(snapshot.prInfo.number).toBe(1);
|
|
expect(snapshot.commentCount).toBe(1);
|
|
expect(snapshot.summary?.reviewers[0]).toEqual(expect.objectContaining({ login: "octocat", state: "CHANGES_REQUESTED" }));
|
|
});
|
|
|
|
it("falls back to API review details when gh fails and token is available", async () => {
|
|
mockRunGhJsonAsync.mockImplementation(() => {
|
|
throw new Error("gh down");
|
|
});
|
|
const clientWithToken = new GitHubClient("ghp_token");
|
|
const mockFetch = vi.fn()
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({
|
|
data: {
|
|
repository: {
|
|
pullRequest: {
|
|
reviewDecision: "APPROVED",
|
|
comments: { nodes: [{ id: "C_1", body: "lgtm", createdAt: "2024-01-01T00:00:00Z", updatedAt: "2024-01-01T00:00:01Z", url: "https://example.com/c1", author: { login: "bot" } }] },
|
|
reviews: { nodes: [{ id: "R_1", state: "APPROVED", body: "good", submittedAt: "2024-01-01T00:00:00Z", url: "https://example.com/r1", author: { login: "reviewer" } }] },
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({
|
|
data: {
|
|
repository: {
|
|
pullRequest: {
|
|
number: 1,
|
|
url: "https://github.com/owner/repo/pull/1",
|
|
title: "PR",
|
|
state: "OPEN",
|
|
reviewDecision: "APPROVED",
|
|
baseRefName: "main",
|
|
headRefName: "fn/fn-1",
|
|
comments: { totalCount: 1 },
|
|
commits: { nodes: [{ commit: { statusCheckRollup: { contexts: { nodes: [] } } } }] },
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
global.fetch = mockFetch as any;
|
|
|
|
const snapshot = await clientWithToken.getPrReviewSnapshot("owner", "repo", 1);
|
|
expect(snapshot.summary?.reviewDecision).toBe("APPROVED");
|
|
expect(snapshot.items).toHaveLength(2);
|
|
vi.restoreAllMocks();
|
|
});
|
|
});
|
|
|
|
describe("mergePr", () => {
|
|
it("merges a PR with gh CLI and refetches merged status", async () => {
|
|
mockRunGh.mockReturnValue("Merged pull request");
|
|
mockRunGhJsonAsync.mockResolvedValue({
|
|
number: 42,
|
|
url: "https://github.com/owner/repo/pull/42",
|
|
title: "Merged PR",
|
|
state: "MERGED",
|
|
baseRefName: "main",
|
|
headRefName: "fusion/fn-093",
|
|
});
|
|
|
|
const result = await client.mergePr({ owner: "owner", repo: "repo", number: 42, method: "squash" });
|
|
|
|
expect(mockRunGh).toHaveBeenCalledWith([
|
|
"pr", "merge", "42",
|
|
"--repo", "owner/repo",
|
|
"--squash",
|
|
"--delete-branch",
|
|
]);
|
|
expect(result.status).toBe("merged");
|
|
});
|
|
|
|
it("falls back to REST API merge when gh CLI fails and token is available", async () => {
|
|
mockRunGh.mockImplementation(() => {
|
|
throw new Error("gh failed");
|
|
});
|
|
const clientWithToken = new GitHubClient("ghp_token");
|
|
const mockFetch = vi.fn()
|
|
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ merged: true }) })
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
number: 42,
|
|
html_url: "https://github.com/owner/repo/pull/42",
|
|
title: "Merged PR",
|
|
state: "closed",
|
|
merged: true,
|
|
head: { ref: "fusion/fn-093" },
|
|
base: { ref: "main" },
|
|
comments: 0,
|
|
updated_at: "2024-01-01T00:00:00Z",
|
|
}),
|
|
});
|
|
global.fetch = mockFetch as any;
|
|
|
|
const result = await clientWithToken.mergePr({ owner: "owner", repo: "repo", number: 42 });
|
|
|
|
expect(result.status).toBe("merged");
|
|
vi.restoreAllMocks();
|
|
});
|
|
});
|
|
|
|
describe("isPrMergeReady", () => {
|
|
it("blocks closed PRs", () => {
|
|
expect(isPrMergeReady({ status: "closed", reviewDecision: null, checks: [] })).toEqual({
|
|
ready: false,
|
|
blockingReasons: ["PR is closed"],
|
|
});
|
|
});
|
|
|
|
it("blocks changes requested review even when checks pass", () => {
|
|
expect(isPrMergeReady({
|
|
status: "open",
|
|
reviewDecision: "CHANGES_REQUESTED",
|
|
checks: [{ name: "ci", required: true, state: "success" }],
|
|
})).toEqual({
|
|
ready: false,
|
|
blockingReasons: ["changes requested review is active"],
|
|
});
|
|
});
|
|
|
|
it("blocks pending required checks", () => {
|
|
expect(isPrMergeReady({
|
|
status: "open",
|
|
reviewDecision: null,
|
|
checks: [{ name: "ci", required: true, state: "pending" }],
|
|
})).toEqual({
|
|
ready: false,
|
|
blockingReasons: ["required checks not successful: ci (pending)"],
|
|
});
|
|
});
|
|
|
|
it("ignores optional checks when determining readiness", () => {
|
|
expect(isPrMergeReady({
|
|
status: "open",
|
|
reviewDecision: "REVIEW_REQUIRED",
|
|
checks: [
|
|
{ name: "required-ci", required: true, state: "success" },
|
|
{ name: "optional-preview", required: false, state: "failure" },
|
|
],
|
|
})).toEqual({ ready: true, blockingReasons: [] });
|
|
});
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|
|
|
|
describe("fetchThrottled", () => {
|
|
let fetchSpy: ReturnType<typeof vi.fn>;
|
|
const originalFetch = globalThis.fetch;
|
|
|
|
beforeEach(() => {
|
|
fetchSpy = vi.fn();
|
|
globalThis.fetch = fetchSpy as any;
|
|
});
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
});
|
|
|
|
it("returns success with data on successful request", async () => {
|
|
const mockData = { id: 1, title: "Test Issue" };
|
|
fetchSpy.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve(mockData),
|
|
} as Response);
|
|
|
|
const result = await client.fetchThrottled("https://api.github.com/repos/owner/repo/issues/1");
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data).toEqual(mockData);
|
|
expect(result.error).toBeUndefined();
|
|
});
|
|
|
|
it("returns error on non-429 HTTP error without retry", async () => {
|
|
fetchSpy.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 404,
|
|
statusText: "Not Found",
|
|
json: () => Promise.resolve({ message: "Not Found" }),
|
|
} as Response);
|
|
|
|
const result = await client.fetchThrottled("https://api.github.com/repos/owner/repo/issues/1");
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain("404");
|
|
expect(fetchSpy).toHaveBeenCalledTimes(1); // No retries for non-429 errors
|
|
});
|
|
|
|
it("retries on 429 with exponential backoff", async () => {
|
|
fetchSpy
|
|
.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 429,
|
|
statusText: "Too Many Requests",
|
|
headers: new Headers(),
|
|
json: () => Promise.resolve({ message: "Rate limited" }),
|
|
} as Response)
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve({ id: 1 }),
|
|
} as Response);
|
|
|
|
const result = await client.fetchThrottled(
|
|
"https://api.github.com/repos/owner/repo/issues/1",
|
|
{},
|
|
{ delayMs: 10, maxRetries: 3 }
|
|
);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data).toEqual({ id: 1 });
|
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("respects Retry-After header on 429", async () => {
|
|
vi.useFakeTimers();
|
|
const headers = new Headers();
|
|
headers.set("Retry-After", "1");
|
|
|
|
fetchSpy
|
|
.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 429,
|
|
statusText: "Too Many Requests",
|
|
headers,
|
|
json: () => Promise.resolve({ message: "Rate limited" }),
|
|
} as Response)
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve({ id: 1 }),
|
|
} as Response);
|
|
|
|
const resultPromise = client.fetchThrottled(
|
|
"https://api.github.com/repos/owner/repo/issues/1",
|
|
{},
|
|
{ delayMs: 100, maxRetries: 3 }
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(999);
|
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
const result = await resultPromise;
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("returns error with retryAfter after max retries exceeded", async () => {
|
|
vi.useFakeTimers();
|
|
const headers = new Headers();
|
|
headers.set("Retry-After", "1");
|
|
|
|
// All attempts return 429
|
|
fetchSpy.mockResolvedValue({
|
|
ok: false,
|
|
status: 429,
|
|
statusText: "Too Many Requests",
|
|
headers,
|
|
json: () => Promise.resolve({ message: "Rate limited" }),
|
|
} as Response);
|
|
|
|
const resultPromise = client.fetchThrottled(
|
|
"https://api.github.com/repos/owner/repo/issues/1",
|
|
{},
|
|
{ delayMs: 1, maxRetries: 2 }
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(2000);
|
|
const result = await resultPromise;
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain("rate limit exceeded");
|
|
expect(result.retryAfter).toBe(1);
|
|
expect(fetchSpy).toHaveBeenCalledTimes(3); // initial + 2 retries
|
|
});
|
|
|
|
it("retries on network errors with exponential backoff", async () => {
|
|
fetchSpy
|
|
.mockRejectedValueOnce(new Error("Network error"))
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve({ id: 1 }),
|
|
} as Response);
|
|
|
|
const result = await client.fetchThrottled(
|
|
"https://api.github.com/repos/owner/repo/issues/1",
|
|
{},
|
|
{ delayMs: 10, maxRetries: 3 }
|
|
);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data).toEqual({ id: 1 });
|
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("returns error after max retries on persistent network errors", async () => {
|
|
fetchSpy.mockRejectedValue(new Error("Network error"));
|
|
|
|
const result = await client.fetchThrottled(
|
|
"https://api.github.com/repos/owner/repo/issues/1",
|
|
{},
|
|
{ delayMs: 1, maxRetries: 2 }
|
|
);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe("Network error");
|
|
expect(fetchSpy).toHaveBeenCalledTimes(3); // initial + 2 retries
|
|
});
|
|
|
|
it("enforces delay between sequential requests", async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-01-01T00:00:01.000Z"));
|
|
|
|
fetchSpy.mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve({ id: 1 }),
|
|
} as Response);
|
|
|
|
// First request
|
|
await client.fetchThrottled(
|
|
"https://api.github.com/repos/owner/repo/issues/1",
|
|
{},
|
|
{ delayMs: 100 }
|
|
);
|
|
|
|
// Second request should be delayed
|
|
const resultPromise = client.fetchThrottled(
|
|
"https://api.github.com/repos/owner/repo/issues/2",
|
|
{},
|
|
{ delayMs: 100 }
|
|
);
|
|
|
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
await vi.advanceTimersByTimeAsync(99);
|
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await resultPromise;
|
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("uses custom delayMs option", async () => {
|
|
fetchSpy.mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve({ id: 1 }),
|
|
} as Response);
|
|
|
|
const startTime = Date.now();
|
|
await client.fetchThrottled(
|
|
"https://api.github.com/repos/owner/repo/issues/1",
|
|
{},
|
|
{ delayMs: 200 }
|
|
);
|
|
const elapsed = Date.now() - startTime;
|
|
|
|
// Should be relatively quick since no previous request
|
|
expect(elapsed).toBeLessThan(100);
|
|
});
|
|
|
|
it("uses custom maxRetries option", async () => {
|
|
fetchSpy.mockRejectedValue(new Error("Network error"));
|
|
|
|
await client.fetchThrottled(
|
|
"https://api.github.com/repos/owner/repo/issues/1",
|
|
{},
|
|
{ delayMs: 1, maxRetries: 1 }
|
|
);
|
|
|
|
expect(fetchSpy).toHaveBeenCalledTimes(2); // initial + 1 retry
|
|
});
|
|
});
|
|
});
|