import { execFileSync } from "node:child_process"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { GitHubAuthenticationError, GitHubCliMissingError, GitHubCommandError, computeGithubNextInterval, createGitHubService, resolveGitHubRepo, type GitHubCommandRunner, type GitHubCommandRunnerOptions, type GitHubCurrentPullRequestStatus, type GitHubReadOptions, } from "./github-service.js"; import { CheckoutPrStatusResponseSchema } from "../shared/messages.js"; const EXPECTED_GITHUB_FAST_POLL_MS = 20_000; const EXPECTED_GITHUB_SLOW_POLL_MS = 120_000; const EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS = 300_000; const CURRENT_PR_STATUS_BASE_FIELDS = "number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,reviewDecision,mergeable,headRepositoryOwner"; const CURRENT_PR_STATUS_FIELDS = `${CURRENT_PR_STATUS_BASE_FIELDS},statusCheckRollup`; interface RunnerCall { args: string[]; cwd: string; envOverlay?: Record; } interface TestRunner { calls: RunnerCall[]; runner: GitHubCommandRunner; resolveNext: (stdout: string) => void; } type RunnerStep = | string | { stdout?: string; stderr?: string; error?: Error; }; function createRunner(stdoutByCall: string[]): TestRunner { const calls: RunnerCall[] = []; return { calls, runner: async (args: string[], options: GitHubCommandRunnerOptions) => { calls.push({ args, cwd: options.cwd, envOverlay: options.envOverlay }); const stdout = stdoutByCall.shift() ?? "[]"; return { stdout, stderr: "" }; }, resolveNext: () => {}, }; } function createScriptedRunner(steps: RunnerStep[]): TestRunner { const calls: RunnerCall[] = []; return { calls, runner: async (args: string[], options: GitHubCommandRunnerOptions) => { calls.push({ args, cwd: options.cwd, envOverlay: options.envOverlay }); const step = steps.shift() ?? ""; if (typeof step === "string") { return { stdout: step, stderr: "" }; } if (step.error) { throw step.error; } return { stdout: step.stdout ?? "", stderr: step.stderr ?? "" }; }, resolveNext: () => {}, }; } function createDeferredRunner(): TestRunner { const calls: RunnerCall[] = []; let resolveNext: ((stdout: string) => void) | null = null; return { calls, runner: (args: string[], options: GitHubCommandRunnerOptions) => { calls.push({ args, cwd: options.cwd, envOverlay: options.envOverlay }); return new Promise((resolve) => { resolveNext = (stdout: string) => resolve({ stdout, stderr: "" }); }); }, resolveNext: (stdout: string) => { if (!resolveNext) { throw new Error("No runner call is waiting for resolution."); } resolveNext(stdout); }, }; } function currentPullRequestJson(overrides: Record = {}): string { return JSON.stringify({ number: 42, url: "https://github.com/parentOwner/parentRepo/pull/42", title: "Fork PR", state: "OPEN", isDraft: false, baseRefName: "main", headRefName: "feature/fork", mergedAt: null, statusCheckRollup: [], reviewDecision: "REVIEW_REQUIRED", ...overrides, }); } function currentPullRequestGithubFactsJson(overrides: Record = {}): string { return JSON.stringify({ data: { repository: { autoMergeAllowed: true, mergeCommitAllowed: false, squashMergeAllowed: true, rebaseMergeAllowed: false, viewerDefaultMergeMethod: "SQUASH", pullRequest: { mergeStateStatus: "BLOCKED", autoMergeRequest: null, viewerCanEnableAutoMerge: true, viewerCanDisableAutoMerge: false, viewerCanMergeAsAdmin: false, viewerCanUpdateBranch: true, isMergeQueueEnabled: false, isInMergeQueue: false, }, ...overrides, }, }, }); } function createCurrentPullRequestStatus( overrides: Partial = {}, ): GitHubCurrentPullRequestStatus { return { number: 42, repoOwner: "acme", repoName: "repo", url: "https://github.com/acme/repo/pull/42", title: "Update feature", state: "open", baseRefName: "main", headRefName: "feature", isMerged: false, isDraft: false, mergeable: "MERGEABLE", checks: [], checksStatus: "none", reviewDecision: null, ...overrides, }; } function githubStatusFacts( overrides: Partial> = {}, ): NonNullable { return { mergeStateStatus: "CLEAN", autoMergeRequest: null, viewerCanEnableAutoMerge: false, viewerCanDisableAutoMerge: false, viewerCanMergeAsAdmin: false, viewerCanUpdateBranch: false, repository: { autoMergeAllowed: true, mergeCommitAllowed: true, squashMergeAllowed: true, rebaseMergeAllowed: true, viewerDefaultMergeMethod: "SQUASH", }, isMergeQueueEnabled: false, isInMergeQueue: false, ...overrides, }; } function recordCurrentPullRequestStatusReads(service: ReturnType) { const reads: GitHubReadOptions[] = []; const getCurrentPullRequestStatus = service.getCurrentPullRequestStatus.bind(service); service.getCurrentPullRequestStatus = vi.fn(async (options) => { reads.push(options); return getCurrentPullRequestStatus(options); }); return reads; } function currentPullRequestStatusCalls(calls: RunnerCall[]): RunnerCall[] { return calls.filter( (call) => call.args[0] === "pr" && (call.args[1] === "view" || call.args[1] === "list"), ); } function noPullRequestError(args: string[] = ["pr", "view"]): GitHubCommandError { return new GitHubCommandError({ args, cwd: "/repo", exitCode: 1, stderr: "no pull requests found for branch", }); } function statusCheckRollupPermissionError(args: string[]): GitHubCommandError { return new GitHubCommandError({ args, cwd: "/repo", exitCode: 1, stderr: "GraphQL: Resource not accessible by personal access token (repository.pullRequest.statusCheckRollup)", }); } function pullRequestJson(title: string): string { return JSON.stringify([ { number: 123, title, url: "https://github.com/acme/repo/pull/123", state: "OPEN", baseRefName: "main", headRefName: "feature", labels: [{ name: "bug" }], }, ]); } function pullRequestCheckoutTargetJson(): string { return JSON.stringify({ data: { repository: { pullRequest: { number: 526, baseRefName: "main", headRefName: "main", isCrossRepository: true, headRepositoryOwner: { login: "therainisme" }, headRepository: { sshUrl: "git@github.com:therainisme/paseo.git", url: "https://github.com/therainisme/paseo", }, }, }, }, }); } function repoViewJson(): string { return JSON.stringify({ owner: { login: "getpaseo" }, name: "paseo", parent: null, }); } function issueJson(title: string): string { return JSON.stringify([ { number: 55, title, url: "https://github.com/acme/repo/issues/55", state: "OPEN", body: "issue body", labels: [{ name: "bug" }], updatedAt: "2026-04-18T12:00:00Z", }, ]); } function searchPullRequestJson(title: string): string { return JSON.stringify([ { number: 123, title, url: "https://github.com/acme/repo/pull/123", state: "OPEN", body: "pr body", baseRefName: "main", headRefName: "feature", labels: [{ name: "enhancement" }], updatedAt: "2026-04-18T13:00:00Z", }, ]); } function pullRequestTimelineJson(overrides: Record = {}): string { return JSON.stringify({ data: { repository: { pullRequest: { number: 42, reviews: { nodes: [ { id: "PRR_approved", state: "APPROVED", body: "Looks good to me.", url: "https://github.com/parentOwner/parentRepo/pull/42#pullrequestreview-1", submittedAt: "2026-04-02T13:52:14Z", author: { login: "reviewer", url: "https://github.com/reviewer", }, }, { id: "PRR_empty_commented", state: "COMMENTED", body: "", url: "https://github.com/parentOwner/parentRepo/pull/42#pullrequestreview-2", submittedAt: "2026-04-02T13:50:00Z", author: null, }, ], pageInfo: { hasNextPage: false }, }, comments: { nodes: [ { id: "IC_later", body: "Can we add a regression test?", url: "https://github.com/parentOwner/parentRepo/pull/42#issuecomment-3", createdAt: "2026-04-02T13:55:00Z", author: { login: "commenter", url: "https://github.com/commenter", }, }, ], pageInfo: { hasNextPage: false }, }, ...overrides, }, }, }, }); } describe("GitHubService", () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); it.each([ ["merge", ["pr", "merge", "42", "--merge"]], ["squash", ["pr", "merge", "42", "--squash"]], ["rebase", ["pr", "merge", "42", "--rebase"]], ] as const)("merges pull requests with gh using %s", async (mergeMethod, expectedArgs) => { const runner = createRunner([""]); const service = createGitHubService({ runner: runner.runner, }); await expect( service.mergePullRequest({ cwd: "/tmp/repo", prNumber: 42, mergeMethod, status: createCurrentPullRequestStatus({ github: githubStatusFacts(), }), }), ).resolves.toEqual({ success: true }); expect(runner.calls).toEqual([ { args: expectedArgs, cwd: "/tmp/repo", envOverlay: { GH_PROMPT_DISABLED: "1" }, }, ]); }); it("rejects direct merge when GitHub facts are unavailable", async () => { const runner = createRunner([""]); const service = createGitHubService({ runner: runner.runner, }); await expect( service.mergePullRequest({ cwd: "/tmp/repo", prNumber: 42, mergeMethod: "squash", status: createCurrentPullRequestStatus(), }), ).rejects.toThrow("GitHub merge facts are unavailable"); expect(runner.calls).toEqual([]); }); it.each(["BLOCKED", "DIRTY", null] as const)( "rejects direct merge when GitHub mergeStateStatus is %s", async (mergeStateStatus) => { const runner = createRunner([""]); const service = createGitHubService({ runner: runner.runner, }); await expect( service.mergePullRequest({ cwd: "/tmp/repo", prNumber: 42, mergeMethod: "squash", status: createCurrentPullRequestStatus({ github: githubStatusFacts({ mergeStateStatus }), }), }), ).rejects.toThrow("ready for direct merge"); expect(runner.calls).toEqual([]); }, ); it.each([ ["merge queue enabled", { isMergeQueueEnabled: true }], ["PR already in merge queue", { isInMergeQueue: true }], ] as const)("rejects direct merge when %s", async (_name, overrides) => { const runner = createRunner([""]); const service = createGitHubService({ runner: runner.runner, }); await expect( service.mergePullRequest({ cwd: "/tmp/repo", prNumber: 42, mergeMethod: "squash", status: createCurrentPullRequestStatus({ github: githubStatusFacts(overrides), }), }), ).rejects.toThrow("merge queue"); expect(runner.calls).toEqual([]); }); it("rejects direct merge when auto-merge is already enabled", async () => { const runner = createRunner([""]); const service = createGitHubService({ runner: runner.runner, }); await expect( service.mergePullRequest({ cwd: "/tmp/repo", prNumber: 42, mergeMethod: "squash", status: createCurrentPullRequestStatus({ github: githubStatusFacts({ autoMergeRequest: { enabledAt: "2026-05-13T12:00:00Z", mergeMethod: "SQUASH", enabledBy: "octocat", }, }), }), }), ).rejects.toThrow("auto-merge is already enabled"); expect(runner.calls).toEqual([]); }); it("rejects direct merge when the requested method is disabled by repository policy", async () => { const runner = createRunner([""]); const service = createGitHubService({ runner: runner.runner, }); await expect( service.mergePullRequest({ cwd: "/tmp/repo", prNumber: 42, mergeMethod: "squash", status: createCurrentPullRequestStatus({ github: githubStatusFacts({ repository: { autoMergeAllowed: true, mergeCommitAllowed: true, squashMergeAllowed: false, rebaseMergeAllowed: true, viewerDefaultMergeMethod: "MERGE", }, }), }), }), ).rejects.toThrow("squash is disabled"); expect(runner.calls).toEqual([]); }); it.each([ ["merge", ["pr", "merge", "42", "--auto", "--merge"]], ["squash", ["pr", "merge", "42", "--auto", "--squash"]], ["rebase", ["pr", "merge", "42", "--auto", "--rebase"]], ] as const)("enables auto-merge with gh using %s", async (mergeMethod, expectedArgs) => { const runner = createRunner([""]); const service = createGitHubService({ runner: runner.runner, }); await expect( service.enablePullRequestAutoMerge({ cwd: "/tmp/repo", prNumber: 42, mergeMethod, status: createCurrentPullRequestStatus({ github: githubStatusFacts({ mergeStateStatus: "BLOCKED", viewerCanEnableAutoMerge: true, repository: { autoMergeAllowed: true, mergeCommitAllowed: true, squashMergeAllowed: true, rebaseMergeAllowed: true, viewerDefaultMergeMethod: "SQUASH", }, }), }), }), ).resolves.toEqual({ success: true }); expect(runner.calls).toEqual([ { args: expectedArgs, cwd: "/tmp/repo", envOverlay: { GH_PROMPT_DISABLED: "1" }, }, ]); }); it("disables auto-merge with gh", async () => { const runner = createRunner([""]); const service = createGitHubService({ runner: runner.runner, }); await expect( service.disablePullRequestAutoMerge({ cwd: "/tmp/repo", prNumber: 42, status: createCurrentPullRequestStatus({ github: githubStatusFacts({ autoMergeRequest: { enabledAt: "2026-05-13T12:00:00Z", mergeMethod: "SQUASH", enabledBy: "octocat", }, viewerCanDisableAutoMerge: true, }), }), }), ).resolves.toEqual({ success: true }); expect(runner.calls).toEqual([ { args: ["pr", "merge", "42", "--disable-auto"], cwd: "/tmp/repo", envOverlay: { GH_PROMPT_DISABLED: "1" }, }, ]); }); it("computes fast cadence for pending and slow cadence for stable PR states", () => { const pendingStatus = createCurrentPullRequestStatus({ checksStatus: "pending" }); const runningCheckStatus = createCurrentPullRequestStatus({ checksStatus: "success", checks: [{ name: "ci", status: "pending", url: null }], }); const stableStatus = createCurrentPullRequestStatus({ checksStatus: "success" }); expect(computeGithubNextInterval(pendingStatus, 0)).toBe(EXPECTED_GITHUB_FAST_POLL_MS); expect(computeGithubNextInterval(runningCheckStatus, 0)).toBe(EXPECTED_GITHUB_FAST_POLL_MS); expect(computeGithubNextInterval(stableStatus, 0)).toBe(EXPECTED_GITHUB_SLOW_POLL_MS); expect(computeGithubNextInterval(null, 0)).toBe(EXPECTED_GITHUB_SLOW_POLL_MS); }); it("computes exponential error backoff up to the cap", () => { const stableStatus = createCurrentPullRequestStatus({ checksStatus: "success" }); expect(computeGithubNextInterval(stableStatus, 1)).toBe(EXPECTED_GITHUB_SLOW_POLL_MS); expect(computeGithubNextInterval(stableStatus, 2)).toBe(240_000); expect(computeGithubNextInterval(stableStatus, 3)).toBe(EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS); expect(computeGithubNextInterval(stableStatus, 4)).toBe(EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS); }); it("loads pull request checkout target details through GraphQL", async () => { const runner = createRunner([repoViewJson(), pullRequestCheckoutTargetJson()]); const service = createGitHubService({ runner: runner.runner, resolveGhPath: async () => "/usr/bin/gh", now: () => 100, }); await expect( service.getPullRequestCheckoutTarget?.({ cwd: "/repo", number: 526 }), ).resolves.toEqual({ number: 526, baseRefName: "main", headRefName: "main", headOwnerLogin: "therainisme", headRepositorySshUrl: "git@github.com:therainisme/paseo.git", headRepositoryUrl: "https://github.com/therainisme/paseo", isCrossRepository: true, }); expect(runner.calls).toHaveLength(2); expect(runner.calls[0]).toEqual({ cwd: "/repo", args: ["repo", "view", "--json", "owner,name,parent"], }); expect(runner.calls[1]?.cwd).toBe("/repo"); expect(runner.calls[1]?.args.slice(0, 3)).toEqual(["api", "graphql", "-f"]); expect(runner.calls[1]?.args).toContain("owner=getpaseo"); expect(runner.calls[1]?.args).toContain("name=paseo"); expect(runner.calls[1]?.args).toContain("number=526"); }); it("polls PR status at fast cadence while checks are pending", async () => { let now = 0; const runner = createRunner([ currentPullRequestJson({ statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "PENDING" }], }), currentPullRequestJson({ statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "PENDING" }], }), ]); const service = createGitHubService({ ttlMs: 0, runner: runner.runner, resolveGhPath: async () => "/usr/bin/gh", now: () => now, }); const reads = recordCurrentPullRequestStatusReads(service); const subscription = service.retainCurrentPullRequestStatusPoll?.({ cwd: "/repo", headRef: "feature/fork", }); await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" }); now = EXPECTED_GITHUB_FAST_POLL_MS; await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS); expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(2); expect(reads.map((read) => read.reason)).toEqual([undefined, "self-heal-github"]); subscription?.unsubscribe(); service.dispose?.(); }); it("polls PR status at slow cadence after stable checks", async () => { let now = 0; const runner = createRunner([ currentPullRequestJson({ statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "SUCCESS" }], }), currentPullRequestJson({ statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "SUCCESS" }], }), ]); const service = createGitHubService({ ttlMs: 0, runner: runner.runner, resolveGhPath: async () => "/usr/bin/gh", now: () => now, }); const reads = recordCurrentPullRequestStatusReads(service); const subscription = service.retainCurrentPullRequestStatusPoll?.({ cwd: "/repo", headRef: "feature/fork", }); await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" }); now = EXPECTED_GITHUB_FAST_POLL_MS; await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS); expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1); now = EXPECTED_GITHUB_SLOW_POLL_MS; await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_SLOW_POLL_MS - EXPECTED_GITHUB_FAST_POLL_MS); expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(2); expect(reads.map((read) => read.reason)).toEqual([undefined, "self-heal-github"]); subscription?.unsubscribe(); service.dispose?.(); }); it("backs off consecutive poll errors and resets cadence after recovery", async () => { let now = 0; const runner = createScriptedRunner([ currentPullRequestJson({ statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "PENDING" }], }), currentPullRequestGithubFactsJson(), { error: new Error("network down") }, { error: new Error("network still down") }, currentPullRequestJson({ statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "SUCCESS" }], }), currentPullRequestGithubFactsJson(), currentPullRequestJson({ statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "SUCCESS" }], }), currentPullRequestGithubFactsJson(), ]); const service = createGitHubService({ ttlMs: 0, runner: runner.runner, resolveGhPath: async () => "/usr/bin/gh", now: () => now, }); const reads = recordCurrentPullRequestStatusReads(service); const subscription = service.retainCurrentPullRequestStatusPoll?.({ cwd: "/repo", headRef: "feature/fork", }); await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" }); now = EXPECTED_GITHUB_FAST_POLL_MS; await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS); now += EXPECTED_GITHUB_FAST_POLL_MS; await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS); now += EXPECTED_GITHUB_FAST_POLL_MS * 2; await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS * 2); now += EXPECTED_GITHUB_SLOW_POLL_MS; await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_SLOW_POLL_MS); expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(5); expect(reads.map((read) => read.reason)).toEqual([ undefined, "self-heal-github", "self-heal-github", "self-heal-github", "self-heal-github", ]); subscription?.unsubscribe(); service.dispose?.(); }); it("unsubscribe clears the adaptive GitHub poll timer", async () => { let now = 0; const runner = createRunner([ currentPullRequestJson({ statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "PENDING" }], }), ]); const service = createGitHubService({ ttlMs: 0, runner: runner.runner, resolveGhPath: async () => "/usr/bin/gh", now: () => now, }); const subscription = service.retainCurrentPullRequestStatusPoll?.({ cwd: "/repo", headRef: "feature/fork", }); await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" }); subscription?.unsubscribe(); now = EXPECTED_GITHUB_FAST_POLL_MS; await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS); expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1); service.dispose?.(); }); it("dispose clears all adaptive GitHub poll timers", async () => { let now = 0; const runner = createRunner([ currentPullRequestJson({ statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "PENDING" }], }), ]); const service = createGitHubService({ ttlMs: 0, runner: runner.runner, resolveGhPath: async () => "/usr/bin/gh", now: () => now, }); service.retainCurrentPullRequestStatusPoll?.({ cwd: "/repo", headRef: "feature/fork" }); await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" }); service.dispose?.(); now = EXPECTED_GITHUB_FAST_POLL_MS; await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS); expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1); }); it("fetches PR reviews and issue comments with one GraphQL call sorted chronologically", async () => { const runner = createRunner([pullRequestTimelineJson()]); const service = createGitHubService({ runner: runner.runner, resolveGhPath: async () => "/usr/bin/gh", now: () => 100, }); const timeline = await service.getPullRequestTimeline({ cwd: "/repo", prNumber: 42, repoOwner: "parentOwner", repoName: "parentRepo", }); expect(runner.calls).toHaveLength(1); expect(runner.calls[0]).toMatchObject({ cwd: "/repo", args: [ "api", "graphql", "-f", expect.stringContaining("query="), "-F", "owner=parentOwner", "-F", "name=parentRepo", "-F", "number=42", ], }); expect(runner.calls[0]?.args[3]).toContain("reviews(first: 100)"); expect(runner.calls[0]?.args[3]).toContain("comments(first: 100)"); expect(timeline).toEqual({ prNumber: 42, repoOwner: "parentOwner", repoName: "parentRepo", truncated: false, error: null, items: [ { kind: "review", id: "PRR_empty_commented", author: "unknown", authorUrl: null, body: "", createdAt: Date.parse("2026-04-02T13:50:00Z"), url: "https://github.com/parentOwner/parentRepo/pull/42#pullrequestreview-2", reviewState: "commented", }, { kind: "review", id: "PRR_approved", author: "reviewer", authorUrl: "https://github.com/reviewer", body: "Looks good to me.", createdAt: Date.parse("2026-04-02T13:52:14Z"), url: "https://github.com/parentOwner/parentRepo/pull/42#pullrequestreview-1", reviewState: "approved", }, { kind: "comment", id: "IC_later", author: "commenter", authorUrl: "https://github.com/commenter", body: "Can we add a regression test?", createdAt: Date.parse("2026-04-02T13:55:00Z"), url: "https://github.com/parentOwner/parentRepo/pull/42#issuecomment-3", }, ], }); }); it("uses the passed parent repository identity for fork PR timelines", async () => { const runner = createRunner([pullRequestTimelineJson()]); const service = createGitHubService({ runner: runner.runner, resolveGhPath: async () => "/usr/bin/gh", now: () => 100, }); await service.getPullRequestTimeline({ cwd: "/local/fork", prNumber: 42, repoOwner: "parentOwner", repoName: "parentRepo", }); expect(runner.calls[0]?.args).toEqual([ "api", "graphql", "-f", expect.stringContaining("repository(owner: $owner, name: $name)"), "-F", "owner=parentOwner", "-F", "name=parentRepo", "-F", "number=42", ]); }); it("marks PR timeline results truncated when reviews or comments hit the pagination cap", async () => { const runner = createRunner([ pullRequestTimelineJson({ reviews: { nodes: [], pageInfo: { hasNextPage: true }, }, comments: { nodes: [], pageInfo: { hasNextPage: false }, }, }), ]); const service = createGitHubService({ runner: runner.runner, resolveGhPath: async () => "/usr/bin/gh", now: () => 100, }); const timeline = await service.getPullRequestTimeline({ cwd: "/repo", prNumber: 42, repoOwner: "parentOwner", repoName: "parentRepo", }); expect(timeline).toMatchObject({ items: [], truncated: true, error: null, }); }); it("maps PR timeline GraphQL access failures to typed internal errors", async () => { const runner = createScriptedRunner([ { error: new GitHubCommandError({ args: ["api", "graphql"], cwd: "/repo", exitCode: 1, stderr: "GraphQL: Resource not accessible by integration", }), }, ]); const service = createGitHubService({ runner: runner.runner, resolveGhPath: async () => "/usr/bin/gh", now: () => 100, }); const timeline = await service.getPullRequestTimeline({ cwd: "/repo", prNumber: 42, repoOwner: "parentOwner", repoName: "parentRepo", }); expect(timeline).toEqual({ prNumber: 42, repoOwner: "parentOwner", repoName: "parentRepo", items: [], truncated: false, error: { kind: "forbidden", message: "GraphQL: Resource not accessible by integration", }, }); }); it("maps PR timeline missing PR failures to typed internal errors", async () => { const runner = createScriptedRunner([ { error: new GitHubCommandError({ args: ["api", "graphql"], cwd: "/repo", exitCode: 1, stderr: "GraphQL: Could not resolve to a PullRequest", }), }, ]); const service = createGitHubService({ runner: runner.runner, resolveGhPath: async () => "/usr/bin/gh", now: () => 100, }); const timeline = await service.getPullRequestTimeline({ cwd: "/repo", prNumber: 404, repoOwner: "parentOwner", repoName: "parentRepo", }); expect(timeline.error).toEqual({ kind: "not_found", message: "GraphQL: Could not resolve to a PullRequest", }); }); it("does not classify unrelated not found failures as missing PR timeline errors", async () => { const runner = createScriptedRunner([ { error: new GitHubCommandError({ args: ["api", "graphql"], cwd: "/repo", exitCode: 1, stderr: "fatal: remote not found", }), }, ]); const service = createGitHubService({ runner: runner.runner, resolveGhPath: async () => "/usr/bin/gh", now: () => 100, }); const timeline = await service.getPullRequestTimeline({ cwd: "/repo", prNumber: 42, repoOwner: "parentOwner", repoName: "parentRepo", }); expect(timeline.error).toEqual({ kind: "unknown", message: "fatal: remote not found", }); }); it("maps a missing gh binary during PR timeline fetch to an unknown timeline error", async () => { const runner = createRunner([]); const service = createGitHubService({ runner: runner.runner, resolveGhPath: async () => null, now: () => 100, }); const timeline = await service.getPullRequestTimeline({ cwd: "/repo", prNumber: 42, repoOwner: "parentOwner", repoName: "parentRepo", }); expect(runner.calls).toHaveLength(0); expect(timeline.error).toEqual({ kind: "unknown", message: "GitHub CLI (gh) is not installed or not in PATH", }); }); it("maps PR timeline authentication failures to forbidden timeline errors", async () => { const runner = createScriptedRunner([ { error: new GitHubCommandError({ args: ["api", "graphql"], cwd: "/repo", exitCode: 1, stderr: "To authenticate, run: gh auth login", }), }, ]); const service = createGitHubService({ runner: runner.runner, resolveGhPath: async () => "/usr/bin/gh", now: () => 100, }); const timeline = await service.getPullRequestTimeline({ cwd: "/repo", prNumber: 42, repoOwner: "parentOwner", repoName: "parentRepo", }); expect(timeline.error).toEqual({ kind: "forbidden", message: "To authenticate, run: gh auth login", }); }); it("maps PR timeline rate limits to unknown timeline errors", async () => { const runner = createScriptedRunner([ { error: new GitHubCommandError({ args: ["api", "graphql"], cwd: "/repo", exitCode: 1, stderr: "GraphQL: API rate limit exceeded for user ID 123", }), }, ]); const service = createGitHubService({ runner: runner.runner, resolveGhPath: async () => "/usr/bin/gh", now: () => 100, }); const timeline = await service.getPullRequestTimeline({ cwd: "/repo", prNumber: 42, repoOwner: "parentOwner", repoName: "parentRepo", }); expect(timeline.error).toEqual({ kind: "unknown", message: "GraphQL: API rate limit exceeded for user ID 123", }); }); it("maps PR timeline network timeouts to unknown timeline errors with runner details", async () => { const runner = createScriptedRunner([ { error: Object.assign(new Error("request timed out after 10000ms"), { code: "ETIMEDOUT", }), }, ]); const service = createGitHubService({ runner: runner.runner, resolveGhPath: async () => "/usr/bin/gh", now: () => 100, }); const timeline = await service.getPullRequestTimeline({ cwd: "/repo", prNumber: 42, repoOwner: "parentOwner", repoName: "parentRepo", }); expect(timeline.error).toEqual({ kind: "unknown", message: "request timed out after 10000ms", }); }); it("maps PR timeline JSON parse failures to unknown timeline errors", async () => { const runner = createRunner(["{"]); const service = createGitHubService({ runner: runner.runner, resolveGhPath: async () => "/usr/bin/gh", now: () => 100, }); const timeline = await service.getPullRequestTimeline({ cwd: "/repo", prNumber: 42, repoOwner: "parentOwner", repoName: "parentRepo", }); expect(timeline.error).toMatchObject({ kind: "unknown", }); expect(timeline.error?.message).toContain("JSON"); }); it("caches PR timelines by cwd and PR number until invalidated", async () => { const runner = createRunner([ pullRequestTimelineJson({ comments: { nodes: [ { id: "IC_first", body: "First cached result", url: "https://github.com/parentOwner/parentRepo/pull/42#issuecomment-1", createdAt: "2026-04-02T13:55:00Z", author: { login: "commenter", url: "https://github.com/commenter" }, }, ], pageInfo: { hasNextPage: false }, }, }), pullRequestTimelineJson({ comments: { nodes: [ { id: "IC_refreshed", body: "Refreshed result", url: "https://github.com/parentOwner/parentRepo/pull/42#issuecomment-2", createdAt: "2026-04-02T13:56:00Z", author: { login: "commenter", url: "https://github.com/commenter" }, }, ], pageInfo: { hasNextPage: false }, }, }), ]); const service = createGitHubService({ ttlMs: 1_000, runner: runner.runner, resolveGhPath: async () => "/usr/bin/gh", now: () => 100, }); const request = { cwd: "/repo", prNumber: 42, repoOwner: "parentOwner", repoName: "parentRepo", }; const first = await service.getPullRequestTimeline(request); const second = await service.getPullRequestTimeline(request); service.invalidate({ cwd: "/repo" }); const refreshed = await service.getPullRequestTimeline(request); expect(first.items.at(-1)?.body).toBe("First cached result"); expect(second.items.at(-1)?.body).toBe("First cached result"); expect(refreshed.items.at(-1)?.body).toBe("Refreshed result"); expect(runner.calls).toHaveLength(2); }); it("does not cache a PR timeline result that resolves after cwd invalidation", async () => { const runner = createDeferredRunner(); const service = createGitHubService({ ttlMs: 1_000, runner: runner.runner, resolveGhPath: async () => "/usr/bin/gh", now: () => 100, }); const request = { cwd: "/repo", prNumber: 42, repoOwner: "parentOwner", repoName: "parentRepo", }; const staleRequest = service.getPullRequestTimeline(request); await Promise.resolve(); expect(runner.calls).toHaveLength(1); service.invalidate({ cwd: "/repo" }); runner.resolveNext( pullRequestTimelineJson({ comments: { nodes: [ { id: "IC_stale", body: "Stale pre-invalidation result", url: "https://github.com/parentOwner/parentRepo/pull/42#issuecomment-1", createdAt: "2026-04-02T13:55:00Z", author: { login: "commenter", url: "https://github.com/commenter" }, }, ], pageInfo: { hasNextPage: false }, }, }), ); const stale = await staleRequest; expect(stale.items.at(-1)?.body).toBe("Stale pre-invalidation result"); const freshRequest = service.getPullRequestTimeline(request); await Promise.resolve(); expect(runner.calls).toHaveLength(2); runner.resolveNext( pullRequestTimelineJson({ comments: { nodes: [ { id: "IC_fresh", body: "Fresh post-invalidation result", url: "https://github.com/parentOwner/parentRepo/pull/42#issuecomment-2", createdAt: "2026-04-02T13:56:00Z", author: { login: "commenter", url: "https://github.com/commenter" }, }, ], pageInfo: { hasNextPage: false }, }, }), ); const fresh = await freshRequest; expect(fresh.items.at(-1)?.body).toBe("Fresh post-invalidation result"); expect(runner.calls).toHaveLength(2); }); it("requests and surfaces current PR number, draft state, workflow names, and formatted check durations", async () => { const runner = createRunner([ JSON.stringify({ number: 42, url: "https://github.com/acme/repo/pull/42", title: "Wire real PR pane data", state: "OPEN", isDraft: true, baseRefName: "main", headRefName: "feature/pr-pane", mergedAt: null, reviewDecision: "REVIEW_REQUIRED", statusCheckRollup: [ { __typename: "CheckRun", completedAt: "2026-04-02T13:52:14Z", conclusion: "SUCCESS", detailsUrl: "https://github.com/acme/repo/actions/runs/123", name: "server-tests", startedAt: "2026-04-02T13:50:00Z", status: "COMPLETED", workflowName: "Server CI", checkSuite: { workflowRun: { databaseId: 123, }, }, }, { __typename: "StatusContext", context: "deploy/preview", state: "SUCCESS", targetUrl: "https://github.com/acme/repo/status/preview", createdAt: "2026-04-02T13:51:00Z", }, ], }), ]); const service = createGitHubService({ runner: runner.runner, resolveGhPath: async () => "/usr/bin/gh", now: () => 100, }); const status = await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/pr-pane", }); expect(runner.calls[0]?.args).toEqual(["pr", "view", "--json", CURRENT_PR_STATUS_FIELDS]); expect(status).toEqual({ number: 42, repoOwner: "acme", repoName: "repo", url: "https://github.com/acme/repo/pull/42", title: "Wire real PR pane data", state: "open", baseRefName: "main", headRefName: "feature/pr-pane", isMerged: false, isDraft: true, mergeable: "UNKNOWN", checks: [ { name: "server-tests", status: "success", url: "https://github.com/acme/repo/actions/runs/123", workflow: "Server CI", duration: "2m 14s", }, { name: "deploy/preview", status: "success", url: "https://github.com/acme/repo/status/preview", }, ], checksStatus: "success", reviewDecision: "pending", }); }); it("retries current PR view without statusCheckRollup when token permissions are insufficient", async () => { const runner = createScriptedRunner([ { error: statusCheckRollupPermissionError(["pr", "view", "--json", CURRENT_PR_STATUS_FIELDS]), }, currentPullRequestJson({ headRefName: "feature/pr-pane", reviewDecision: "APPROVED", }), ]); const service = createGitHubService({ runner: runner.runner, resolveGhPath: async () => "/usr/bin/gh", now: () => 100, }); const status = await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/pr-pane", }); expect(runner.calls.slice(0, 2).map((call) => call.args)).toEqual([ ["pr", "view", "--json", CURRENT_PR_STATUS_FIELDS], ["pr", "view", "--json", CURRENT_PR_STATUS_BASE_FIELDS], ]); expect(status).toMatchObject({ title: "Fork PR", headRefName: "feature/pr-pane", checks: [], checksStatus: "none", reviewDecision: "approved", }); }); it("defaults unexpected PR mergeability values to unknown", async () => { const runner = createRunner([ currentPullRequestJson({ mergeable: "", }), ]); const service = createGitHubService({ runner: runner.runner, resolveGhPath: async () => "/usr/bin/gh", }); const status = await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork", }); expect(status?.mergeable).toBe("UNKNOWN"); }); it("loads GitHub merge, auto-merge, permission, policy, and queue facts for PR 993 shape", async () => { const runner = createScriptedRunner([ currentPullRequestJson({ number: 993, url: "https://github.com/getpaseo/paseo/pull/993", title: "Auto-merge UX", headRefName: "github-pr-auto-merge-ux", mergeable: "MERGEABLE", reviewDecision: "APPROVED", statusCheckRollup: [ { __typename: "CheckRun", name: "server tests", workflowName: "CI", status: "IN_PROGRESS", conclusion: null, detailsUrl: "https://github.com/getpaseo/paseo/actions/runs/993", }, ], }), currentPullRequestGithubFactsJson(), ]); const service = createGitHubService({ runner: runner.runner, resolveGhPath: async () => "/usr/bin/gh", now: () => 100, }); const status = await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "github-pr-auto-merge-ux", }); expect(runner.calls.map((call) => call.args[0])).toEqual(["pr", "api"]); expect(status).toMatchObject({ number: 993, mergeable: "MERGEABLE", checks: [ { name: "server tests", status: "pending", url: "https://github.com/getpaseo/paseo/actions/runs/993", workflow: "CI", }, ], checksStatus: "pending", github: { mergeStateStatus: "BLOCKED", autoMergeRequest: null, viewerCanEnableAutoMerge: true, viewerCanDisableAutoMerge: false, viewerCanMergeAsAdmin: false, viewerCanUpdateBranch: true, repository: { autoMergeAllowed: true, mergeCommitAllowed: false, squashMergeAllowed: true, rebaseMergeAllowed: false, viewerDefaultMergeMethod: "SQUASH", }, isMergeQueueEnabled: false, isInMergeQueue: false, }, }); }); it("resolves fork PR heads to the parent repository when gh pr view returns a stale branch match", async () => { const runner = createScriptedRunner([ currentPullRequestJson({ number: 7, url: "https://github.com/parentOwner/parentRepo/pull/7", title: "Stale tracking PR", headRefName: "old-branch", }), JSON.stringify({ owner: { login: "forkOwner" }, name: "parentRepo", parent: { owner: { login: "parentOwner" }, name: "parentRepo" }, }), JSON.stringify([ { number: 41, url: "https://github.com/parentOwner/parentRepo/pull/41", title: "Wrong fork owner", state: "OPEN", isDraft: false, baseRefName: "main", headRefName: "feature/fork", mergedAt: null, statusCheckRollup: [], reviewDecision: "REVIEW_REQUIRED", headRepositoryOwner: { login: "otherFork" }, }, { number: 42, url: "https://github.com/parentOwner/parentRepo/pull/42", title: "Real fork PR", state: "OPEN", isDraft: false, baseRefName: "main", headRefName: "feature/fork", mergedAt: null, statusCheckRollup: [], reviewDecision: "REVIEW_REQUIRED", headRepositoryOwner: { login: "forkOwner" }, }, ]), ]); const service = createGitHubService({ runner: runner.runner, resolveGhPath: async () => "/usr/bin/gh", now: () => 100, }); const status = await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork", }); expect(status).toMatchObject({ number: 42, repoOwner: "parentOwner", repoName: "parentRepo", title: "Real fork PR", headRefName: "feature/fork", }); expect(runner.calls.slice(0, 3).map((call) => call.args)).toEqual([ ["pr", "view", "--json", CURRENT_PR_STATUS_FIELDS], ["repo", "view", "--json", "owner,name,parent"], [ "pr", "list", "--repo", "parentOwner/parentRepo", "--state", "all", "--head", "forkOwner:feature/fork", "--limit", "10", "--json", CURRENT_PR_STATUS_FIELDS, ], ]); }); it("retries scoped PR list without statusCheckRollup when token permissions are insufficient", async () => { const runner = createScriptedRunner([ currentPullRequestJson({ number: 7, url: "https://github.com/parentOwner/parentRepo/pull/7", title: "Stale tracking PR", headRefName: "old-branch", }), JSON.stringify({ owner: { login: "forkOwner" }, name: "parentRepo", parent: { owner: { login: "parentOwner" }, name: "parentRepo" }, }), { error: statusCheckRollupPermissionError([ "pr", "list", "--repo", "parentOwner/parentRepo", "--state", "all", "--head", "forkOwner:feature/fork", "--json", CURRENT_PR_STATUS_FIELDS, "--limit", "10", ]), }, JSON.stringify([ { number: 42, url: "https://github.com/parentOwner/parentRepo/pull/42", title: "Real fork PR", state: "OPEN", isDraft: false, baseRefName: "main", headRefName: "feature/fork", mergedAt: null, reviewDecision: "REVIEW_REQUIRED", headRepositoryOwner: { login: "forkOwner" }, }, ]), ]); const service = createGitHubService({ runner: runner.runner, resolveGhPath: async () => "/usr/bin/gh", now: () => 100, }); const status = await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork", }); expect(status).toMatchObject({ number: 42, repoOwner: "parentOwner", repoName: "parentRepo", headRefName: "feature/fork", checks: [], checksStatus: "none", }); expect(runner.calls.slice(0, 4).map((call) => call.args)).toEqual([ ["pr", "view", "--json", CURRENT_PR_STATUS_FIELDS], ["repo", "view", "--json", "owner,name,parent"], [ "pr", "list", "--repo", "parentOwner/parentRepo", "--state", "all", "--head", "forkOwner:feature/fork", "--limit", "10", "--json", CURRENT_PR_STATUS_FIELDS, ], [ "pr", "list", "--repo", "parentOwner/parentRepo", "--state", "all", "--head", "forkOwner:feature/fork", "--limit", "10", "--json", CURRENT_PR_STATUS_BASE_FIELDS, ], ]); }); it("does not match another fork owner's PR when the current branch is main", async () => { const calls: RunnerCall[] = []; const runner: GitHubCommandRunner = async (args, options) => { calls.push({ args, cwd: options.cwd }); if (args[0] === "pr" && args[1] === "view") { throw noPullRequestError(args); } if (args[0] === "pr" && args[1] === "list" && args.includes("--head")) { return { stdout: JSON.stringify([ { number: 77, url: "https://github.com/parentOwner/parentRepo/pull/77", title: "Unrelated fork main branch", state: "OPEN", isDraft: false, baseRefName: "main", headRefName: "main", mergedAt: null, statusCheckRollup: [], reviewDecision: "REVIEW_REQUIRED", headRepositoryOwner: { login: "otherForkOwner" }, }, ]), stderr: "", }; } return { stdout: JSON.stringify({ owner: { login: "repoOwner" }, name: "repo", parent: null, }), stderr: "", }; }; const service = createGitHubService({ runner, resolveGhPath: async () => "/usr/bin/gh", now: () => 100, }); await expect( service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "main", }), ).resolves.toBeNull(); expect(calls.map((call) => call.args)).toEqual([ ["pr", "view", "--json", CURRENT_PR_STATUS_FIELDS], ["repo", "view", "--json", "owner,name,parent"], ]); }); it("selects the requested fork owner when resolving a scoped PR worktree branch", async () => { const runner = createScriptedRunner([ { error: noPullRequestError() }, JSON.stringify([ { number: 77, url: "https://github.com/repoOwner/repo/pull/77", title: "Unrelated fork main branch", state: "OPEN", isDraft: false, baseRefName: "main", headRefName: "main", mergedAt: null, statusCheckRollup: [], reviewDecision: "REVIEW_REQUIRED", headRepositoryOwner: { login: "otherForkOwner" }, }, { number: 345, url: "https://github.com/repoOwner/repo/pull/345", title: "Requested fork main branch", state: "OPEN", isDraft: false, baseRefName: "main", headRefName: "main", mergedAt: null, statusCheckRollup: [], reviewDecision: "REVIEW_REQUIRED", headRepositoryOwner: { login: "chethanuk" }, }, ]), ]); const service = createGitHubService({ runner: runner.runner, resolveGhPath: async () => "/usr/bin/gh", now: () => 100, }); const status = await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "main", headRepositoryOwner: "chethanuk", }); expect(status).toMatchObject({ number: 345, repoOwner: "repoOwner", repoName: "repo", headRefName: "main", }); expect(runner.calls.slice(0, 2).map((call) => call.args)).toEqual([ ["pr", "view", "--json", CURRENT_PR_STATUS_FIELDS], [ "pr", "list", "--state", "all", "--head", "main", "--limit", "10", "--json", CURRENT_PR_STATUS_FIELDS, ], ]); }); it("finds a fork PR in the parent repo when the direct current branch view is unavailable", async () => { const runner = createScriptedRunner([ { error: noPullRequestError() }, JSON.stringify({ owner: { login: "forkOwner" }, name: "repo", parent: { owner: { login: "parentOwner" }, name: "repo" }, }), JSON.stringify([ { number: 88, url: "https://github.com/parentOwner/repo/pull/88", title: "Fork-only PR", state: "OPEN", isDraft: false, baseRefName: "main", headRefName: "feature/fork", mergedAt: null, statusCheckRollup: [], reviewDecision: null, headRepositoryOwner: { login: "forkOwner" }, }, ]), ]); const service = createGitHubService({ runner: runner.runner, resolveGhPath: async () => "/usr/bin/gh", now: () => 100, }); const status = await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork", }); expect(status).toMatchObject({ number: 88, repoOwner: "parentOwner", repoName: "repo", headRefName: "feature/fork", }); expect(runner.calls[2]?.args).toContain("forkOwner:feature/fork"); }); it("propagates DNS errors while resolving the current PR view", async () => { const dnsError = new GitHubCommandError({ args: ["pr", "view"], cwd: "/repo", exitCode: 1, stderr: "could not resolve host: github.com", }); const runner = createScriptedRunner([{ error: dnsError }]); const service = createGitHubService({ runner: runner.runner, resolveGhPath: async () => "/usr/bin/gh", now: () => 100, }); await expect( service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/pr-pane", }), ).rejects.toBe(dnsError); }); it("returns null when no current branch PR is matched by view or qualified fork lookup", async () => { const runner = createScriptedRunner([ { error: noPullRequestError() }, JSON.stringify({ owner: { login: "forkOwner" }, name: "repo", parent: { owner: { login: "parentOwner" }, name: "repo" }, }), "[]", ]); const service = createGitHubService({ runner: runner.runner, resolveGhPath: async () => "/usr/bin/gh", now: () => 100, }); await expect( service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/missing", }), ).resolves.toBeNull(); }); it("keeps S1 PR status schema additions optional and strips internal check timestamps", () => { const oldDaemonResponse = CheckoutPrStatusResponseSchema.parse({ type: "checkout_pr_status_response", payload: { cwd: "/repo", status: { url: "https://github.com/acme/repo/pull/42", title: "Old daemon payload", state: "open", baseRefName: "main", headRefName: "feature", isMerged: false, }, githubFeaturesEnabled: true, error: null, requestId: "req-old", }, }); expect(oldDaemonResponse.payload.status).toMatchObject({ isDraft: false, checks: [], }); const newDaemonResponse = CheckoutPrStatusResponseSchema.parse({ type: "checkout_pr_status_response", payload: { cwd: "/repo", status: { number: 42, url: "https://github.com/acme/repo/pull/42", title: "New daemon payload", state: "open", baseRefName: "main", headRefName: "feature", isMerged: false, isDraft: true, checks: [ { name: "server-tests", status: "success", url: "https://github.com/acme/repo/actions/runs/123", workflow: "Server CI", duration: "2m 14s", startedAt: "2026-04-02T13:50:00Z", completedAt: "2026-04-02T13:52:14Z", workflowRunDatabaseId: 123, }, ], checksStatus: "success", reviewDecision: "pending", }, githubFeaturesEnabled: true, error: null, requestId: "req-new", }, }); expect(newDaemonResponse.payload.status).toEqual({ number: 42, url: "https://github.com/acme/repo/pull/42", title: "New daemon payload", state: "open", baseRefName: "main", headRefName: "feature", isMerged: false, isDraft: true, mergeable: "UNKNOWN", checks: [ { name: "server-tests", status: "success", url: "https://github.com/acme/repo/actions/runs/123", workflow: "Server CI", duration: "2m 14s", }, ], checksStatus: "success", reviewDecision: "pending", }); }); it("returns cached results for identical calls within the TTL", async () => { const runner = createRunner([pullRequestJson("First result")]); const service = createGitHubService({ ttlMs: 1_000, runner: runner.runner, resolveGhPath: async () => "/usr/bin/gh", now: () => 100, }); const first = await service.listPullRequests({ cwd: "/repo", query: "bug", limit: 10 }); const second = await service.listPullRequests({ cwd: "/repo", query: "bug", limit: 10 }); expect(first).toEqual(second); expect(first[0]?.title).toBe("First result"); expect(runner.calls).toHaveLength(1); }); it("refreshes cached results after the TTL expires", async () => { let now = 100; const runner = createRunner([ pullRequestJson("First result"), pullRequestJson("Second result"), ]); const service = createGitHubService({ ttlMs: 50, runner: runner.runner, resolveGhPath: async () => "/usr/bin/gh", now: () => now, }); const first = await service.listPullRequests({ cwd: "/repo", query: "bug", limit: 10 }); now = 151; const second = await service.listPullRequests({ cwd: "/repo", query: "bug", limit: 10 }); expect(first[0]?.title).toBe("First result"); expect(second[0]?.title).toBe("Second result"); expect(runner.calls).toHaveLength(2); }); it("coalesces concurrent identical calls into one runner invocation", async () => { const runner = createDeferredRunner(); const service = createGitHubService({ ttlMs: 1_000, runner: runner.runner, resolveGhPath: async () => "/usr/bin/gh", now: () => 100, }); const first = service.listPullRequests({ cwd: "/repo", query: "bug", limit: 10 }); const second = service.listPullRequests({ cwd: "/repo", query: "bug", limit: 10 }); await Promise.resolve(); runner.resolveNext(pullRequestJson("Shared result")); await expect(Promise.all([first, second])).resolves.toEqual([ [ { number: 123, title: "Shared result", url: "https://github.com/acme/repo/pull/123", state: "OPEN", body: null, baseRefName: "main", headRefName: "feature", labels: ["bug"], updatedAt: "", }, ], [ { number: 123, title: "Shared result", url: "https://github.com/acme/repo/pull/123", state: "OPEN", body: null, baseRefName: "main", headRefName: "feature", labels: ["bug"], updatedAt: "", }, ], ]); expect(runner.calls).toHaveLength(1); }); it("invalidates only cache entries matching the requested cwd", async () => { const runner = createRunner([ pullRequestJson("Repo one"), pullRequestJson("Repo two"), pullRequestJson("Repo one refreshed"), ]); const service = createGitHubService({ ttlMs: 1_000, runner: runner.runner, resolveGhPath: async () => "/usr/bin/gh", now: () => 100, }); await service.listPullRequests({ cwd: "/repo-one", query: "bug", limit: 10 }); await service.listPullRequests({ cwd: "/repo-two", query: "bug", limit: 10 }); service.invalidate({ cwd: "/repo-one" }); const refreshed = await service.listPullRequests({ cwd: "/repo-one", query: "bug", limit: 10 }); const cached = await service.listPullRequests({ cwd: "/repo-two", query: "bug", limit: 10 }); expect(refreshed[0]?.title).toBe("Repo one refreshed"); expect(cached[0]?.title).toBe("Repo two"); expect(runner.calls).toHaveLength(3); }); it("throws a typed missing-cli error when gh is unavailable", async () => { const runner = createRunner([]); const service = createGitHubService({ runner: runner.runner, resolveGhPath: async () => null, now: () => 100, }); await expect(service.listPullRequests({ cwd: "/repo" })).rejects.toBeInstanceOf( GitHubCliMissingError, ); expect(runner.calls).toHaveLength(0); }); it("throws a typed auth error for authentication failures", async () => { const service = createGitHubService({ runner: async () => { throw new GitHubCommandError({ args: ["auth", "status"], cwd: "/repo", exitCode: 1, stderr: "To authenticate, run: gh auth login", }); }, resolveGhPath: async () => "/usr/bin/gh", now: () => 100, }); await expect(service.isAuthenticated({ cwd: "/repo" })).rejects.toBeInstanceOf( GitHubAuthenticationError, ); }); it("throws a typed command error for non-zero exits", async () => { const service = createGitHubService({ runner: async () => { throw new GitHubCommandError({ args: ["pr", "list"], cwd: "/repo", exitCode: 2, stderr: "GraphQL: unavailable", }); }, resolveGhPath: async () => "/usr/bin/gh", now: () => 100, }); await expect(service.listPullRequests({ cwd: "/repo" })).rejects.toMatchObject({ kind: "command-error", exitCode: 2, stderr: "GraphQL: unavailable", }); }); it("searches GitHub issues and PRs", async () => { const runner = createRunner([issueJson("Issue title"), searchPullRequestJson("PR title")]); const service = createGitHubService({ runner: runner.runner, resolveGhPath: async () => "/usr/bin/gh", now: () => 100, }); await expect( service.searchIssuesAndPrs({ cwd: "/repo", query: "cache", limit: 5 }), ).resolves.toEqual({ githubFeaturesEnabled: true, items: [ { kind: "pr", number: 123, title: "PR title", url: "https://github.com/acme/repo/pull/123", state: "OPEN", body: "pr body", labels: ["enhancement"], baseRefName: "main", headRefName: "feature", updatedAt: "2026-04-18T13:00:00Z", }, { kind: "issue", number: 55, title: "Issue title", url: "https://github.com/acme/repo/issues/55", state: "OPEN", body: "issue body", labels: ["bug"], baseRefName: null, headRefName: null, updatedAt: "2026-04-18T12:00:00Z", }, ], }); expect(runner.calls).toEqual([ { cwd: "/repo", args: [ "issue", "list", "--search", "cache", "--json", "number,title,url,state,body,labels,updatedAt", "--limit", "5", ], }, { cwd: "/repo", args: [ "pr", "list", "--search", "cache", "--json", "number,title,url,state,body,labels,baseRefName,headRefName,updatedAt", "--limit", "5", ], }, ]); }); it("treats a GitHub issue or PR URL as a search for that number", async () => { const runner = createRunner([issueJson("Issue title"), searchPullRequestJson("PR title")]); const service = createGitHubService({ runner: runner.runner, resolveGhPath: async () => "/usr/bin/gh", now: () => 100, }); await service.searchIssuesAndPrs({ cwd: "/repo", query: "https://github.com/getpaseo/paseo/pull/793", limit: 5, }); expect(runner.calls.map((call) => call.args)).toEqual([ [ "issue", "list", "--search", "793", "--json", "number,title,url,state,body,labels,updatedAt", "--limit", "5", ], [ "pr", "list", "--search", "793", "--json", "number,title,url,state,body,labels,baseRefName,headRefName,updatedAt", "--limit", "5", ], ]); }); it("searches only GitHub PRs when the search kinds request excludes issues", async () => { const runner = createRunner([searchPullRequestJson("PR title")]); const service = createGitHubService({ runner: runner.runner, resolveGhPath: async () => "/usr/bin/gh", now: () => 100, }); await expect( service.searchIssuesAndPrs({ cwd: "/repo", query: "cache", limit: 5, kinds: ["github-pr"], }), ).resolves.toEqual({ githubFeaturesEnabled: true, items: [ { kind: "pr", number: 123, title: "PR title", url: "https://github.com/acme/repo/pull/123", state: "OPEN", body: "pr body", labels: ["enhancement"], baseRefName: "main", headRefName: "feature", updatedAt: "2026-04-18T13:00:00Z", }, ], }); expect(runner.calls).toEqual([ { cwd: "/repo", args: [ "pr", "list", "--search", "cache", "--json", "number,title,url,state,body,labels,baseRefName,headRefName,updatedAt", "--limit", "5", ], }, ]); }); it("reuses cached PR status without another gh call", async () => { let now = 100; const runner = createRunner([currentPullRequestJson()]); const service = createGitHubService({ runner: runner.runner, resolveGhPath: async () => "/usr/bin/gh", now: () => now, }); await expect( service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" }), ).resolves.toMatchObject({ number: 42 }); now = 101; await expect( service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" }), ).resolves.toMatchObject({ number: 42 }); expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1); }); it("bypasses the warm PR status cache for forced reads", async () => { const runner = createRunner([ currentPullRequestJson({ number: 41, title: "First" }), currentPullRequestGithubFactsJson(), currentPullRequestJson({ number: 42, title: "Forced" }), currentPullRequestGithubFactsJson(), ]); const service = createGitHubService({ runner: runner.runner, resolveGhPath: async () => "/usr/bin/gh", now: () => 100, }); await expect( service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" }), ).resolves.toMatchObject({ number: 41 }); await expect( service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork", force: true, reason: "test", }), ).resolves.toMatchObject({ number: 42 }); expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(2); }); it("coalesces concurrent PR status callers", async () => { const runner = createDeferredRunner(); const service = createGitHubService({ runner: runner.runner, resolveGhPath: async () => "/usr/bin/gh", now: () => 100, }); const first = service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" }); const second = service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" }); await Promise.resolve(); expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1); runner.resolveNext(currentPullRequestJson()); for (let i = 0; i < 10 && runner.calls.length < 2; i += 1) { await Promise.resolve(); } expect(runner.calls[1]?.args[0]).toBe("api"); runner.resolveNext(currentPullRequestGithubFactsJson()); await expect(Promise.all([first, second])).resolves.toEqual([ expect.objectContaining({ number: 42 }), expect.objectContaining({ number: 42 }), ]); }); it("requires a reason for forced reads at runtime", async () => { const service = createGitHubService({ runner: async () => ({ stdout: currentPullRequestJson(), stderr: "" }), resolveGhPath: async () => "/usr/bin/gh", now: () => 100, }); await expect( service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork", force: true, } as never), ).rejects.toThrow("GitHubService forced read requires a reason"); }); it("type: force true requires a reason", () => { // @ts-expect-error force: true requires reason const invalid: GitHubReadOptions = { force: true }; const valid: GitHubReadOptions = { force: true, reason: "test" }; expect(invalid.force).toBe(true); expect(valid.reason).toBe("test"); }); it("resolves GitHub repos from the origin remote URL", async () => { vi.useRealTimers(); const cwd = mkdtempSync(join(tmpdir(), "github-service-repo-")); try { execFileSync("git", ["init", "-b", "main"], { cwd, stdio: "ignore" }); execFileSync("git", ["remote", "add", "origin", "git@github.com:getpaseo/paseo.git"], { cwd, stdio: "ignore", }); await expect(resolveGitHubRepo(cwd)).resolves.toBe("getpaseo/paseo"); } finally { rmSync(cwd, { recursive: true, force: true }); } }); });