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