FN-5643: fix gh api --json flag handling for PR fetches

Fusion-Task-Id: FN-5643

Fusion-Task-Lineage: 2eaba342-1ac7-4ebf-886e-acd22af637ee
This commit is contained in:
gsxdsm
2026-05-28 22:59:18 -07:00
parent aa7eccbadb
commit d78fbccacc
3 changed files with 91 additions and 6 deletions

View File

@@ -1,7 +1,8 @@
import { describe, it, expect, vi } from "vitest";
const { mockExecFile } = vi.hoisted(() => ({
const { mockExecFile, mockExecFileSync } = vi.hoisted(() => ({
mockExecFile: vi.fn(),
mockExecFileSync: vi.fn(),
}));
// Mock child_process before importing gh-cli so runGhAsync's `execFile`
@@ -9,7 +10,7 @@ const { mockExecFile } = vi.hoisted(() => ({
// is what we need to exercise; the synchronous helpers don't go through it.
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return { ...actual, execFile: mockExecFile };
return { ...actual, execFile: mockExecFile, execFileSync: mockExecFileSync };
});
import {
@@ -17,6 +18,8 @@ import {
getGhErrorMessage,
parseRepoFromRemote,
runGhAsync,
runGhJson,
runGhJsonAsync,
type GhErrorCode,
} from "../gh-cli.js";
@@ -275,6 +278,77 @@ describe("gh-cli functions (inline tests)", () => {
});
});
describe("runGhJson / runGhJsonAsync", () => {
it("does not append --json for gh api async", async () => {
mockExecFile.mockReset();
mockExecFile.mockImplementation((_bin, args, _opts, callback) => {
callback?.(null, "[]", "");
expect(args).toEqual(["api", "repos/o/r/issues/1/comments?per_page=100&page=1"]);
return {} as ReturnType<typeof import("node:child_process").execFile>;
});
const result = await runGhJsonAsync<unknown[]>(["api", "repos/o/r/issues/1/comments?per_page=100&page=1"]);
expect(result).toEqual([]);
});
it("auto-appends --json once for non-api async commands", async () => {
mockExecFile.mockReset();
mockExecFile.mockImplementation((_bin, args, _opts, callback) => {
callback?.(null, '{"ok":true}', "");
expect(args).toEqual(["pr", "view", "1", "--repo", "o/r", "--json"]);
return {} as ReturnType<typeof import("node:child_process").execFile>;
});
const result = await runGhJsonAsync<{ ok: boolean }>(["pr", "view", "1", "--repo", "o/r"]);
expect(result).toEqual({ ok: true });
});
it("does not double-append --json for async commands", async () => {
mockExecFile.mockReset();
mockExecFile.mockImplementation((_bin, args, _opts, callback) => {
callback?.(null, '{"ok":true}', "");
expect(args).toEqual(["pr", "view", "1", "--json", "reviewDecision"]);
return {} as ReturnType<typeof import("node:child_process").execFile>;
});
const result = await runGhJsonAsync<{ ok: boolean }>(["pr", "view", "1", "--json", "reviewDecision"]);
expect(result).toEqual({ ok: true });
});
it("mirrors api/non-api/no-double-append behavior for sync commands", () => {
mockExecFileSync.mockReset();
mockExecFileSync
.mockReturnValueOnce('{"api":true}')
.mockReturnValueOnce('{"pr":true}')
.mockReturnValueOnce('{"already":true}');
expect(runGhJson<{ api: boolean }>(["api", "repos/o/r/issues/1/comments?per_page=100&page=1"])).toEqual({ api: true });
expect(mockExecFileSync).toHaveBeenNthCalledWith(1, "gh", ["api", "repos/o/r/issues/1/comments?per_page=100&page=1"], expect.any(Object));
expect(runGhJson<{ pr: boolean }>(["pr", "view", "1", "--repo", "o/r"])).toEqual({ pr: true });
expect(mockExecFileSync).toHaveBeenNthCalledWith(2, "gh", ["pr", "view", "1", "--repo", "o/r", "--json"], expect.any(Object));
expect(runGhJson<{ already: boolean }>(["pr", "view", "1", "--json", "reviewDecision"])).toEqual({ already: true });
expect(mockExecFileSync).toHaveBeenNthCalledWith(3, "gh", ["pr", "view", "1", "--json", "reviewDecision"], expect.any(Object));
});
it("throws the existing parse error message on invalid JSON", async () => {
mockExecFileSync.mockReset();
mockExecFileSync.mockReturnValue("not-json");
expect(() => runGhJson(["pr", "view", "1"]))
.toThrowError(/Failed to parse gh JSON output:/);
mockExecFile.mockReset();
mockExecFile.mockImplementation((_bin, _args, _opts, callback) => {
callback?.(null, "not-json", "");
return {} as ReturnType<typeof import("node:child_process").execFile>;
});
await expect(runGhJsonAsync(["pr", "view", "1"]))
.rejects.toThrowError(/Failed to parse gh JSON output:/);
});
});
describe("runGhAsync timeout / abort", () => {
// Each test wires execFile to capture the AbortSignal from gh-cli's
// internal controller and the user callback, then drives the abort path