mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 18:52:39 +00:00
* fix(gitea): prefix fork PR branches with the owner Two forks with the same branch name (say patch-1) ended up on the same local branch. The gitea service had no buildPrLocalBranchName or defaultCheckoutRefs, github has both. getPullRequestCheckoutTarget also fell back to refs/heads/<name> on origin for fork PRs. Thats the base repo's branch, not the fork's, so a failed refs/pull fetch checked out the wrong branch without an error. fixes #3515 * refactor(forge): share fork branch naming with github moved the owner normalizer and the branch name builder to utils/change-request-checkout.ts, gitea and github both use it. github changes a bit: a fork PR with an unknown or unusable owner (eg deleted fork) now gets pr-N/<branch> instead of the bare branch name, which could collide with a local branch the same way #3515 did. * fix(gitea): PRs from a deleted fork are still cross repo the API drops head.repo once the fork is gone, we read that as a same repo PR and fetched refs/heads/<name> from origin * fix(gitea): handle AGit PRs and deleted head branches neither has a branch on origin. forgejo reports refs/pull/N/head as head.ref for both, so skip the refs/heads fallback and dont track origin. also try refs/pull/N/head on upstream for clones where origin is your fork, like github does. that list lives in the shared util now. * test: resolver harness uses the real branch helper plus deleted forks and same repo PRs without an origin branch * fix(gitea): collapse pull-ref-only heads to pr-N in local branch names buildForkLocalBranchName still used the raw headRef even when it was just the pull ref (refs/pull/N/head), which agit prs and deleted head branches report as their headRefName. that gave local branches literal names like refs/heads/refs/pull/3/head. * fix(gitea): surface hasHeadBranch on the pr checkout target resolveForkCheckoutFacts already knew whether a pr had a real head branch (agit flow, or head.ref == the pull ref both mean no). now that signal reaches buildPrLocalBranchName so worktree names and local branches stop being named after the raw pull ref. covers same-repo, fork with owner known, and deleted fork all with no real head branch. added an end to end gitea-service test plus resolver cases since the worktree slug follows localBranchName too. * refactor(forge): one head ref kind on the checkout target hasOriginHeadBranch and hasHeadBranch could describe contradictory states (cross-repo checkout claiming an origin head branch). collapse both into headRefKind: "branch" | "pull-ref" and derive origin tracking in the resolver instead of storing it separately. Co-authored-by: Alexander Adam <alexander.adam@vade.io> * fix(worktrees): avoid branch namespace conflicts in fork checkouts Fork owner prefixes can name an existing branch as a parent, which Git rejects even when the full proposed name is unused. Allocate names against parent and child refs, suffixing the conflicting segment while preserving existing branches and fetched commits. * test(worktrees): make fork fixture line endings deterministic Windows Git converts the fixture checkout to CRLF when core.autocrlf is enabled. Set the repository fixture to preserve its written LF bytes so the content assertion checks the fetched patch on every platform. Reproduced the failure locally with inherited core.autocrlf=true and verified the corrected setup against that same configuration. --------- Co-authored-by: Alexander Adam <alexander.adam@vade.io> Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
4786 lines
147 KiB
TypeScript
4786 lines
147 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";
|
|
|
|
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,mergedAt,reviewDecision,mergeable,headRepositoryOwner";
|
|
const CURRENT_PR_STATUS_FIELDS = `${CURRENT_PR_STATUS_BASE_FIELDS},statusCheckRollup`;
|
|
const FORK_PR_QUERY = "state=all&per_page=100&head=forkOwner%3Afeature%2Ffork";
|
|
|
|
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 {
|
|
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",
|
|
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> {
|
|
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: "acme" },
|
|
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",
|
|
});
|
|
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("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: "OPEN",
|
|
mergedAt: null,
|
|
headRefName: "feat-a",
|
|
headRepositoryOwner: { login: "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();
|
|
|
|
// Ten same-named fork PRs filled the page, so absence of a self-owned PR
|
|
// is inconclusive and the target resolves through the legacy path.
|
|
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("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: ",
|
|
bodyHTML: `<p>Screenshot: <img alt="bug" src="${privateAttachmentUrl.replaceAll("&", "&")}" /></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: `,
|
|
});
|
|
});
|
|
|
|
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: "",
|
|
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: "",
|
|
});
|
|
});
|
|
|
|
it("leaves GitHub attachment image URLs unchanged when rendered images do not match", async () => {
|
|
const body = "";
|
|
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",
|
|
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 only when headRefOid matches the checkout HEAD", async () => {
|
|
const headSha = "2222222222222222222222222222222222222222";
|
|
const runner = createRunner([
|
|
currentPullRequestJson({
|
|
state: "MERGED",
|
|
mergedAt: "2026-07-17T12:00:00Z",
|
|
headRefOid: headSha,
|
|
}),
|
|
]);
|
|
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("does not attach a stale merged PR after a same-name branch advances", async () => {
|
|
const stale = currentPullRequestJson({
|
|
state: "MERGED",
|
|
mergedAt: "2026-07-17T12:00:00Z",
|
|
headRefOid: "1111111111111111111111111111111111111111",
|
|
});
|
|
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.toBeNull();
|
|
expect(runner.calls.some((call) => call.args[0] === "pr" && call.args[1] === "list")).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
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",
|
|
});
|
|
|
|
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 }, { number: 42 }]),
|
|
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" },
|
|
}),
|
|
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" },
|
|
}),
|
|
]);
|
|
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("reads the fork PR carrying the checked-out commit past newer attempts on the branch", 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) } },
|
|
{ number: 49, head: { sha: "4".repeat(40) } },
|
|
{ number: 48, head: { sha: "8".repeat(40) } },
|
|
{ number: 47, head: { sha: checkoutSha } },
|
|
]),
|
|
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: 47,
|
|
repoOwner: "parentOwner",
|
|
repoName: "parentRepo",
|
|
});
|
|
expect(viewed).toContain(47);
|
|
expect(viewed).toHaveLength(3);
|
|
});
|
|
|
|
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 },
|
|
};
|
|
}
|
|
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([{ number: 4984 }]), 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 }]),
|
|
{
|
|
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" },
|
|
}),
|
|
]);
|
|
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",
|
|
"--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" },
|
|
},
|
|
]),
|
|
]);
|
|
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 }]),
|
|
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).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("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");
|
|
});
|
|
});
|