Files
paseo/packages/server/src/services/github-service.test.ts
T
Mohamed Boudra a7d5f99a1c fix(forges): retain transferred forks in crowded searches
Resolve the head repository before choosing the bounded fallback so transferred forks keep their pull request association.
2026-11-02 06:42:09 +01:00

5359 lines
167 KiB
TypeScript

import { execFileSync } from "node:child_process";
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { delimiter, join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
GitHubAuthenticationError,
GitHubCliMissingError,
GitHubCommandError,
GITHUB_POLL_ALIGNMENT_MS,
computeGithubNextInterval,
createGitHubService,
parseStatusCheckRollup,
type GitHubCommandRunner,
type GitHubCommandRunnerOptions,
type CurrentPullRequestStatus,
type GitHubPullRequestStatusFacts,
} from "./github-service.js";
import { isPlatform } from "../test-utils/platform.js";
import { CheckoutPrStatusResponseSchema } from "@getpaseo/protocol/messages";
import { ForgeDiscoveryIncompleteError } from "./forge-service.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,headRefOid,updatedAt,mergedAt,reviewDecision,mergeable,headRepositoryOwner,headRepository";
const CURRENT_PR_STATUS_FIELDS = `${CURRENT_PR_STATUS_BASE_FIELDS},statusCheckRollup`;
const FORK_PR_QUERY =
"state=all&per_page=100&head=forkOwner%3Afeature%2Ffork&sort=updated&direction=desc";
interface RunnerCall {
args: string[];
cwd: string;
envOverlay?: Record<string, string>;
}
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: () => {},
};
}
async function flushMicrotasks(): Promise<void> {
for (let i = 0; i < 10; i++) {
await Promise.resolve();
}
}
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);
},
};
}
interface FakeGitHubCliFixture {
cwd: string;
logPath: string;
dispose: () => void;
}
function createFakeGitHubCliFixture(input: {
remoteUrl: string;
authStatusSucceeds?: boolean;
authStatusMode?: "success" | "unauthenticated" | "transient-once";
}): FakeGitHubCliFixture {
const previousPath = process.env.PATH;
const previousLog = process.env.GH_TEST_LOG;
const tempDir = mkdtempSync(join(tmpdir(), "github-service-gh-host-"));
const repoDir = join(tempDir, "repo");
const binDir = join(tempDir, "bin");
const logPath = join(tempDir, "gh.log");
const authStatusMode =
input.authStatusMode ?? (input.authStatusSucceeds === true ? "success" : "unauthenticated");
const authStatusStatePath = join(tempDir, "auth-status-state");
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
execFileSync("git", ["-C", repoDir, "remote", "add", "origin", input.remoteUrl], {
stdio: "pipe",
});
mkdirSync(binDir, { recursive: true });
const ghPath = join(binDir, "gh");
writeFileSync(
ghPath,
`#!/bin/sh
AUTH_STATUS_MODE=${JSON.stringify(authStatusMode)}
AUTH_STATUS_STATE=${JSON.stringify(authStatusStatePath)}
printf '%s|%s\\n' "$*" "\${GH_HOST:-}" >> "$GH_TEST_LOG"
if [ "$1" = "--version" ]; then
echo "gh version test"
exit 0
fi
if [ "$1" = "auth" ] && [ "$2" = "status" ] && [ "$3" = "--hostname" ]; then
case "$AUTH_STATUS_MODE" in
success)
exit 0
;;
transient-once)
if [ ! -f "$AUTH_STATUS_STATE" ]; then
touch "$AUTH_STATUS_STATE"
echo "request timed out" >&2
exit 2
fi
exit 0
;;
*)
echo "gh auth login required" >&2
exit 1
;;
esac
fi
if [ "$1" = "repo" ] && [ "$2" = "view" ]; then
echo '{"owner":{"login":"acme"},"name":"repo"}'
exit 0
fi
if [ "$1" = "api" ]; then
echo '{"url":"https://github.acme.internal/acme/repo/pull/7","number":7}'
exit 0
fi
echo "unexpected gh args: $*" >&2
exit 1
`,
);
chmodSync(ghPath, 0o755);
process.env.PATH = [binDir, previousPath].filter(Boolean).join(delimiter);
process.env.GH_TEST_LOG = logPath;
return {
cwd: repoDir,
logPath,
dispose: () => {
if (previousPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = previousPath;
}
if (previousLog === undefined) {
delete process.env.GH_TEST_LOG;
} else {
process.env.GH_TEST_LOG = previousLog;
}
rmSync(tempDir, { recursive: true, force: true });
},
};
}
function readFakeGitHubCliLog(logPath: string): string[] {
return readFileSync(logPath, "utf8").trim().split("\n").filter(Boolean);
}
function currentPullRequestJson(overrides: Record<string, unknown> = {}): string {
const repoUrl = String(overrides.url ?? "https://github.com/parentOwner/parentRepo/pull/42");
const [, owner, name] = new URL(repoUrl).pathname.split("/");
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",
headRefOid: "1111111111111111111111111111111111111111",
headRepositoryOwner: { login: owner },
headRepository: { name },
mergedAt: null,
statusCheckRollup: [],
reviewDecision: "REVIEW_REQUIRED",
...overrides,
});
}
function currentPullRequestGithubFactsJson(overrides: Record<string, unknown> = {}): 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 batchPollPrNodeJson(overrides: Record<string, unknown> = {}): Record<string, unknown> {
const repoUrl = String(overrides.url ?? "https://github.com/acme/widgets/pull/42");
const [, owner, name] = new URL(repoUrl).pathname.split("/");
return {
number: 42,
url: "https://github.com/acme/widgets/pull/42",
title: "Widget PR",
state: "MERGED",
isDraft: false,
baseRefName: "main",
headRefName: "feat-a",
headRefOid: "oid-42",
mergedAt: "2026-08-01T00:00:00Z",
mergeable: "UNKNOWN",
reviewDecision: null,
headRepositoryOwner: { login: owner },
headRepository: { name },
mergeStateStatus: "CLEAN",
autoMergeRequest: null,
viewerCanEnableAutoMerge: false,
viewerCanDisableAutoMerge: false,
viewerCanMergeAsAdmin: false,
viewerCanUpdateBranch: false,
isMergeQueueEnabled: false,
isInMergeQueue: false,
...overrides,
};
}
function batchPollRepositoryJson(
nodes: Array<Record<string, unknown>>,
overrides: Record<string, unknown> = {},
): Record<string, unknown> {
return {
isFork: false,
autoMergeAllowed: true,
mergeCommitAllowed: true,
squashMergeAllowed: true,
rebaseMergeAllowed: true,
viewerDefaultMergeMethod: "SQUASH",
pullRequests: { nodes },
...overrides,
};
}
function batchPollChecksAliasJson(
contexts: Array<Record<string, unknown>>,
): Record<string, unknown> {
return {
pullRequest: {
commits: { nodes: [{ commit: { statusCheckRollup: { contexts: { nodes: contexts } } } }] },
},
};
}
function batchPollStatusJson(aliases: Record<string, unknown>): string {
return JSON.stringify({ data: aliases });
}
function batchPollStatusWithRateLimitJson(input: {
aliases: Record<string, unknown>;
limit: number;
remaining: number;
resetAt: string;
}): string {
return JSON.stringify({
data: {
...input.aliases,
rateLimit: { limit: input.limit, remaining: input.remaining, resetAt: input.resetAt },
},
});
}
function createCurrentPullRequestStatus(
overrides: Partial<CurrentPullRequestStatus> = {},
): CurrentPullRequestStatus {
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<GitHubPullRequestStatusFacts> = {},
): GitHubPullRequestStatusFacts & { forge: "github" } {
return {
forge: "github",
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<typeof createGitHubService>) {
const reads: Parameters<typeof service.getCurrentPullRequestStatus>[0][] = [];
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, unknown> = {}): string {
return JSON.stringify({
data: {
repository: {
pullRequest: {
number: 42,
reviews: {
nodes: [
{
id: "PRR_approved",
state: "APPROVED",
body: "Looks good to me.",
bodyHTML: "<p>Looks good to me.</p>",
url: "https://github.com/parentOwner/parentRepo/pull/42#pullrequestreview-1",
submittedAt: "2026-04-02T13:52:14Z",
author: {
login: "reviewer",
url: "https://github.com/reviewer",
avatarUrl: "https://avatars.githubusercontent.com/u/1?v=4",
},
},
{
id: "PRR_empty_commented",
state: "COMMENTED",
body: "",
bodyHTML: "",
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?",
bodyHTML: "<p>Can we add a regression test?</p>",
url: "https://github.com/parentOwner/parentRepo/pull/42#issuecomment-3",
createdAt: "2026-04-02T13:55:00Z",
author: {
login: "commenter",
url: "https://github.com/commenter",
avatarUrl: "https://avatars.githubusercontent.com/u/2?v=4",
},
},
],
pageInfo: { hasNextPage: false },
},
...overrides,
},
},
},
});
}
describe("ForgeService", () => {
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({
forgeSpecific: 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({
forgeSpecific: 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({
forgeSpecific: 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({
forgeSpecific: 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({
forgeSpecific: 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({
forgeSpecific: 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({
forgeSpecific: 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",
checkoutRefs: [
{ remoteName: "origin", remoteRef: "refs/pull/526/head" },
{ remoteName: "upstream", remoteRef: "refs/pull/526/head" },
],
headOwnerLogin: "therainisme",
headRepositorySshUrl: "git@github.com:therainisme/paseo.git",
headRepositoryUrl: "https://github.com/therainisme/paseo",
isCrossRepository: true,
headRefKind: "branch",
});
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("populates repoOwner/repoName from a GitHub Enterprise PR URL", async () => {
const runner = createRunner([
currentPullRequestJson({ url: "https://github.acme.internal/acme/repo/pull/42" }),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
now: () => 0,
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
});
expect(status).toMatchObject({
repoOwner: "acme",
repoName: "repo",
url: "https://github.acme.internal/acme/repo/pull/42",
});
});
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("retained fork PR status polls keep the head repository owner", async () => {
const runner = createRunner([
currentPullRequestJson({
headRefName: "open-button-targets-active-file",
headRepositoryOwner: { login: "fork-owner" },
}),
]);
const service = createGitHubService({
ttlMs: 0,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => null,
resolveRepoSlug: async () => null,
});
const reads = recordCurrentPullRequestStatusReads(service);
const subscription = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/repo",
headRef: "open-button-targets-active-file",
headRepositoryOwner: "fork-owner",
headRepositoryPath: "fork-owner/parentRepo",
});
await vi.advanceTimersByTimeAsync(0);
await flushMicrotasks();
expect(reads).toEqual([
expect.objectContaining({
cwd: "/repo",
headRef: "open-button-targets-active-file",
headRepositoryOwner: "fork-owner",
reason: "self-heal-github",
}),
]);
await vi.advanceTimersByTimeAsync(0);
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1);
subscription?.unsubscribe();
service.dispose?.();
});
it("keeps mixed-port endpoint proof in retained polls", async () => {
const endpoint = {
transport: "ssh" as const,
host: "forge.example",
port: "2222",
path: "parentOwner/parentRepo",
};
const calls: string[][] = [];
const service = createGitHubService({
ttlMs: 0,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => null,
resolveRepoSlug: async () => null,
runner: async (args) => {
calls.push(args);
if (args[0] === "pr") return { stdout: currentPullRequestJson(), stderr: "" };
if (args[1] === "repos/parentOwner/parentRepo")
return {
stdout: JSON.stringify({
ssh_url: "ssh://git@forge.example:2222/parentOwner/parentRepo.git",
}),
stderr: "",
};
return { stdout: currentPullRequestGithubFactsJson(), stderr: "" };
},
});
const reads = recordCurrentPullRequestStatusReads(service);
const statuses: Array<CurrentPullRequestStatus | null> = [];
const subscription = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/repo",
headRef: "feature/fork",
headRepositoryPath: endpoint.path,
headRepositoryEndpoint: endpoint,
onStatus: (status) => statuses.push(status),
});
await vi.advanceTimersByTimeAsync(0);
await flushMicrotasks();
expect(reads).toEqual([expect.objectContaining({ headRepositoryEndpoint: endpoint })]);
expect(statuses).toEqual([expect.objectContaining({ number: 42 })]);
expect(calls.filter((args) => args[1] === "repos/parentOwner/parentRepo")).toHaveLength(1);
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);
await vi.advanceTimersByTimeAsync(0);
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("batches PR status polls across workspaces into one GraphQL request", async () => {
let now = 0;
const runner = createScriptedRunner([
batchPollStatusJson({
t0: batchPollRepositoryJson([
batchPollPrNodeJson({
number: 41,
url: "https://github.com/acme/widgets/pull/41",
headRefName: "feat-a",
headRefOid: "oid-a",
}),
]),
t1: batchPollRepositoryJson([
batchPollPrNodeJson({
number: 52,
url: "https://github.com/acme/gadgets/pull/52",
headRefName: "feat-b",
headRefOid: "oid-b",
}),
]),
}),
batchPollStatusJson({
t0: batchPollChecksAliasJson([
{ __typename: "StatusContext", context: "ci", state: "SUCCESS" },
]),
t1: batchPollChecksAliasJson([
{ __typename: "StatusContext", context: "ci", state: "SUCCESS" },
]),
}),
batchPollStatusJson({
t0: batchPollRepositoryJson([
batchPollPrNodeJson({
number: 41,
url: "https://github.com/acme/widgets/pull/41",
headRefName: "feat-a",
headRefOid: "oid-a",
}),
]),
t1: batchPollRepositoryJson([
batchPollPrNodeJson({
number: 52,
url: "https://github.com/acme/gadgets/pull/52",
headRefName: "feat-b",
headRefOid: "oid-b",
}),
]),
}),
]);
const slugs: Record<string, string> = { "/ws-a": "acme/widgets", "/ws-b": "acme/gadgets" };
const service = createGitHubService({
ttlMs: 0,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => null,
resolveRepoSlug: async (cwd) => slugs[cwd] ?? null,
now: () => now,
});
const statusesA: Array<CurrentPullRequestStatus | null> = [];
const statusesB: Array<CurrentPullRequestStatus | null> = [];
const subscriptionA = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/ws-a",
headRef: "feat-a",
headSha: "oid-a",
onStatus: (status) => statusesA.push(status),
});
const subscriptionB = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/ws-b",
headRef: "feat-b",
headSha: "oid-b",
onStatus: (status) => statusesB.push(status),
});
await vi.advanceTimersByTimeAsync(0);
await flushMicrotasks();
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(0);
expect(runner.calls).toHaveLength(2);
expect(runner.calls[0]?.args.slice(0, 2)).toEqual(["api", "graphql"]);
expect(runner.calls[0]?.args[3]).toContain('t0: repository(owner: "acme", name: "widgets")');
expect(runner.calls[0]?.args[3]).toContain('t1: repository(owner: "acme", name: "gadgets")');
expect(runner.calls[0]?.args[3]).toContain('headRefName: "feat-a"');
expect(runner.calls[0]?.args[3]).toContain('headRefName: "feat-b"');
expect(runner.calls[1]?.args[3]).toContain("pullRequest(number: 41)");
expect(runner.calls[1]?.args[3]).toContain("pullRequest(number: 52)");
expect(statusesA).toEqual([
expect.objectContaining({
number: 41,
state: "merged",
isMerged: true,
checksStatus: "success",
forgeSpecific: expect.objectContaining({ forge: "github", mergeStateStatus: "CLEAN" }),
}),
]);
expect(statusesB).toEqual([
expect.objectContaining({ number: 52, state: "merged", checksStatus: "success" }),
]);
// Merged PR checks are frozen: the second cycle reuses the cached rollup
// and issues only the discovery request.
now = EXPECTED_GITHUB_SLOW_POLL_MS;
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_SLOW_POLL_MS);
await flushMicrotasks();
expect(runner.calls).toHaveLength(3);
expect(statusesA).toHaveLength(2);
expect(statusesA[1]).toEqual(expect.objectContaining({ number: 41, checksStatus: "success" }));
subscriptionA?.unsubscribe();
subscriptionB?.unsubscribe();
service.dispose?.();
});
it("batches the first PR status poll after repository identity resolves", async () => {
let now = 0;
let resolveRepositoryIdentity!: () => void;
const repositoryIdentityReady = new Promise<void>((resolve) => {
resolveRepositoryIdentity = resolve;
});
const runner = createScriptedRunner([
batchPollStatusJson({
t0: batchPollRepositoryJson([
batchPollPrNodeJson({
number: 41,
url: "https://github.com/acme/widgets/pull/41",
headRefName: "feat-a",
headRefOid: "oid-a",
}),
]),
t1: batchPollRepositoryJson([
batchPollPrNodeJson({
number: 52,
url: "https://github.com/acme/gadgets/pull/52",
headRefName: "feat-b",
headRefOid: "oid-b",
}),
]),
}),
batchPollStatusJson({
t0: batchPollChecksAliasJson([]),
t1: batchPollChecksAliasJson([]),
}),
]);
const slugs: Record<string, string> = { "/ws-a": "acme/widgets", "/ws-b": "acme/gadgets" };
const service = createGitHubService({
ttlMs: 0,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => {
await repositoryIdentityReady;
return null;
},
resolveRepoSlug: async (cwd) => {
await repositoryIdentityReady;
return slugs[cwd] ?? null;
},
now: () => now,
});
const subscriptionA = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/ws-a",
headRef: "feat-a",
headSha: "oid-a",
});
const subscriptionB = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/ws-b",
headRef: "feat-b",
headSha: "oid-b",
});
vi.advanceTimersByTime(0);
await flushMicrotasks();
expect(runner.calls).toHaveLength(0);
resolveRepositoryIdentity();
await flushMicrotasks();
now = GITHUB_POLL_ALIGNMENT_MS;
await vi.advanceTimersByTimeAsync(GITHUB_POLL_ALIGNMENT_MS);
await flushMicrotasks();
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(0);
expect(runner.calls).toHaveLength(2);
expect(runner.calls[0]?.args.slice(0, 2)).toEqual(["api", "graphql"]);
expect(runner.calls[0]?.args[3]).toContain('t0: repository(owner: "acme", name: "widgets")');
expect(runner.calls[0]?.args[3]).toContain('t1: repository(owner: "acme", name: "gadgets")');
subscriptionA?.unsubscribe();
subscriptionB?.unsubscribe();
service.dispose?.();
});
it("does not let one unresolved repository identity block ready poll targets", async () => {
let now = 0;
const neverResolves = new Promise<string | null>(() => {});
const runner = createScriptedRunner([
batchPollStatusJson({
t0: batchPollRepositoryJson([
batchPollPrNodeJson({
number: 41,
url: "https://github.com/acme/widgets/pull/41",
state: "OPEN",
mergedAt: null,
headRefName: "feat-ready",
headRefOid: "oid-ready",
}),
]),
}),
batchPollStatusJson({ t0: batchPollChecksAliasJson([]) }),
]);
const service = createGitHubService({
ttlMs: 0,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async (cwd) => (cwd === "/ws-blocked" ? neverResolves : null),
resolveRepoSlug: async (cwd) => (cwd === "/ws-blocked" ? neverResolves : "acme/widgets"),
now: () => now,
});
const readyStatuses: Array<CurrentPullRequestStatus | null> = [];
const blockedSubscription = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/ws-blocked",
headRef: "feat-blocked",
});
const readySubscription = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/ws-ready",
headRef: "feat-ready",
headSha: "oid-ready",
onStatus: (status) => readyStatuses.push(status),
});
vi.advanceTimersByTime(0);
await flushMicrotasks();
now = GITHUB_POLL_ALIGNMENT_MS;
await vi.advanceTimersByTimeAsync(GITHUB_POLL_ALIGNMENT_MS);
await flushMicrotasks();
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(0);
expect(runner.calls).toHaveLength(2);
expect(runner.calls[0]?.args[3]).toContain('t0: repository(owner: "acme", name: "widgets")');
expect(readyStatuses).toEqual([expect.objectContaining({ number: 41 })]);
blockedSubscription?.unsubscribe();
readySubscription?.unsubscribe();
service.dispose?.();
});
it("refetches checks for open PRs on every batched poll at fast cadence", async () => {
let now = 0;
const openNode = batchPollPrNodeJson({
number: 7,
url: "https://github.com/acme/widgets/pull/7",
state: "OPEN",
mergedAt: null,
headRefName: "feat-a",
headRefOid: "oid-open",
});
const pendingChecks = batchPollChecksAliasJson([
{ __typename: "StatusContext", context: "ci", state: "PENDING" },
]);
const runner = createScriptedRunner([
batchPollStatusJson({ t0: batchPollRepositoryJson([openNode]) }),
batchPollStatusJson({ t0: pendingChecks }),
batchPollStatusJson({ t0: batchPollRepositoryJson([openNode]) }),
batchPollStatusJson({ t0: pendingChecks }),
]);
const service = createGitHubService({
ttlMs: 0,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => null,
resolveRepoSlug: async () => "acme/widgets",
now: () => now,
});
const statuses: Array<CurrentPullRequestStatus | null> = [];
const subscription = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/ws-a",
headRef: "feat-a",
onStatus: (status) => statuses.push(status),
});
await vi.advanceTimersByTimeAsync(0);
await flushMicrotasks();
expect(runner.calls).toHaveLength(2);
expect(statuses).toEqual([
expect.objectContaining({ number: 7, state: "open", checksStatus: "pending" }),
]);
now = EXPECTED_GITHUB_FAST_POLL_MS;
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS);
await flushMicrotasks();
expect(runner.calls).toHaveLength(4);
expect(statuses).toHaveLength(2);
subscription?.unsubscribe();
service.dispose?.();
});
it("backs off all batched targets on a failed batch without per-target fallback calls", async () => {
let now = 0;
const runner = createScriptedRunner([{ error: new Error("network down") }]);
const slugs: Record<string, string> = { "/ws-a": "acme/widgets", "/ws-b": "acme/gadgets" };
const service = createGitHubService({
ttlMs: 0,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => null,
resolveRepoSlug: async (cwd) => slugs[cwd] ?? null,
now: () => now,
});
const errors: unknown[] = [];
const subscriptionA = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/ws-a",
headRef: "feat-a",
onError: (error) => errors.push(error),
});
const subscriptionB = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/ws-b",
headRef: "feat-b",
onError: (error) => errors.push(error),
});
await vi.advanceTimersByTimeAsync(0);
await flushMicrotasks();
expect(runner.calls).toHaveLength(1);
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(0);
expect(errors).toHaveLength(2);
subscriptionA?.unsubscribe();
subscriptionB?.unsubscribe();
service.dispose?.();
});
it("falls back to the per-target lookup when a fork repository has no matching PR", async () => {
let now = 0;
const runner = createScriptedRunner([
batchPollStatusJson({ t0: batchPollRepositoryJson([], { isFork: true }) }),
currentPullRequestJson({ headRefName: "feat-a" }),
currentPullRequestGithubFactsJson(),
]);
const service = createGitHubService({
ttlMs: 0,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => null,
resolveRepoSlug: async () => "forkowner/widgets",
now: () => now,
});
const statuses: Array<CurrentPullRequestStatus | null> = [];
const subscription = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/ws-a",
headRef: "feat-a",
onStatus: (status) => statuses.push(status),
});
await vi.advanceTimersByTimeAsync(0);
await flushMicrotasks();
expect(runner.calls[0]?.args.slice(0, 2)).toEqual(["api", "graphql"]);
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1);
expect(statuses).toEqual([expect.objectContaining({ number: 42, state: "open" })]);
subscription?.unsubscribe();
service.dispose?.();
});
it("redirects a fork poll to its parent and reuses that batch address", async () => {
let now = 0;
const parent = { owner: { login: "upstream" }, name: "widgets" };
const forkPr = batchPollPrNodeJson({
url: "https://github.com/upstream/widgets/pull/42",
headRefName: "feat-a",
headRefOid: "oid-a",
headRepositoryOwner: { login: "forkowner" },
});
const runner = createScriptedRunner([
batchPollStatusJson({
t0: batchPollRepositoryJson([], { isFork: true, parent }),
}),
batchPollStatusJson({ t0: batchPollRepositoryJson([forkPr]) }),
batchPollStatusJson({
t0: batchPollChecksAliasJson([
{ __typename: "StatusContext", context: "ci", state: "SUCCESS" },
]),
}),
batchPollStatusJson({ t0: batchPollRepositoryJson([forkPr]) }),
]);
const service = createGitHubService({
ttlMs: 0,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => null,
resolveRepoSlug: async () => "forkowner/widgets",
now: () => now,
});
const statuses: Array<CurrentPullRequestStatus | null> = [];
const subscription = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/ws-a",
headRef: "feat-a",
headSha: "oid-a",
onStatus: (status) => statuses.push(status),
});
await vi.advanceTimersByTimeAsync(0);
await flushMicrotasks();
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(0);
expect(runner.calls[0]?.args[3]).toContain(
't0: repository(owner: "forkowner", name: "widgets")',
);
expect(runner.calls[1]?.args[3]).toContain(
't0: repository(owner: "upstream", name: "widgets")',
);
expect(statuses).toEqual([
expect.objectContaining({ number: 42, repoOwner: "upstream", checksStatus: "success" }),
]);
now = EXPECTED_GITHUB_SLOW_POLL_MS;
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_SLOW_POLL_MS);
await flushMicrotasks();
expect(runner.calls[3]?.args[3]).toContain(
't0: repository(owner: "upstream", name: "widgets")',
);
subscription?.unsubscribe();
service.dispose?.();
});
it("treats a missing pullRequests connection as a failed alias", async () => {
let now = 0;
const runner = createScriptedRunner([
batchPollStatusJson({
t0: { isFork: false, autoMergeAllowed: true },
}),
]);
const service = createGitHubService({
ttlMs: 0,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => null,
resolveRepoSlug: async () => "acme/widgets",
now: () => now,
});
const statuses: Array<CurrentPullRequestStatus | null> = [];
const errors: unknown[] = [];
const subscription = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/ws-a",
headRef: "feat-a",
onStatus: (status) => statuses.push(status),
onError: (error) => errors.push(error),
});
await vi.advanceTimersByTimeAsync(0);
await flushMicrotasks();
expect(statuses).toEqual([]);
expect(errors).toHaveLength(1);
expect(errors[0]).toBeInstanceOf(GitHubCommandError);
subscription?.unsubscribe();
service.dispose?.();
});
it("treats a malformed checks connection as a failed alias", async () => {
let now = 0;
const runner = createScriptedRunner([
batchPollStatusJson({
t0: batchPollRepositoryJson([batchPollPrNodeJson({ state: "OPEN", mergedAt: null })]),
}),
batchPollStatusJson({ t0: { pullRequest: {} } }),
]);
const service = createGitHubService({
ttlMs: 0,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => null,
resolveRepoSlug: async () => "acme/widgets",
now: () => now,
});
const statuses: Array<CurrentPullRequestStatus | null> = [];
const errors: unknown[] = [];
const subscription = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/ws-a",
headRef: "feat-a",
onStatus: (status) => statuses.push(status),
onError: (error) => errors.push(error),
});
await vi.advanceTimersByTimeAsync(0);
await flushMicrotasks();
expect(statuses).toEqual([]);
expect(errors).toHaveLength(1);
expect(errors[0]).toBeInstanceOf(GitHubCommandError);
subscription?.unsubscribe();
service.dispose?.();
});
it("pauses a host until reset when a batch reaches the GraphQL reserve", async () => {
let now = 0;
const resetAt = new Date(EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS).toISOString();
const runner = createScriptedRunner([
batchPollStatusWithRateLimitJson({
aliases: { t0: batchPollRepositoryJson([batchPollPrNodeJson()]) },
limit: 5_000,
remaining: 2_000,
resetAt,
}),
batchPollStatusJson({ t0: batchPollRepositoryJson([]) }),
]);
const service = createGitHubService({
ttlMs: 0,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => null,
resolveRepoSlug: async () => "acme/widgets",
now: () => now,
});
const errors: unknown[] = [];
const subscription = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/ws-a",
headRef: "feat-a",
onError: (error) => errors.push(error),
});
await vi.advanceTimersByTimeAsync(0);
await flushMicrotasks();
expect(runner.calls).toHaveLength(1);
expect(runner.calls[0]?.args[3]).toContain("rateLimit");
expect(errors).toHaveLength(1);
now = EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS;
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS);
await flushMicrotasks();
expect(runner.calls).toHaveLength(1);
now += 1_000;
await vi.advanceTimersByTimeAsync(1_000);
await flushMicrotasks();
expect(runner.calls).toHaveLength(2);
subscription?.unsubscribe();
service.dispose?.();
});
it("loads the reset time once when GitHub rejects a batch for rate limiting", async () => {
let now = 0;
const rateLimitError = Object.assign(new Error("gh exited 1"), {
code: 1,
stderr: "GraphQL: API rate limit already exceeded for user ID 123",
});
const runner = createScriptedRunner([
{ error: rateLimitError },
JSON.stringify({ resources: { graphql: { remaining: 0, reset: 300 } } }),
batchPollStatusJson({ t0: batchPollRepositoryJson([]) }),
]);
const service = createGitHubService({
ttlMs: 0,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => null,
resolveRepoSlug: async () => "acme/widgets",
now: () => now,
});
const subscription = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/ws-a",
headRef: "feat-a",
});
await vi.advanceTimersByTimeAsync(0);
await flushMicrotasks();
expect(runner.calls.map((call) => call.args.slice(0, 2))).toEqual([
["api", "graphql"],
["api", "rate_limit"],
]);
now = EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS;
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS);
await flushMicrotasks();
expect(runner.calls).toHaveLength(2);
now += 1_000;
await vi.advanceTimersByTimeAsync(1_000);
await flushMicrotasks();
expect(runner.calls).toHaveLength(3);
subscription?.unsubscribe();
service.dispose?.();
});
it("keeps refetching checks for a closed PR while its checks are still pending", async () => {
let now = 0;
const closedNode = batchPollPrNodeJson({
number: 9,
url: "https://github.com/acme/widgets/pull/9",
state: "CLOSED",
mergedAt: null,
headRefName: "feat-a",
headRefOid: "oid-closed",
});
const runner = createScriptedRunner([
batchPollStatusJson({ t0: batchPollRepositoryJson([closedNode]) }),
batchPollStatusJson({
t0: batchPollChecksAliasJson([
{ __typename: "StatusContext", context: "ci", state: "PENDING" },
]),
}),
batchPollStatusJson({ t0: batchPollRepositoryJson([closedNode]) }),
batchPollStatusJson({
t0: batchPollChecksAliasJson([
{ __typename: "StatusContext", context: "ci", state: "SUCCESS" },
]),
}),
]);
const service = createGitHubService({
ttlMs: 0,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => null,
resolveRepoSlug: async () => "acme/widgets",
now: () => now,
});
const statuses: Array<CurrentPullRequestStatus | null> = [];
const subscription = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/ws-a",
headRef: "feat-a",
headSha: "oid-closed",
onStatus: (status) => statuses.push(status),
});
await vi.advanceTimersByTimeAsync(0);
await flushMicrotasks();
expect(runner.calls).toHaveLength(2);
expect(statuses).toEqual([expect.objectContaining({ number: 9, checksStatus: "pending" })]);
// Pending checks are never frozen, so the next cycle re-runs the checks
// query and observes the completed run.
now = EXPECTED_GITHUB_FAST_POLL_MS;
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS);
await flushMicrotasks();
expect(runner.calls).toHaveLength(4);
expect(statuses[1]).toEqual(expect.objectContaining({ number: 9, checksStatus: "success" }));
subscription?.unsubscribe();
service.dispose?.();
});
it("falls back to the per-target lookup when the batch candidate page is full", async () => {
let now = 0;
const crowdedNodes = Array.from({ length: 10 }, (_, index) =>
batchPollPrNodeJson({
number: 100 + index,
url: `https://github.com/acme/widgets/pull/${100 + index}`,
state: index === 0 ? "MERGED" : "OPEN",
mergedAt: index === 0 ? "2026-08-01T00:00:00Z" : null,
headRefName: "feat-a",
headRepositoryOwner: { login: index === 0 ? "acme" : "stranger" },
}),
);
const runner = createScriptedRunner([
batchPollStatusJson({ t0: batchPollRepositoryJson(crowdedNodes) }),
currentPullRequestJson({ headRefName: "feat-a" }),
currentPullRequestGithubFactsJson(),
]);
const service = createGitHubService({
ttlMs: 0,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => null,
resolveRepoSlug: async () => "acme/widgets",
now: () => now,
});
const statuses: Array<CurrentPullRequestStatus | null> = [];
const subscription = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/ws-a",
headRef: "feat-a",
onStatus: (status) => statuses.push(status),
});
await vi.advanceTimersByTimeAsync(0);
await flushMicrotasks();
// A full page can contain a terminal match while an older open PR is omitted.
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1);
expect(statuses).toEqual([expect.objectContaining({ number: 42, state: "open" })]);
subscription?.unsubscribe();
service.dispose?.();
});
it("ignores another fork owner's same-named branch in batched polls without an owner hint", async () => {
let now = 0;
const runner = createScriptedRunner([
batchPollStatusJson({
t0: batchPollRepositoryJson([
batchPollPrNodeJson({
number: 900,
url: "https://github.com/acme/widgets/pull/900",
state: "OPEN",
mergedAt: null,
headRefName: "main",
headRepositoryOwner: { login: "stranger" },
}),
]),
}),
]);
const service = createGitHubService({
ttlMs: 0,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => null,
resolveRepoSlug: async () => "acme/widgets",
now: () => now,
});
const statuses: Array<CurrentPullRequestStatus | null> = [];
const subscription = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/ws-a",
headRef: "main",
onStatus: (status) => statuses.push(status),
});
await vi.advanceTimersByTimeAsync(0);
await flushMicrotasks();
expect(runner.calls).toHaveLength(1);
expect(statuses).toEqual([null]);
subscription?.unsubscribe();
service.dispose?.();
});
it("uses a full fork path in batched polls without a separate owner hint", async () => {
const node = batchPollPrNodeJson({
state: "OPEN",
mergedAt: null,
headRepositoryOwner: { login: "fork" },
headRepository: { name: "renamed" },
});
const runner = createScriptedRunner([
batchPollStatusJson({ t0: batchPollRepositoryJson([node]) }),
batchPollStatusJson({ t0: batchPollChecksAliasJson([]) }),
]);
const service = createGitHubService({
ttlMs: 0,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => null,
resolveRepoSlug: async () => "acme/widgets",
});
const statuses: Array<CurrentPullRequestStatus | null> = [];
const subscription = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/ws-a",
headRef: "feat-a",
headRepositoryPath: "fork/renamed",
onStatus: (status) => statuses.push(status),
});
await vi.advanceTimersByTimeAsync(0);
await flushMicrotasks();
expect(statuses).toEqual([expect.objectContaining({ number: 42, state: "open" })]);
subscription?.unsubscribe();
service.dispose?.();
});
it("uses canonical repository identity for batched polls after a transfer", async () => {
const node = batchPollPrNodeJson({
state: "OPEN",
mergedAt: null,
headRepositoryOwner: { login: "new-owner" },
headRepository: { name: "repo" },
});
const runner = createScriptedRunner([
batchPollStatusJson({
t0: batchPollRepositoryJson([node], { owner: { login: "new-owner" }, name: "repo" }),
}),
batchPollStatusJson({ t0: batchPollChecksAliasJson([]) }),
]);
const service = createGitHubService({
ttlMs: 0,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => null,
resolveRepoSlug: async () => "old-owner/repo",
});
const statuses: Array<CurrentPullRequestStatus | null> = [];
const subscription = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/ws-a",
headRef: "feat-a",
headRepositoryPath: "old-owner/repo",
onStatus: (status) => statuses.push(status),
});
await vi.advanceTimersByTimeAsync(0);
await flushMicrotasks();
expect(statuses).toEqual([expect.objectContaining({ number: 42, state: "open" })]);
subscription?.unsubscribe();
service.dispose?.();
});
it("resolves a transferred fork during batched polling", async () => {
const node = batchPollPrNodeJson({
state: "OPEN",
mergedAt: null,
headRepositoryOwner: { login: "new-fork" },
headRepository: { name: "repo" },
});
const pr = currentPullRequestJson({
url: "https://github.com/base/repo/pull/42",
headRefName: "feat-a",
headRepositoryOwner: { login: "new-fork" },
headRepository: { name: "repo" },
});
const runner = createScriptedRunner([
batchPollStatusJson({ t0: batchPollRepositoryJson([node]) }),
pr,
JSON.stringify([JSON.parse(pr)]),
JSON.stringify({ full_name: "new-fork/repo" }),
currentPullRequestGithubFactsJson(),
]);
const service = createGitHubService({
ttlMs: 0,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => null,
resolveRepoSlug: async () => "base/repo",
});
const statuses: Array<CurrentPullRequestStatus | null> = [];
const subscription = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/ws-a",
headRef: "feat-a",
headRepositoryOwner: "old-fork",
headRepositoryPath: "old-fork/repo",
onStatus: (status) => statuses.push(status),
});
await vi.advanceTimersByTimeAsync(0);
await flushMicrotasks();
expect(statuses).toEqual([expect.objectContaining({ number: 42, state: "open" })]);
subscription?.unsubscribe();
service.dispose?.();
});
it("recovers healthy aliases from a partially failed batch response", async () => {
let now = 0;
const partialBody = batchPollStatusJson({
t0: batchPollRepositoryJson([
batchPollPrNodeJson({
number: 41,
url: "https://github.com/acme/widgets/pull/41",
headRefName: "feat-a",
headRefOid: "oid-a",
}),
]),
t1: null,
});
const runner = createScriptedRunner([
{
error: Object.assign(new Error("gh exited 1"), {
code: 1,
stdout: partialBody,
stderr: "GraphQL: Could not resolve to a Repository",
}),
},
batchPollStatusJson({
t0: batchPollChecksAliasJson([
{ __typename: "StatusContext", context: "ci", state: "SUCCESS" },
]),
}),
]);
const slugs: Record<string, string> = { "/ws-a": "acme/widgets", "/ws-b": "acme/missing" };
const service = createGitHubService({
ttlMs: 0,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => null,
resolveRepoSlug: async (cwd) => slugs[cwd] ?? null,
now: () => now,
});
const statuses: Array<CurrentPullRequestStatus | null> = [];
const errors: unknown[] = [];
const subscriptionA = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/ws-a",
headRef: "feat-a",
headSha: "oid-a",
onStatus: (status) => statuses.push(status),
});
const subscriptionB = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/ws-b",
headRef: "feat-b",
onError: (error) => errors.push(error),
});
await vi.advanceTimersByTimeAsync(0);
await flushMicrotasks();
expect(runner.calls).toHaveLength(2);
expect(statuses).toEqual([
expect.objectContaining({ number: 41, state: "merged", checksStatus: "success" }),
]);
expect(errors).toHaveLength(1);
expect(errors[0]).toBeInstanceOf(GitHubCommandError);
subscriptionA?.unsubscribe();
subscriptionB?.unsubscribe();
service.dispose?.();
});
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(runner.calls[0]?.args[3]).toContain("bodyHTML");
expect(runner.calls[0]?.args[3]).toContain("avatarUrl");
expect(runner.calls[0]?.args[3]).toContain("reviewThreads(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,
avatarUrl: 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",
avatarUrl: "https://avatars.githubusercontent.com/u/1?v=4",
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",
avatarUrl: "https://avatars.githubusercontent.com/u/2?v=4",
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("rewrites GitHub attachment image URLs in timeline comments", async () => {
const privateAttachmentUrl =
"https://private-user-images.githubusercontent.com/123/asset.png?jwt=abc&expires=123";
const runner = createRunner([
pullRequestTimelineJson({
reviews: {
nodes: [],
pageInfo: { hasNextPage: false },
},
comments: {
nodes: [
{
id: "IC_attachment",
body: "Screenshot: ![bug](https://github.com/user-attachments/assets/raw-asset)",
bodyHTML: `<p>Screenshot: <img alt="bug" src="${privateAttachmentUrl.replaceAll("&", "&amp;")}" /></p>`,
url: "https://github.com/parentOwner/parentRepo/pull/42#issuecomment-4",
createdAt: "2026-04-02T13:56:00Z",
author: null,
},
],
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.items).toHaveLength(1);
expect(timeline.items[0]).toMatchObject({
kind: "comment",
id: "IC_attachment",
body: `Screenshot: ![bug](${privateAttachmentUrl})`,
});
});
it("rewrites GitHub attachment image URLs in review thread comments", async () => {
const privateAttachmentUrl =
"https://private-user-images.githubusercontent.com/123/thread.png?jwt=thread";
const runner = createRunner([
pullRequestTimelineJson({
reviews: {
nodes: [],
pageInfo: { hasNextPage: false },
},
comments: {
nodes: [],
pageInfo: { hasNextPage: false },
},
reviewThreads: {
nodes: [
{
id: "PRRT_attachment",
path: "packages/app/src/git/pull-request-panel/data.ts",
line: 24,
startLine: 20,
isResolved: false,
isOutdated: false,
comments: {
nodes: [
{
id: "PRRC_attachment",
body: '<img src="https://github.com/user-attachments/assets/thread-asset" alt="thread" />',
bodyHTML: `<p><img alt="thread" src="${privateAttachmentUrl}" /></p>`,
url: "https://github.com/parentOwner/parentRepo/pull/42#discussion_r2",
createdAt: "2026-04-02T13:51:00Z",
author: null,
pullRequestReview: { id: "PRR_empty_commented" },
},
],
pageInfo: { hasNextPage: false },
},
},
],
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.items).toHaveLength(1);
expect(timeline.items[0]).toMatchObject({
kind: "comment",
id: "PRRC_attachment",
body: `<img src="${privateAttachmentUrl}" alt="thread" />`,
});
});
it("leaves external badge images unchanged", async () => {
const runner = createRunner([
pullRequestTimelineJson({
reviews: {
nodes: [],
pageInfo: { hasNextPage: false },
},
comments: {
nodes: [
{
id: "IC_badge",
body: "![build](https://img.shields.io/github/actions/workflow/status/getpaseo/paseo/ci.yml)",
bodyHTML:
'<p><img alt="build" src="https://camo.githubusercontent.com/badge-signature" /></p>',
url: "https://github.com/parentOwner/parentRepo/pull/42#issuecomment-5",
createdAt: "2026-04-02T13:57:00Z",
author: null,
},
],
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.items[0]).toMatchObject({
kind: "comment",
id: "IC_badge",
body: "![build](https://img.shields.io/github/actions/workflow/status/getpaseo/paseo/ci.yml)",
});
});
it("leaves GitHub attachment image URLs unchanged when rendered images do not match", async () => {
const body = "![bug](https://github.com/user-attachments/assets/raw-asset)";
const runner = createRunner([
pullRequestTimelineJson({
reviews: {
nodes: [],
pageInfo: { hasNextPage: false },
},
comments: {
nodes: [
{
id: "IC_mismatch",
body,
bodyHTML: "<p>No rendered image.</p>",
url: "https://github.com/parentOwner/parentRepo/pull/42#issuecomment-6",
createdAt: "2026-04-02T13:58:00Z",
author: null,
},
],
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.items[0]).toMatchObject({
kind: "comment",
id: "IC_mismatch",
body,
});
});
it("maps inline review thread comments as chronological PR timeline comments with location", async () => {
const runner = createRunner([
pullRequestTimelineJson({
reviewThreads: {
nodes: [
{
id: "PRRT_1",
path: "packages/app/src/git/pull-request-panel/data.ts",
line: 24,
startLine: 20,
isResolved: true,
isOutdated: false,
comments: {
nodes: [
{
id: "PRRC_1",
body: "This should include line context.",
bodyHTML: "<p>This should include line context.</p>",
url: "https://github.com/parentOwner/parentRepo/pull/42#discussion_r1",
createdAt: "2026-04-02T13:51:00Z",
author: {
login: "inline-reviewer",
url: "https://github.com/inline-reviewer",
avatarUrl: "https://avatars.githubusercontent.com/u/3?v=4",
},
pullRequestReview: { id: "PRR_empty_commented" },
},
],
pageInfo: { hasNextPage: true },
},
},
],
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.truncated).toBe(true);
expect(timeline.items.map((item) => item.id)).toEqual([
"PRR_empty_commented",
"PRRC_1",
"PRR_approved",
"IC_later",
]);
expect(timeline.items[1]).toEqual({
kind: "comment",
id: "PRRC_1",
author: "inline-reviewer",
authorUrl: "https://github.com/inline-reviewer",
avatarUrl: "https://avatars.githubusercontent.com/u/3?v=4",
body: "This should include line context.",
createdAt: Date.parse("2026-04-02T13:51:00Z"),
url: "https://github.com/parentOwner/parentRepo/pull/42#discussion_r1",
reviewId: "PRR_empty_commented",
location: {
path: "packages/app/src/git/pull-request-panel/data.ts",
line: 24,
startLine: 20,
threadId: "PRRT_1",
isResolved: true,
isOutdated: false,
},
});
expect(runner.calls[0]?.args[3]).toContain("pullRequestReview");
});
it("keeps inline review thread comments once when they also appear in PR comments", async () => {
const runner = createRunner([
pullRequestTimelineJson({
comments: {
nodes: [
{
id: "PRRC_1",
body: "This should include line context.",
url: "https://github.com/parentOwner/parentRepo/pull/42#discussion_r1",
createdAt: "2026-04-02T13:51:00Z",
author: {
login: "inline-reviewer",
url: "https://github.com/inline-reviewer",
avatarUrl: "https://avatars.githubusercontent.com/u/3?v=4",
},
},
{
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",
avatarUrl: "https://avatars.githubusercontent.com/u/2?v=4",
},
},
],
pageInfo: { hasNextPage: false },
},
reviewThreads: {
nodes: [
{
id: "PRRT_1",
path: "packages/app/src/git/pull-request-panel/data.ts",
line: 24,
startLine: 20,
isResolved: false,
isOutdated: false,
comments: {
nodes: [
{
id: "PRRC_1",
body: "This should include line context.",
url: "https://github.com/parentOwner/parentRepo/pull/42#discussion_r1",
createdAt: "2026-04-02T13:51:00Z",
author: {
login: "inline-reviewer",
url: "https://github.com/inline-reviewer",
avatarUrl: "https://avatars.githubusercontent.com/u/3?v=4",
},
pullRequestReview: { id: "PRR_empty_commented" },
},
],
pageInfo: { hasNextPage: false },
},
},
],
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.items.map((item) => item.id)).toEqual([
"PRR_empty_commented",
"PRRC_1",
"PRR_approved",
"IC_later",
]);
expect(timeline.items[1]).toMatchObject({
id: "PRRC_1",
reviewId: "PRR_empty_commented",
location: {
path: "packages/app/src/git/pull-request-panel/data.ts",
line: 24,
startLine: 20,
threadId: "PRRT_1",
},
});
});
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",
resolveRepoHost: async () => null,
now: () => 100,
});
const request = {
cwd: "/repo",
prNumber: 42,
repoOwner: "parentOwner",
repoName: "parentRepo",
};
const staleRequest = service.getPullRequestTimeline(request);
await flushMicrotasks();
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 flushMicrotasks();
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",
headRepositoryOwner: { login: "acme" },
headRepository: { name: "repo" },
mergedAt: null,
reviewDecision: "REVIEW_REQUIRED",
statusCheckRollup: [
{
__typename: "CheckRun",
databaseId: 12345,
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",
checkRunId: 12345,
workflowRunId: 123,
},
{
name: "deploy/preview",
status: "success",
url: "https://github.com/acme/repo/status/preview",
},
],
checksStatus: "success",
reviewDecision: "pending",
});
});
it("fetches GitHub check details with capped failed job log tails and reuses cached logs", async () => {
const longLog = Array.from({ length: 220 }, (_, index) => `line ${index + 1}`).join("\n");
const runner = createRunner([
JSON.stringify({
id: 12345,
name: "server-tests",
status: "completed",
conclusion: "failure",
html_url: "https://github.com/acme/repo/actions/runs/456/job/789",
details_url: "https://github.com/acme/repo/actions/runs/456/job/789",
output: {
title: "Tests failed",
summary: "1 failure",
text: "Assertion failed",
},
check_suite: {
workflow_run: {
id: 456,
},
},
}),
JSON.stringify([
{
path: "packages/server/src/index.ts",
start_line: 10,
end_line: 12,
annotation_level: "failure",
message: "Expected true",
title: "server test failed",
raw_details: "stack trace",
},
]),
JSON.stringify({
jobs: [
{
id: 789,
name: "test",
status: "completed",
conclusion: "failure",
html_url: "https://github.com/acme/repo/actions/runs/456/job/789",
completed_at: "2026-04-02T13:52:14Z",
},
{
id: 790,
name: "lint",
status: "completed",
conclusion: "success",
html_url: "https://github.com/acme/repo/actions/runs/456/job/790",
completed_at: "2026-04-02T13:52:15Z",
},
],
}),
longLog,
JSON.stringify({
id: 12345,
name: "server-tests",
status: "completed",
conclusion: "failure",
html_url: "https://github.com/acme/repo/actions/runs/456/job/789",
check_suite: { workflow_run: { id: 456 } },
}),
JSON.stringify([]),
JSON.stringify({
jobs: [
{
id: 789,
name: "test",
status: "completed",
conclusion: "failure",
html_url: "https://github.com/acme/repo/actions/runs/456/job/789",
completed_at: "2026-04-02T13:52:14Z",
},
],
}),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
now: () => 100,
});
const first = await service.getCheckDetails({
cwd: "/repo",
repoOwner: "acme",
repoName: "repo",
checkRunId: 12345,
});
const second = await service.getCheckDetails({
cwd: "/repo",
repoOwner: "acme",
repoName: "repo",
checkRunId: 12345,
force: true,
reason: "verify-log-cache",
});
expect(first).toEqual({
checkRunId: 12345,
workflowRunId: 456,
name: "server-tests",
status: "completed",
conclusion: "failure",
url: "https://github.com/acme/repo/actions/runs/456/job/789",
detailsUrl: "https://github.com/acme/repo/actions/runs/456/job/789",
output: {
title: "Tests failed",
summary: "1 failure",
text: "Assertion failed",
},
annotations: [
{
path: "packages/server/src/index.ts",
startLine: 10,
endLine: 12,
annotationLevel: "failure",
message: "Expected true",
title: "server test failed",
rawDetails: "stack trace",
},
],
failedJobs: [
{
jobId: 789,
name: "test",
status: "completed",
conclusion: "failure",
url: "https://github.com/acme/repo/actions/runs/456/job/789",
logTail: Array.from({ length: 200 }, (_, index) => `line ${index + 21}`).join("\n"),
logTruncated: true,
},
],
truncated: true,
});
expect(second.failedJobs[0]?.logTail).toBe(first.failedJobs[0]?.logTail);
expect(runner.calls.map((call) => call.args)).toEqual([
["api", "repos/acme/repo/check-runs/12345"],
["api", "repos/acme/repo/check-runs/12345/annotations", "-f", "per_page=20"],
["api", "repos/acme/repo/actions/runs/456/jobs", "-f", "per_page=100"],
["api", "repos/acme/repo/actions/jobs/789/logs"],
["api", "repos/acme/repo/check-runs/12345"],
["api", "repos/acme/repo/check-runs/12345/annotations", "-f", "per_page=20"],
["api", "repos/acme/repo/actions/runs/456/jobs", "-f", "per_page=100"],
]);
});
it("caps failed check jobs at five and caps each log tail to 16 KiB", async () => {
const oversizedLog = "x".repeat(20 * 1024);
const failedJobs = Array.from({ length: 6 }, (_, index) => ({
id: 800 + index,
name: `failed-${index + 1}`,
status: "completed",
conclusion: "failure",
html_url: `https://github.com/acme/repo/actions/runs/456/job/${800 + index}`,
completed_at: `2026-04-02T13:52:${10 + index}Z`,
}));
const runner = createRunner([
JSON.stringify({
id: 12345,
name: "server-tests",
status: "completed",
conclusion: "failure",
html_url: "https://github.com/acme/repo/actions/runs/456/job/789",
check_suite: { workflow_run: { id: 456 } },
}),
JSON.stringify([]),
JSON.stringify({ jobs: failedJobs }),
oversizedLog,
oversizedLog,
oversizedLog,
oversizedLog,
oversizedLog,
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
now: () => 100,
});
const details = await service.getCheckDetails({
cwd: "/repo",
repoOwner: "acme",
repoName: "repo",
checkRunId: 12345,
});
expect(details.failedJobs.map((job) => job.jobId)).toEqual([800, 801, 802, 803, 804]);
expect(details.failedJobs.map((job) => job.logTruncated)).toEqual([
true,
true,
true,
true,
true,
]);
expect(details.failedJobs.map((job) => Buffer.byteLength(job.logTail ?? "", "utf8"))).toEqual([
16 * 1024,
16 * 1024,
16 * 1024,
16 * 1024,
16 * 1024,
]);
expect(runner.calls.map((call) => call.args)).toEqual([
["api", "repos/acme/repo/check-runs/12345"],
["api", "repos/acme/repo/check-runs/12345/annotations", "-f", "per_page=20"],
["api", "repos/acme/repo/actions/runs/456/jobs", "-f", "per_page=100"],
["api", "repos/acme/repo/actions/jobs/800/logs"],
["api", "repos/acme/repo/actions/jobs/801/logs"],
["api", "repos/acme/repo/actions/jobs/802/logs"],
["api", "repos/acme/repo/actions/jobs/803/logs"],
["api", "repos/acme/repo/actions/jobs/804/logs"],
]);
expect(details.truncated).toBe(true);
});
it("marks check details truncated when annotations hit the page cap", async () => {
const annotations = Array.from({ length: 20 }, (_, index) => ({
path: `packages/server/src/file-${index}.ts`,
start_line: index + 1,
annotation_level: "failure",
message: `Failure ${index + 1}`,
}));
const runner = createRunner([
JSON.stringify({
id: 12345,
name: "server-tests",
status: "completed",
conclusion: "failure",
html_url: "https://github.com/acme/repo/actions/runs/456/job/789",
check_suite: { workflow_run: { id: 456 } },
}),
JSON.stringify(annotations),
JSON.stringify({ jobs: [] }),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
now: () => 100,
});
const details = await service.getCheckDetails({
cwd: "/repo",
repoOwner: "acme",
repoName: "repo",
checkRunId: 12345,
});
expect(details.annotations).toHaveLength(20);
expect(details.truncated).toBe(true);
});
it("marks check details truncated when workflow jobs hit the page cap", async () => {
const jobs = Array.from({ length: 100 }, (_, index) => ({
id: 900 + index,
name: `job-${index + 1}`,
status: "completed",
conclusion: "success",
html_url: `https://github.com/acme/repo/actions/runs/456/job/${900 + index}`,
completed_at: `2026-04-02T13:52:${String(index % 60).padStart(2, "0")}Z`,
}));
const runner = createRunner([
JSON.stringify({
id: 12345,
name: "server-tests",
status: "completed",
conclusion: "failure",
html_url: "https://github.com/acme/repo/actions/runs/456/job/789",
check_suite: { workflow_run: { id: 456 } },
}),
JSON.stringify([]),
JSON.stringify({ jobs }),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
now: () => 100,
});
const details = await service.getCheckDetails({
cwd: "/repo",
repoOwner: "acme",
repoName: "repo",
checkRunId: 12345,
});
expect(details.failedJobs).toEqual([]);
expect(details.truncated).toBe(true);
});
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("keeps an open PR when its remote head SHA differs from the checkout HEAD", async () => {
const runner = createRunner([
currentPullRequestJson({
headRefOid: "1111111111111111111111111111111111111111",
}),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headSha: "2222222222222222222222222222222222222222",
});
expect(status).toMatchObject({ number: 42, state: "open" });
});
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",
forgeSpecific: {
forge: "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("keeps a merged PR when headRefOid matches the checkout HEAD", async () => {
const headSha = "2222222222222222222222222222222222222222";
const merged = currentPullRequestJson({
state: "MERGED",
mergedAt: "2026-07-17T12:00:00Z",
headRefOid: headSha,
headRepositoryOwner: { login: "parentOwner" },
});
const runner = createScriptedRunner([
merged,
JSON.stringify({ owner: { login: "parentOwner" }, name: "parentRepo", parent: null }),
JSON.stringify([JSON.parse(merged)]),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headSha,
});
expect(status?.number).toBe(42);
expect(status?.state).toBe("merged");
});
it("shows a merged PR after a same-name branch advances", async () => {
const stale = currentPullRequestJson({
state: "MERGED",
mergedAt: "2026-07-17T12:00:00Z",
headRefOid: "1111111111111111111111111111111111111111",
headRepositoryOwner: { login: "parentOwner" },
});
const runner = createScriptedRunner([
stale,
JSON.stringify({ owner: { login: "parentOwner" }, name: "parentRepo", parent: null }),
JSON.stringify([JSON.parse(stale)]),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
await expect(
service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headSha: "2222222222222222222222222222222222222222",
}),
).resolves.toMatchObject({
number: 42,
state: "merged",
headSha: "1111111111111111111111111111111111111111",
});
expect(runner.calls.some((call) => call.args[0] === "pr" && call.args[1] === "list")).toBe(
true,
);
});
it.each(["OPEN", "MERGED"])(
"ignores a %s PR from another repository of the same owner",
async (state) => {
const foreign = JSON.parse(
currentPullRequestJson({
state,
headRepositoryOwner: { login: "same" },
headRepository: { name: "other" },
}),
);
const wanted = JSON.parse(
currentPullRequestJson({
number: 43,
state,
headRepositoryOwner: { login: "same" },
headRepository: { name: "wanted" },
}),
);
const runner = createScriptedRunner([
JSON.stringify(foreign),
JSON.stringify([foreign, wanted]),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headRepositoryOwner: "same",
headRepositoryPath: "same/wanted",
});
expect(status?.number).toBe(43);
},
);
it("uses the checkout hint only to discover its fork repository", async () => {
const hint = currentPullRequestJson({
number: 9,
state: "MERGED",
headRepositoryOwner: { login: "fork" },
headRepository: { name: "renamed" },
});
const open = currentPullRequestJson({
number: 10,
headRepositoryOwner: { login: "fork" },
headRepository: { name: "renamed" },
});
const runner = createScriptedRunner([hint, open]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headRepositoryOwner: "fork",
changeRequestNumber: 9,
});
expect(status).toMatchObject({ number: 10, state: "open" });
});
it("uses the full head path when no separate owner hint is present", async () => {
const other = currentPullRequestJson({
url: "https://github.com/base/repo/pull/42",
state: "CLOSED",
headRepositoryOwner: { login: "base" },
headRepository: { name: "repo" },
});
const wanted = currentPullRequestJson({
number: 43,
url: "https://github.com/base/repo/pull/43",
headRepositoryOwner: { login: "fork" },
headRepository: { name: "renamed" },
});
const runner = createScriptedRunner([
other,
JSON.stringify({ owner: { login: "base" }, name: "repo", parent: null }),
JSON.stringify([JSON.parse(wanted)]),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
expect(
await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headRepositoryPath: "fork/renamed",
}),
).toMatchObject({ number: 43, state: "open" });
});
it("finds the correct repository when the branch search is crowded", async () => {
const other = JSON.parse(
currentPullRequestJson({
headRepositoryOwner: { login: "same" },
headRepository: { name: "other" },
}),
);
const crowd = Array.from({ length: 10 }, (_, i) => ({ ...other, number: i + 100 }));
const wanted = currentPullRequestJson({
number: 42,
url: "https://github.com/base/project/pull/42",
headRepositoryOwner: { login: "same" },
headRepository: { name: "wanted" },
});
const runner = createScriptedRunner([
JSON.stringify(other),
JSON.stringify(crowd),
JSON.stringify({ full_name: "same/wanted" }),
JSON.stringify({ owner: { login: "base" }, name: "project", parent: null }),
JSON.stringify([{ number: 42, state: "open", head: { repo: { full_name: "same/wanted" } } }]),
wanted,
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
expect(
await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headRepositoryOwner: "same",
headRepositoryPath: "same/wanted",
}),
).toMatchObject({ number: 42, state: "open" });
});
it("selects the most recently updated terminal PR instead of the checkout SHA", async () => {
const older = JSON.parse(
currentPullRequestJson({
number: 41,
state: "MERGED",
mergedAt: "2026-07-01T00:00:00Z",
updatedAt: "2026-08-01T01:00:00+02:00",
headRefOid: "checkout",
}),
);
const newer = JSON.parse(
currentPullRequestJson({
number: 42,
state: "CLOSED",
mergedAt: null,
updatedAt: "2026-08-01T00:00:00Z",
headRefOid: "remote",
}),
);
const runner = createScriptedRunner([
JSON.stringify(older),
JSON.stringify({ owner: { login: "parentOwner" }, name: "parentRepo", parent: null }),
JSON.stringify([older, newer]),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
expect(
await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headSha: "checkout",
headRepositoryPath: "parentOwner/parentRepo",
}),
).toMatchObject({ number: 42, state: "closed" });
});
it("prefers an open PR over an exact-SHA merged PR for the same head", async () => {
const checkoutSha = "2222222222222222222222222222222222222222";
const owner = { login: "forkOwner" };
const staleView = currentPullRequestJson({
state: "MERGED",
mergedAt: "2026-07-15T12:00:00Z",
headRefOid: "0000000000000000000000000000000000000000",
headRepositoryOwner: owner,
});
const open = JSON.parse(
currentPullRequestJson({
number: 43,
state: "OPEN",
headRefOid: "3333333333333333333333333333333333333333",
headRepositoryOwner: owner,
}),
);
const exactMerged = JSON.parse(
currentPullRequestJson({
number: 42,
state: "MERGED",
mergedAt: "2026-07-16T12:00:00Z",
headRefOid: checkoutSha,
headRepositoryOwner: owner,
}),
);
const runner = createScriptedRunner([staleView, JSON.stringify([exactMerged, open])]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headSha: checkoutSha,
headRepositoryOwner: "forkOwner",
headRepositoryPath: "forkOwner/parentRepo",
});
expect(status).toMatchObject({ number: 43, state: "open" });
});
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,
updated_at: "2026-08-02",
head: { repo: { full_name: "forkOwner/parentRepo" } },
},
{
number: 42,
updated_at: "2026-08-01",
head: { repo: { full_name: "forkOwner/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" },
headRepository: { name: "parentRepo" },
}),
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,
statusCheckRollup: [],
reviewDecision: "REVIEW_REQUIRED",
headRepositoryOwner: { login: "forkOwner" },
headRepository: { name: "parentRepo" },
}),
]);
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, 5).map((call) => call.args)).toEqual([
["pr", "view", "--json", CURRENT_PR_STATUS_FIELDS],
["repo", "view", "--json", "owner,name,parent"],
["api", `repos/parentOwner/parentRepo/pulls?${FORK_PR_QUERY}`],
["pr", "view", "41", "--repo", "parentOwner/parentRepo", "--json", CURRENT_PR_STATUS_FIELDS],
["pr", "view", "42", "--repo", "parentOwner/parentRepo", "--json", CURRENT_PR_STATUS_FIELDS],
]);
});
it("selects the latest fork PR when a branch was reused", async () => {
const checkoutSha = "3333333333333333333333333333333333333333";
const viewed: number[] = [];
const runner: GitHubCommandRunner = async (args) => {
if (args[0] === "repo" && args[1] === "view") {
return {
stdout: JSON.stringify({
owner: { login: "forkOwner" },
name: "parentRepo",
parent: { owner: { login: "parentOwner" }, name: "parentRepo" },
}),
stderr: "",
};
}
if (args[0] === "api") {
// Newest first, and the checkout is sitting on the oldest attempt.
return {
stdout: JSON.stringify([
{
number: 50,
head: { sha: "5".repeat(40), repo: { full_name: "forkOwner/parentRepo" } },
},
{
number: 49,
head: { sha: "4".repeat(40), repo: { full_name: "forkOwner/parentRepo" } },
},
{
number: 48,
head: { sha: "8".repeat(40), repo: { full_name: "forkOwner/parentRepo" } },
},
{ number: 47, head: { sha: checkoutSha, repo: { full_name: "forkOwner/parentRepo" } } },
]),
stderr: "",
};
}
if (args[0] === "pr" && args[1] === "view" && /^\d+$/.test(args[2] ?? "")) {
const number = Number(args[2]);
viewed.push(number);
return {
stdout: currentPullRequestJson({
number,
url: `https://github.com/parentOwner/parentRepo/pull/${number}`,
state: "CLOSED",
headRefOid: number === 47 ? checkoutSha : `${number}`.repeat(20),
headRepositoryOwner: { login: "forkOwner" },
}),
stderr: "",
};
}
throw noPullRequestError(args);
};
const service = createGitHubService({
runner,
resolveGhPath: async () => "/usr/bin/gh",
now: () => 100,
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headSha: checkoutSha,
});
expect(status).toMatchObject({
number: 50,
repoOwner: "parentOwner",
repoName: "parentRepo",
});
expect(viewed).not.toContain(47);
expect(viewed).toHaveLength(3);
});
it("does not scan beyond the fork request budget when open status is unknown", async () => {
const refs = Array.from({ length: 100 }, (_, i) => ({
number: i + 1,
state: "closed",
head: { repo: { full_name: "forkOwner/parentRepo" } },
}));
const runner = createScriptedRunner([
{ error: noPullRequestError() },
JSON.stringify({
owner: { login: "forkOwner" },
name: "parentRepo",
parent: { owner: { login: "parentOwner" }, name: "parentRepo" },
}),
JSON.stringify(refs),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
await expect(
service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" }),
).rejects.toBeInstanceOf(ForgeDiscoveryIncompleteError);
expect(runner.calls.filter((call) => call.args[0] === "api")).toHaveLength(1);
});
it("matches a repository transferred while the checkout retains its old origin URL", async () => {
const pr = currentPullRequestJson({
url: "https://github.com/new-owner/repo/pull/42",
headRepositoryOwner: { login: "new-owner" },
headRepository: { name: "repo" },
});
const runner = createScriptedRunner([
pr,
JSON.stringify({ owner: { login: "new-owner" }, name: "repo", parent: null }),
JSON.stringify([JSON.parse(pr)]),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoSlug: async () => "old-owner/repo",
});
expect(
await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headRepositoryPath: "old-owner/repo",
}),
).toMatchObject({ number: 42 });
});
it("matches a transferred fork using its canonical repository identity", async () => {
const pr = currentPullRequestJson({
url: "https://github.com/base/repo/pull/42",
headRepositoryOwner: { login: "new-fork" },
headRepository: { name: "repo" },
});
const runner = createScriptedRunner([
pr,
JSON.stringify([JSON.parse(pr)]),
JSON.stringify({ full_name: "new-fork/repo" }),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
expect(
await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headRepositoryOwner: "old-fork",
headRepositoryPath: "old-fork/repo",
}),
).toMatchObject({ number: 42 });
});
it("finds a transferred fork when the branch candidate window is full", async () => {
const pr = JSON.parse(
currentPullRequestJson({
url: "https://github.com/base/repo/pull/42",
headRepositoryOwner: { login: "new-fork" },
headRepository: { name: "repo" },
}),
);
const unrelated = Array.from({ length: 9 }, (_, i) => ({
...pr,
number: i + 50,
headRepositoryOwner: { login: "other" },
}));
const runner = createScriptedRunner([
JSON.stringify(pr),
JSON.stringify([...unrelated, pr]),
JSON.stringify({ full_name: "new-fork/repo" }),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
expect(
await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headRepositoryOwner: "old-fork",
headRepositoryPath: "old-fork/repo",
}),
).toMatchObject({ number: 42, state: "open" });
});
it("uses the canonical fork in the bounded terminal fallback after a transfer", async () => {
const pr = JSON.parse(
currentPullRequestJson({
url: "https://github.com/base/repo/pull/42",
state: "MERGED",
mergedAt: "2026-01-01T00:00:00Z",
headRepositoryOwner: { login: "new-fork" },
headRepository: { name: "repo" },
}),
);
const unrelated = Array.from({ length: 9 }, (_, i) => ({
...pr,
number: i + 50,
headRepositoryOwner: { login: "other" },
}));
const runner = createScriptedRunner([
JSON.stringify(pr),
JSON.stringify([...unrelated, pr]),
JSON.stringify({ full_name: "new-fork/repo" }),
JSON.stringify({ owner: { login: "base" }, name: "repo", parent: null }),
JSON.stringify([
{ number: 42, state: "closed", head: { repo: { full_name: "new-fork/repo" } } },
]),
JSON.stringify(pr),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
expect(
await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headRepositoryOwner: "old-fork",
headRepositoryPath: "old-fork/repo",
}),
).toMatchObject({ number: 42, state: "merged" });
expect(
runner.calls.some((call) => call.args[1]?.includes("head=new-fork%3Afeature%2Ffork")),
).toBe(true);
});
it("propagates a failed fork PR lookup instead of reporting no pull request", async () => {
const rateLimitError = new GitHubCommandError({
args: ["api", "repos/parentOwner/parentRepo/pulls"],
cwd: "/repo",
exitCode: 1,
stderr: "API rate limit exceeded",
});
const runner = createScriptedRunner([
{ error: noPullRequestError() },
JSON.stringify({
owner: { login: "forkOwner" },
name: "parentRepo",
parent: { owner: { login: "parentOwner" }, name: "parentRepo" },
}),
{ error: rateLimitError },
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
now: () => 100,
});
await expect(
service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" }),
).rejects.toThrow(GitHubCommandError);
});
it("resolves a fork PR whose branch name is crowded in the parent repository", async () => {
// Mirrors what `gh` really does: `pr list --head` matches the string
// against headRefName, so an "owner:branch" head never matches, and a
// popular branch name fills the candidate page with other forks' PRs.
const calls: RunnerCall[] = [];
function parentRepoPullRequest(
number: number,
headRepositoryOwner: string,
): Record<string, unknown> {
return {
number,
url: `https://github.com/parentOwner/parentRepo/pull/${number}`,
title: `PR ${number}`,
state: "OPEN",
isDraft: false,
baseRefName: "main",
headRefName: "main",
headRefOid: `${number}`.padStart(40, "0"),
mergedAt: null,
statusCheckRollup: [],
reviewDecision: "REVIEW_REQUIRED",
headRepositoryOwner: { login: headRepositoryOwner },
headRepository: { name: "parentRepo" },
};
}
const crowd = Array.from({ length: 10 }, (_, index) =>
parentRepoPullRequest(100 + index, `otherFork${index}`),
);
const runner: GitHubCommandRunner = async (args, options) => {
calls.push({ args, cwd: options.cwd });
if (args[0] === "repo" && args[1] === "view") {
return {
stdout: JSON.stringify({
owner: { login: "forkOwner" },
name: "parentRepo",
parent: { owner: { login: "parentOwner" }, name: "parentRepo" },
}),
stderr: "",
};
}
if (args[0] === "pr" && args[1] === "view" && args[2] === "4984") {
return { stdout: JSON.stringify(parentRepoPullRequest(4984, "forkOwner")), stderr: "" };
}
if (args[0] === "pr" && args[1] === "view") {
throw noPullRequestError(args);
}
if (args[0] === "pr" && args[1] === "list") {
const headRef = args[args.indexOf("--head") + 1];
const limit = Number(args[args.indexOf("--limit") + 1]);
const matching = crowd.filter((candidate) => candidate.headRefName === headRef);
return { stdout: JSON.stringify(matching.slice(0, limit)), stderr: "" };
}
if (args[0] === "api" && args[1]?.startsWith("repos/")) {
expect(args[1]).toContain("head=forkOwner%3Amain");
return {
stdout: JSON.stringify([
...[1, 2, 3, 4].map((number) => ({
number,
state: "open",
head: { repo: { full_name: "forkOwner/other" } },
})),
{ number: 4984, state: "open", head: { repo: { full_name: "forkOwner/parentRepo" } } },
]),
stderr: "",
};
}
return { stdout: "{}", stderr: "" };
};
const service = createGitHubService({
runner,
resolveGhPath: async () => "/usr/bin/gh",
now: () => 100,
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "main",
});
expect(status).toMatchObject({
number: 4984,
repoOwner: "parentOwner",
repoName: "parentRepo",
headRefName: "main",
});
});
it("retries the fork PR view 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" },
}),
JSON.stringify([{ number: 42, head: { repo: { full_name: "forkOwner/parentRepo" } } }]),
{
error: statusCheckRollupPermissionError([
"pr",
"view",
"42",
"--repo",
"parentOwner/parentRepo",
"--json",
CURRENT_PR_STATUS_FIELDS,
]),
},
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" },
headRepository: { name: "parentRepo" },
}),
]);
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, 5).map((call) => call.args)).toEqual([
["pr", "view", "--json", CURRENT_PR_STATUS_FIELDS],
["repo", "view", "--json", "owner,name,parent"],
["api", `repos/parentOwner/parentRepo/pulls?${FORK_PR_QUERY}`],
["pr", "view", "42", "--repo", "parentOwner/parentRepo", "--json", CURRENT_PR_STATUS_FIELDS],
[
"pr",
"view",
"42",
"--repo",
"parentOwner/parentRepo",
"--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"],
[
"pr",
"list",
"--state",
"all",
"--head",
"main",
"--search",
"sort:updated-desc",
"--limit",
"10",
"--json",
CURRENT_PR_STATUS_FIELDS,
],
]);
});
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" },
headRepository: { name: "repo" },
},
]),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
now: () => 100,
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "main",
headRepositoryOwner: "chethanuk",
headRepositoryPath: "chethanuk/repo",
});
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",
"--search",
"sort:updated-desc",
"--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, head: { repo: { full_name: "forkOwner/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" },
headRepository: { name: "repo" },
}),
]);
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).toEqual(["api", `repos/parentOwner/repo/pulls?${FORK_PR_QUERY}`]);
});
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("measures a still-running check to now, so a pending check reports a duration too", () => {
const nowMs = Date.parse("2026-04-02T13:55:00Z");
const checks = parseStatusCheckRollup(
[
{
__typename: "CheckRun",
name: "still-going",
status: "IN_PROGRESS",
conclusion: null,
detailsUrl: "https://github.com/acme/repo/runs/1",
startedAt: "2026-04-02T13:50:00Z",
completedAt: null,
},
{
__typename: "CheckRun",
name: "queued",
status: "QUEUED",
conclusion: null,
detailsUrl: "https://github.com/acme/repo/runs/2",
startedAt: null,
completedAt: null,
},
{
__typename: "CheckRun",
name: "finished",
status: "COMPLETED",
conclusion: "SUCCESS",
detailsUrl: "https://github.com/acme/repo/runs/3",
startedAt: "2026-04-02T13:50:00Z",
completedAt: "2026-04-02T13:52:14Z",
},
],
nowMs,
);
expect(checks.map((check) => [check.name, check.duration])).toEqual([
["still-going", "5m"],
["queued", undefined],
["finished", "2m 14s"],
]);
});
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,
},
featuresEnabled: true,
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",
},
featuresEnabled: true,
githubFeaturesEnabled: true,
error: null,
requestId: "req-new",
},
});
expect(newDaemonResponse.payload.status).toEqual({
forge: "github",
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",
resolveRepoHost: async () => null,
now: () => 100,
});
const first = service.listPullRequests({ cwd: "/repo", query: "bug", limit: 10 });
const second = service.listPullRequests({ cwd: "/repo", query: "bug", limit: 10 });
await flushMicrotasks();
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("throws a typed command error for malformed JSON output", async () => {
const runner = createRunner(["not-json"]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
now: () => 100,
});
await expect(service.listPullRequests({ cwd: "/repo" })).rejects.toMatchObject({
kind: "command-error",
args: [
"pr",
"list",
"--search",
"",
"--json",
"number,title,url,state,body,labels,baseRefName,headRefName,updatedAt",
"--limit",
"20",
],
cwd: "/repo",
exitCode: null,
stderr: "gh did not return valid JSON (8 bytes)",
});
});
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({
featuresEnabled: true,
authState: "authenticated",
githubFeaturesEnabled: true,
items: [
{
kind: "change_request",
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("does not treat an unrelated tracker URL as a GitHub issue/PR number", async () => {
const runner = createRunner([issueJson("Issue title"), searchPullRequestJson("PR title")]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => null,
now: () => 100,
});
await service.searchIssuesAndPrs({
cwd: "/repo",
query: "https://gitlab.com/getpaseo/paseo/issues/793",
limit: 5,
});
expect(runner.calls.map((call) => call.args)).toEqual([
[
"issue",
"list",
"--search",
"https://gitlab.com/getpaseo/paseo/issues/793",
"--json",
"number,title,url,state,body,labels,updatedAt",
"--limit",
"5",
],
[
"pr",
"list",
"--search",
"https://gitlab.com/getpaseo/paseo/issues/793",
"--json",
"number,title,url,state,body,labels,baseRefName,headRefName,updatedAt",
"--limit",
"5",
],
]);
});
it("treats a GitHub Enterprise issue/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",
resolveRepoHost: async () => "github.acme.internal",
now: () => 100,
});
await service.searchIssuesAndPrs({
cwd: "/repo",
query: "https://github.acme.internal/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({
featuresEnabled: true,
authState: "authenticated",
githubFeaturesEnabled: true,
items: [
{
kind: "change_request",
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("requires forge clone URL proof for mixed ports and separates cache entries", async () => {
const calls: string[][] = [];
let cloneUrl: string | undefined = "ssh://git@forge.example:2222/parentOwner/parentRepo.git";
const service = createGitHubService({
resolveGhPath: async () => "/usr/bin/gh",
runner: async (args) => {
calls.push(args);
if (args[0] === "pr") return { stdout: currentPullRequestJson(), stderr: "" };
if (args[0] === "api" && args[1] === "repos/parentOwner/parentRepo")
return { stdout: JSON.stringify({ ssh_url: cloneUrl }), stderr: "" };
return { stdout: currentPullRequestGithubFactsJson(), stderr: "" };
},
});
const endpoint = {
transport: "ssh" as const,
host: "forge.example",
port: "2222",
path: "parentOwner/parentRepo",
};
const input = {
cwd: "/repo",
headRef: "feature/fork",
headRepositoryPath: endpoint.path,
headRepositoryEndpoint: endpoint,
};
expect((await service.getCurrentPullRequestStatus(input))?.number).toBe(42);
expect(
await service.getCurrentPullRequestStatus({
...input,
headRepositoryEndpoint: { ...endpoint, port: "2223" },
}),
).toBeNull();
expect(
(await service.getCurrentPullRequestStatus({ ...input, force: true, reason: "archive" }))
?.number,
).toBe(42);
cloneUrl = undefined;
expect(
await service.getCurrentPullRequestStatus({ ...input, force: true, reason: "archive" }),
).toBeNull();
expect(calls.filter((args) => args[1] === "repos/parentOwner/parentRepo")).toHaveLength(4);
});
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",
resolveRepoHost: async () => null,
now: () => 100,
});
const first = service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" });
const second = service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" });
await flushMicrotasks();
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("ForgeService forced read requires a reason");
});
it("type: force true requires a reason", () => {
// @ts-expect-error force: true requires reason
const invalid: ForgeReadOptions = { force: true };
const valid: ForgeReadOptions = { force: true, reason: "test" };
expect(invalid.force).toBe(true);
expect(valid.reason).toBe("test");
});
it("resolves the repo slug from the workspace when creating a pull request", async () => {
const runner = createRunner([
JSON.stringify({ owner: { login: "acme" }, name: "repo" }),
JSON.stringify({ url: "https://github.com/acme/repo/pull/7", number: 7 }),
]);
const service = createGitHubService({
runner: runner.runner,
resolveRepoHost: async () => null,
});
await expect(
service.createPullRequest({
cwd: "/tmp/repo",
title: "Add thing",
head: "feature",
base: "main",
}),
).resolves.toEqual({ url: "https://github.com/acme/repo/pull/7", number: 7 });
expect(runner.calls[0]?.args).toEqual(["repo", "view", "--json", "owner,name,parent"]);
expect(runner.calls[1]?.args).toEqual([
"api",
"-X",
"POST",
"repos/acme/repo/pulls",
"-f",
"title=Add thing",
"-f",
"head=feature",
"-f",
"base=main",
]);
});
it("routes gh calls to the resolved GitHub Enterprise host via GH_HOST", async () => {
const runner = createRunner([
JSON.stringify({ owner: { login: "acme" }, name: "repo" }),
JSON.stringify({ url: "https://github.acme.internal/acme/repo/pull/7", number: 7 }),
]);
const service = createGitHubService({
runner: runner.runner,
resolveRepoHost: async () => "github.acme.internal",
});
await expect(
service.createPullRequest({
cwd: "/tmp/repo",
title: "Add thing",
head: "feature",
base: "main",
}),
).resolves.toEqual({ url: "https://github.acme.internal/acme/repo/pull/7", number: 7 });
expect(runner.calls[1]?.args).toEqual([
"api",
"-X",
"POST",
"repos/acme/repo/pulls",
"-f",
"title=Add thing",
"-f",
"head=feature",
"-f",
"base=main",
]);
// GH_HOST is injected on every gh call (the slug lookup and the POST alike).
expect(runner.calls[0]?.envOverlay).toMatchObject({ GH_HOST: "github.acme.internal" });
expect(runner.calls[1]?.envOverlay).toMatchObject({ GH_HOST: "github.acme.internal" });
});
it.skipIf(isPlatform("win32"))(
"routes default gh calls to GitHub Enterprise only after host auth succeeds",
async () => {
vi.useRealTimers();
const fixture = createFakeGitHubCliFixture({
remoteUrl: "https://github.acme.internal/acme/repo.git",
authStatusSucceeds: true,
});
try {
const service = createGitHubService();
await expect(
service.createPullRequest({
cwd: fixture.cwd,
title: "Add thing",
head: "feature",
base: "main",
}),
).resolves.toEqual({ url: "https://github.acme.internal/acme/repo/pull/7", number: 7 });
expect(readFakeGitHubCliLog(fixture.logPath)).toEqual(
expect.arrayContaining([
"auth status --hostname github.acme.internal|",
"repo view --json owner,name,parent|github.acme.internal",
"api -X POST repos/acme/repo/pulls -f title=Add thing -f head=feature -f base=main|github.acme.internal",
]),
);
} finally {
fixture.dispose();
}
},
);
it.skipIf(isPlatform("win32"))(
"retries GitHub Enterprise host probing after a transient auth-status failure",
async () => {
vi.useRealTimers();
const fixture = createFakeGitHubCliFixture({
remoteUrl: "https://github.acme.internal/acme/repo.git",
authStatusMode: "transient-once",
});
try {
const service = createGitHubService();
await expect(
service.createPullRequest({
cwd: fixture.cwd,
title: "Add thing",
head: "feature",
base: "main",
}),
).rejects.toThrow("Unable to verify GitHub Enterprise host github.acme.internal");
await expect(
service.createPullRequest({
cwd: fixture.cwd,
title: "Add thing",
head: "feature",
base: "main",
}),
).resolves.toEqual({ url: "https://github.acme.internal/acme/repo/pull/7", number: 7 });
const log = readFakeGitHubCliLog(fixture.logPath);
expect(
log.filter((line) => line === "auth status --hostname github.acme.internal|"),
).toHaveLength(2);
expect(log).toEqual(
expect.arrayContaining([
"repo view --json owner,name,parent|github.acme.internal",
"api -X POST repos/acme/repo/pulls -f title=Add thing -f head=feature -f base=main|github.acme.internal",
]),
);
} finally {
fixture.dispose();
}
},
);
it.skipIf(isPlatform("win32"))(
"fails instead of routing gh calls to github.com for an unauthenticated Enterprise host",
async () => {
vi.useRealTimers();
const fixture = createFakeGitHubCliFixture({
remoteUrl: "https://github.acme.internal/acme/repo.git",
authStatusSucceeds: false,
});
try {
const service = createGitHubService();
// An unauthenticated Enterprise host must fail, not silently proceed
// against github.com (the default when GH_HOST is unset).
await expect(
service.createPullRequest({
cwd: fixture.cwd,
title: "Add thing",
head: "feature",
base: "main",
}),
).rejects.toThrow(/authentication failed/i);
const log = readFakeGitHubCliLog(fixture.logPath);
expect(log).toContain("auth status --hostname github.acme.internal|");
// It threw at host resolution, before running any api/graphql call.
expect(log.some((line) => line.startsWith("api ") || line.startsWith("graphql"))).toBe(
false,
);
} finally {
fixture.dispose();
}
},
);
it.skipIf(isPlatform("win32"))("does not probe or route github.com through GH_HOST", async () => {
vi.useRealTimers();
const fixture = createFakeGitHubCliFixture({
remoteUrl: "https://github.com/acme/repo.git",
authStatusSucceeds: false,
});
try {
const service = createGitHubService();
await expect(
service.createPullRequest({
cwd: fixture.cwd,
title: "Add thing",
head: "feature",
base: "main",
}),
).resolves.toEqual({ url: "https://github.acme.internal/acme/repo/pull/7", number: 7 });
const log = readFakeGitHubCliLog(fixture.logPath);
expect(log.some((line) => line.startsWith("auth status --hostname "))).toBe(false);
expect(log.every((line) => line.endsWith("|"))).toBe(true);
} finally {
fixture.dispose();
}
});
it("caches definitive null repo host resolutions", async () => {
const runner = createRunner(["[]", "[]"]);
let hostResolutions = 0;
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => {
hostResolutions += 1;
return null;
},
});
await service.listPullRequests({ cwd: "/repo", query: "one", limit: 1 });
await service.listPullRequests({ cwd: "/repo", query: "two", limit: 1 });
expect(hostResolutions).toBe(1);
expect(runner.calls.every((call) => call.envOverlay?.GH_HOST === undefined)).toBe(true);
});
it("re-resolves a null repo host after the TTL expires", async () => {
const runner = createRunner(["[]", "[]"]);
let hostResolutions = 0;
let nowMs = 0;
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
now: () => nowMs,
resolveRepoHost: async () => {
hostResolutions += 1;
return hostResolutions === 1 ? null : "ghe.example.com";
},
});
await service.listPullRequests({ cwd: "/repo", query: "one", limit: 1 });
expect(runner.calls[0]?.envOverlay?.GH_HOST).toBeUndefined();
nowMs = 60_001;
await service.listPullRequests({ cwd: "/repo", query: "two", limit: 1 });
expect(hostResolutions).toBe(2);
expect(runner.calls[1]?.envOverlay).toMatchObject({ GH_HOST: "ghe.example.com" });
});
it("re-resolves the host after invalidation when the remote changes", async () => {
const runner = createRunner([]);
let hostCall = 0;
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => (hostCall++ === 0 ? "host-a.internal" : "host-b.internal"),
});
await service.listPullRequests({ cwd: "/repo", query: "x", limit: 1 });
expect(runner.calls[0]?.envOverlay).toMatchObject({ GH_HOST: "host-a.internal" });
service.invalidate({ cwd: "/repo" });
await service.listPullRequests({ cwd: "/repo", query: "x", limit: 1 });
expect(runner.calls[1]?.envOverlay).toMatchObject({ GH_HOST: "host-b.internal" });
});
it("throws when the workspace repository cannot be resolved for pull request creation", async () => {
const runner = createRunner([JSON.stringify({})]);
const service = createGitHubService({ runner: runner.runner });
await expect(
service.createPullRequest({
cwd: "/tmp/repo",
title: "Add thing",
head: "feature",
base: "main",
}),
).rejects.toThrow("Unable to resolve GitHub repository for pull request creation");
});
});