repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/strata/__tests__/github.test.ts
Rawimport { spawn } from "node:child_process";
import { existsSync, watch } from "node:fs";
import { appendFile, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { aroundEach, describe, expect, it, vi } from "vitest";
import { cleanGitEnvironment } from "../git.js";
import {
isGitHubReviewCurrent as isCurrent,
openGitHubReview as openReview,
refreshGitHubReview as refreshReview,
} from "../github.js";
import {
cleanupNative,
git,
nativeOperation,
nativeTest,
testSignal,
withFakeExecutable,
withFakeGh,
} from "./fixture.js";
// Always exercise the native adapter, with cancellation bounded by the test.
const openGitHubReview = (
...[cwd, id, confirm, signal]: Parameters<typeof openReview>
) => nativeOperation(() => openReview(cwd, id, confirm, testSignal(signal)));
const isGitHubReviewCurrent = (
...[cwd, review, signal]: Parameters<typeof isCurrent>
) => nativeOperation(() => isCurrent(cwd, review, testSignal(signal)));
const refreshGitHubReview = (
...[cwd, review, signal]: Parameters<typeof refreshReview>
) => nativeOperation(() => refreshReview(cwd, review, testSignal(signal)));
aroundEach(async (run, context) => {
try {
await nativeTest(run, context);
} finally {
vi.restoreAllMocks();
}
});
async function cancelAfterMarker(
marker: string,
message: string,
start: (signal: AbortSignal) => Promise<unknown>,
): Promise<void> {
const controller = new AbortController();
const watcher = watch(path.dirname(marker));
const started = new Promise<void>((resolve, reject) => {
watcher.on("change", () => {
if (existsSync(marker)) resolve();
});
watcher.on("error", reject);
});
const opening = start(controller.signal);
try {
await Promise.race([
started,
opening.then(() => {
throw new Error("PR opening completed before the cancellation signal");
}),
]);
expect(existsSync(marker)).toBe(true);
const pid = Number(await readFile(marker, "utf8"));
expect(Number.isInteger(pid) && pid > 0).toBe(true);
controller.abort(new Error(message));
await expect(opening).rejects.toThrow(message);
expect(() => process.kill(pid, 0)).toThrow(/ESRCH/);
} finally {
controller.abort();
watcher.close();
await opening.catch(() => {});
}
}
function gitInput(
cwd: string,
input: string,
...args: string[]
): Promise<string> {
return nativeOperation(
() =>
new Promise((resolve, reject) => {
const signal = testSignal();
signal.throwIfAborted();
const child = spawn("git", args, {
cwd,
env: cleanGitEnvironment(),
stdio: ["pipe", "pipe", "pipe"],
});
const abort = () => {
child.kill();
};
signal.addEventListener("abort", abort, { once: true });
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk));
child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk));
let failure: Error | undefined;
child.once("error", (error) => {
failure = error;
});
child.stdin.once("error", (error) => {
failure = error;
});
child.once("close", (code) => {
signal.removeEventListener("abort", abort);
if (signal.aborted) reject(signal.reason);
else if (failure) reject(failure);
else if (code === 0)
resolve(Buffer.concat(stdout).toString("utf8").trim());
else reject(new Error(Buffer.concat(stderr).toString("utf8").trim()));
});
child.stdin.end(input);
}),
);
}
async function repository() {
return nativeOperation(async () => {
testSignal().throwIfAborted();
const root = await mkdtemp(path.join(tmpdir(), "strata-github-"));
cleanupNative(() => rm(root, { recursive: true, force: true }));
await git(root, "init", "--quiet", "--initial-branch=main");
await appendFile(
path.join(root, ".git", "config"),
"[user]\n\tname = Strata Test\n\temail = strata@example.invalid\n",
);
await writeFile(path.join(root, "change.ts"), "export const value = 1;\n");
await git(root, "add", "change.ts");
await git(root, "commit", "--quiet", "-m", "base");
const base = await git(root, "rev-parse", "HEAD");
await git(root, "checkout", "--quiet", "-b", "feature");
await writeFile(path.join(root, "change.ts"), "export const value = 2;\n");
await git(root, "commit", "--quiet", "-am", "feature");
const head = await git(root, "rev-parse", "HEAD");
await git(root, "checkout", "--quiet", "main");
await git(
root,
"remote",
"add",
"origin",
"https://github.com/acme/project.git",
);
return { root, base, head };
});
}
function fakeGh(
metadata: Record<string, unknown>,
pages: Record<string, unknown[]> = {},
) {
return `
const metadata = ${JSON.stringify(metadata)};
const pages = ${JSON.stringify(pages)};
if (args[0] === "pr" && args[1] === "view") process.stdout.write(JSON.stringify(metadata));
else if (args[0] === "api") {
if (!args.includes("GET") || args.some((arg) => ["POST", "PATCH", "PUT", "DELETE"].includes(arg))) process.exitCode = 91;
else {
const endpoint = args.at(-1);
const key = Object.keys(pages).find((candidate) => endpoint.startsWith(candidate));
process.stdout.write(JSON.stringify(key ? pages[key] : []));
}
} else process.exitCode = 92;
`;
}
function metadata(
base: string,
head: string,
overrides: Record<string, unknown> = {},
) {
return {
number: 7,
url: "https://github.com/acme/project/pull/7",
title: "Bound retries",
body: "Keep failures visible.",
author: { login: "review-author" },
baseRefName: "main",
baseRefOid: base,
headRefName: "feature",
headRefOid: head,
headRepository: { name: "project", nameWithOwner: "acme/project" },
headRepositoryOwner: { login: "acme" },
...overrides,
};
}
describe.sequential("GitHub forge adapter", () => {
it("loads all review comment kinds through read-only API calls and checks out the pinned existing branch", async () => {
const { root, base, head } = await repository();
const pages = {
"repos/acme/project/issues/7/comments": [
{
id: 11,
html_url: "https://github.com/acme/project/pull/7#issuecomment-11",
user: { login: "discussant" },
body: "Discussion",
},
],
"repos/acme/project/pulls/7/reviews": [
{
id: 12,
html_url:
"https://github.com/acme/project/pull/7#pullrequestreview-12",
user: { login: "reviewer" },
body: "Review summary",
},
],
"repos/acme/project/pulls/7/comments": [
{
id: 13,
html_url: "https://github.com/acme/project/pull/7#discussion_r13",
user: { login: "inline-reviewer" },
body: "Current line",
path: "change.ts",
side: "RIGHT",
line: 1,
commit_id: head,
position: 1,
},
{
id: 14,
html_url: "https://github.com/acme/project/pull/7#discussion_r14",
user: { login: "inline-reviewer" },
body: "Old line",
path: "change.ts",
original_side: "LEFT",
original_line: 1,
original_commit_id: base,
position: null,
in_reply_to_id: 13,
},
],
};
await withFakeGh(fakeGh(metadata(base, head), pages), async () => {
const confirm = vi.fn(async () => true);
const review = await openGitHubReview(
root,
"7",
confirm,
new AbortController().signal,
);
expect(review).toMatchObject({
provider: "github",
providerLabel: "GitHub",
repository: "acme/project",
number: 7,
baseSha: base,
headSha: head,
checkoutBranch: "feature",
});
expect(review.comments).toEqual([
expect.objectContaining({
id: "discussion:11",
kind: "discussion",
outdated: false,
}),
expect.objectContaining({
id: "review:12",
kind: "review",
outdated: false,
}),
expect.objectContaining({
id: "inline:13",
kind: "inline",
path: "change.ts",
side: "new",
line: 1,
commit: head,
outdated: false,
}),
expect.objectContaining({
id: "inline:14",
replyTo: "inline:13",
outdated: true,
}),
]);
expect(confirm).toHaveBeenCalledWith(
"Switch to GitHub PR checkout?",
expect.stringContaining(
"Repository: acme/project\nPR: #7\nCurrent branch: main\nTarget branch: feature",
),
expect.any(AbortSignal),
);
expect(await git(root, "branch", "--show-current")).toBe("feature");
expect(await git(root, "rev-parse", "HEAD")).toBe(head);
});
});
it("creates a non-colliding PR branch for a fork without overwriting existing branches", async () => {
const { root, base, head } = await repository();
await git(root, "branch", "-D", "feature");
await git(root, "branch", "strata/pr-8-fork-work", "main");
const fork = metadata(base, head, {
number: 8,
url: "https://github.com/acme/project/pull/8",
headRefName: "fork/work",
headRepository: { name: "project", nameWithOwner: "contributor/project" },
headRepositoryOwner: { login: "contributor" },
});
await withFakeGh(fakeGh(fork), async () => {
const review = await openGitHubReview(
root,
"https://github.com/acme/project/pull/8",
async () => true,
new AbortController().signal,
);
expect(review.checkoutBranch).toBe("strata/pr-8-fork-work-2");
expect(await git(root, "rev-parse", review.checkoutBranch)).toBe(head);
expect(await git(root, "rev-parse", "strata/pr-8-fork-work")).toBe(base);
});
});
it("refuses a dirty checkout before mutation", async () => {
const first = await repository();
await writeFile(path.join(first.root, "untracked.txt"), "dirty\n");
await withFakeGh(fakeGh(metadata(first.base, first.head)), async () => {
await expect(
openGitHubReview(
first.root,
"7",
async () => true,
new AbortController().signal,
),
).rejects.toThrow(/clean worktree/);
expect(await git(first.root, "branch", "--show-current")).toBe("main");
});
});
it("refuses a denied checkout before mutation", async () => {
const denied = await repository();
await withFakeGh(fakeGh(metadata(denied.base, denied.head)), async () => {
await expect(
openGitHubReview(
denied.root,
"7",
async () => false,
new AbortController().signal,
),
).rejects.toThrow(/not confirmed/);
expect(await git(denied.root, "branch", "--show-current")).toBe("main");
});
});
it("refuses a checkout without UI before mutation", async () => {
const denied = await repository();
await withFakeGh(fakeGh(metadata(denied.base, denied.head)), async () => {
await expect(
openGitHubReview(
denied.root,
"7",
undefined,
new AbortController().signal,
),
).rejects.toThrow(/no interactive confirmation/);
expect(await git(denied.root, "branch", "--show-current")).toBe("main");
});
});
it("refuses a divergent checkout before mutation", async () => {
const divergent = await repository();
await git(divergent.root, "checkout", "--quiet", "feature");
await writeFile(path.join(divergent.root, "other.ts"), "export {};\n");
await git(divergent.root, "add", "other.ts");
await git(divergent.root, "commit", "--quiet", "-m", "local commit");
await git(divergent.root, "checkout", "--quiet", "main");
await withFakeGh(
fakeGh(metadata(divergent.base, divergent.head)),
async () => {
await expect(
openGitHubReview(
divergent.root,
"7",
async () => true,
new AbortController().signal,
),
).rejects.toThrow(/diverges/);
},
);
});
it("refuses protected checkout paths before mutation", async () => {
const protectedChange = await repository();
const blob = await git(
protectedChange.root,
"rev-parse",
`${protectedChange.head}:change.ts`,
);
const tree = await gitInput(
protectedChange.root,
`100644 blob ${blob}\tchange.ts\n100644 blob ${blob}\t.env\n`,
"mktree",
);
const protectedHead = await git(
protectedChange.root,
"commit-tree",
tree,
"-p",
protectedChange.base,
"-m",
"protected metadata fixture",
);
const protectedMetadata = metadata(protectedChange.base, protectedHead, {
headRefName: "protected-fixture",
headRepository: {
name: "project",
nameWithOwner: "contributor/project",
},
headRepositoryOwner: { login: "contributor" },
});
await withFakeGh(fakeGh(protectedMetadata), async () => {
const confirm = vi.fn(async () => true);
await expect(
openGitHubReview(
protectedChange.root,
"7",
confirm,
new AbortController().signal,
),
).rejects.toThrow(/protected path \.env/);
expect(confirm).toHaveBeenCalledOnce();
expect(await git(protectedChange.root, "branch", "--show-current")).toBe(
"main",
);
});
});
it("disables configured fsmonitor hooks on the real status route", async () => {
const { root, base, head } = await repository();
const marker = path.join(root, "fsmonitor-ran");
await git(root, "config", "core.fsmonitor", "fake-fsmonitor");
await withFakeExecutable(
"fake-fsmonitor",
`require("node:fs").writeFileSync(${JSON.stringify(marker)}, "ran"); process.stdout.write("2\\n");`,
async () => {
await withFakeGh(fakeGh(metadata(base, head)), async () => {
const review = await openGitHubReview(
root,
"7",
async () => true,
new AbortController().signal,
);
expect(review.checkoutBranch).toBe("feature");
expect(existsSync(marker)).toBe(false);
});
},
);
});
it("rejects external checkout filters configured during confirmation without executing them", async () => {
const fixture = await repository();
await git(fixture.root, "checkout", "--quiet", "feature");
await writeFile(
path.join(fixture.root, ".gitattributes"),
"change.ts filter=guard\n",
);
await git(fixture.root, "add", ".gitattributes");
await git(fixture.root, "commit", "--quiet", "--amend", "--no-edit");
fixture.head = await git(fixture.root, "rev-parse", "HEAD");
await git(fixture.root, "checkout", "--quiet", "main");
const marker = path.join(fixture.root, "filter-ran");
await withFakeExecutable(
"fake-filter",
`require("node:fs").writeFileSync(${JSON.stringify(marker)}, "ran");`,
async () => {
await withFakeGh(
fakeGh(metadata(fixture.base, fixture.head)),
async () => {
await expect(
openGitHubReview(
fixture.root,
"7",
async () => {
await git(
fixture.root,
"config",
"filter.guard.clean",
"fake-filter",
);
await git(
fixture.root,
"config",
"filter.guard.smudge",
"fake-filter",
);
await git(
fixture.root,
"config",
"filter.guard.process",
"fake-filter",
);
return true;
},
new AbortController().signal,
),
).rejects.toThrow(/external clean, smudge, or process filters/);
expect(existsSync(marker)).toBe(false);
expect(await git(fixture.root, "branch", "--show-current")).toBe(
"main",
);
},
);
},
);
});
it("rejects repository credential helpers before fetch", async () => {
const credential = await repository();
const missing = "1".repeat(40);
const credentialMarker = path.join(credential.root, "credential-ran");
await git(
credential.root,
"config",
"credential.helper",
"!fake-credential",
);
await withFakeExecutable(
"fake-credential",
`require("node:fs").writeFileSync(${JSON.stringify(credentialMarker)}, "ran");`,
async () => {
await withFakeGh(
fakeGh(metadata(missing, credential.head)),
async () => {
await expect(
openGitHubReview(
credential.root,
"7",
async () => true,
new AbortController().signal,
),
).rejects.toThrow(/repository credential helpers/);
expect(existsSync(credentialMarker)).toBe(false);
},
);
},
);
});
it("rejects repository URL rewrites before fetch", async () => {
const missing = "1".repeat(40);
const rewrite = await repository();
const rewriteMarker = path.join(rewrite.root, "rewrite-ran");
await git(
rewrite.root,
"config",
"url.unsafe://redirect/.insteadOf",
"https://github.com/",
);
await withFakeExecutable(
"git-remote-unsafe",
`require("node:fs").writeFileSync(${JSON.stringify(rewriteMarker)}, "ran");`,
async () => {
await withFakeGh(fakeGh(metadata(missing, rewrite.head)), async () => {
await expect(
openGitHubReview(
rewrite.root,
"7",
async () => true,
new AbortController().signal,
),
).rejects.toThrow(/repository URL rewrites/);
expect(existsSync(rewriteMarker)).toBe(false);
});
},
);
});
it("rejects repository include config without following it", async () => {
const fixture = await repository();
const included = path.join(fixture.root, "included.config");
await writeFile(included, '[filter "included"]\n\tclean = fake-filter\n');
await git(fixture.root, "config", "include.path", included);
await withFakeGh(fakeGh(metadata(fixture.base, fixture.head)), async () => {
await expect(
openGitHubReview(
fixture.root,
"7",
async () => true,
new AbortController().signal,
),
).rejects.toThrow(/repository include config/);
});
});
it("uses gh as Git's fixed credential helper for a private HTTPS fetch", async () => {
const fixture = await repository();
const missing = "2".repeat(40);
const marker = path.join(fixture.root, "gh-credential-ran");
const program = `
const metadata = ${JSON.stringify(metadata(missing, fixture.head))};
if (args[0] === "auth" && args[1] === "git-credential") {
require("node:fs").writeFileSync(${JSON.stringify(marker)}, "ran");
process.stdout.write("username=strata-test\\npassword=controlled-test-value\\n");
} else if (args[0] === "pr" && args[1] === "view") process.stdout.write(JSON.stringify(metadata));
else if (args[0] === "api") process.stdout.write("[]");
else process.exitCode = 92;
`;
await withFakeExecutable(
"git-remote-https",
`const result = require("node:child_process").spawnSync("git", ["credential", "fill"], { input: "protocol=https\\nhost=github.com\\n\\n", encoding: "utf8" }); if (result.status !== 0) process.exitCode = 90; else process.exitCode = 1;`,
async () => {
await withFakeGh(program, async () => {
await expect(
openGitHubReview(
fixture.root,
"7",
async () => true,
new AbortController().signal,
),
).rejects.toThrow(/could not fetch pinned commit/);
expect(existsSync(marker)).toBe(true);
});
},
);
});
it("paginates discussion through read-only API calls", async () => {
const paginated = await repository();
const discussions = Array.from({ length: 101 }, (_, index) => ({
id: index + 1,
html_url: `https://github.com/acme/project/pull/7#issuecomment-${index + 1}`,
user: { login: `author-${index + 1}` },
body: `Discussion ${index + 1}`,
}));
const paginatedGh = `
const metadata = ${JSON.stringify(metadata(paginated.base, paginated.head))};
const discussions = ${JSON.stringify(discussions)};
if (args[0] === "pr" && args[1] === "view") process.stdout.write(JSON.stringify(metadata));
else if (args[0] === "api") {
if (!args.includes("GET") || args.some((arg) => ["POST", "PATCH", "PUT", "DELETE"].includes(arg))) process.exitCode = 91;
else {
const endpoint = args.at(-1);
const page = Number(/[?&]page=(\\d+)/.exec(endpoint)?.[1] ?? "1");
const values = endpoint.startsWith("repos/acme/project/issues/7/comments")
? discussions.slice((page - 1) * 100, page * 100)
: [];
process.stdout.write(JSON.stringify(values));
}
} else process.exitCode = 92;
`;
await withFakeGh(paginatedGh, async () => {
const review = await openGitHubReview(
paginated.root,
"7",
async () => true,
new AbortController().signal,
);
expect(review.comments).toHaveLength(101);
expect(review.comments.at(-1)?.id).toBe("discussion:101");
});
});
it("rejects malformed forge metadata", async () => {
const malformed = await repository();
await withFakeGh(
fakeGh(
metadata(malformed.base, malformed.head, { baseRefOid: "not-an-oid" }),
),
async () => {
await expect(
openGitHubReview(
malformed.root,
"7",
async () => true,
new AbortController().signal,
),
).rejects.toThrow(/invalid PR base commit/);
},
);
});
it("rejects foreign PR URLs", async () => {
const { root, base, head } = await repository();
await withFakeGh(fakeGh(metadata(base, head)), async () => {
await expect(
openGitHubReview(
root,
"https://github.com/other/project/pull/7",
async () => true,
new AbortController().signal,
),
).rejects.toThrow(/does not belong/);
});
});
it("rejects unsafe imported links", async () => {
const { root, base, head } = await repository();
const pages = {
"repos/acme/project/issues/7/comments": [
{
id: 1,
html_url: "javascript:alert(1)",
user: { login: "attacker" },
body: "<img src=x onerror=alert(1)>",
},
],
};
await withFakeGh(fakeGh(metadata(base, head), pages), async () => {
await expect(
openGitHubReview(
root,
"7",
async () => true,
new AbortController().signal,
),
).rejects.toThrow(/unsafe discussion comment url/);
});
});
it.each(["same-commit branch switch", "dirty worktree"])(
"rejects freshness after a %s",
async (change) => {
const { root, base, head } = await repository();
await git(root, "checkout", "--quiet", "feature");
await withFakeGh(fakeGh(metadata(base, head)), async () => {
const review = await openGitHubReview(
root,
undefined,
async () => true,
new AbortController().signal,
);
expect(
await isGitHubReviewCurrent(
root,
review,
new AbortController().signal,
),
).toBe(true);
if (change === "same-commit branch switch") {
await git(root, "branch", "same-sha", head);
await git(root, "checkout", "--quiet", "same-sha");
} else {
await writeFile(path.join(root, "local.txt"), "dirty\n");
}
expect(
await isGitHubReviewCurrent(
root,
review,
new AbortController().signal,
),
).toBe(false);
});
},
);
it("requires reopen for a new PR head", async () => {
const changed = await repository();
await withFakeGh(fakeGh(metadata(changed.base, changed.head)), async () => {
const review = await openGitHubReview(
changed.root,
"7",
async () => true,
new AbortController().signal,
);
await git(
changed.root,
"checkout",
"--quiet",
"-b",
"remote-new",
changed.head,
);
await writeFile(
path.join(changed.root, "next.ts"),
"export const next = true;\n",
);
await git(changed.root, "add", "next.ts");
await git(
changed.root,
"commit",
"--quiet",
"-m",
"new remote head fixture",
);
const nextHead = await git(changed.root, "rev-parse", "HEAD");
await git(changed.root, "checkout", "--quiet", "feature");
await withFakeGh(fakeGh(metadata(changed.base, nextHead)), async () => {
await expect(
refreshGitHubReview(
changed.root,
review,
new AbortController().signal,
),
).rejects.toThrow(/new head commit.*reopen/s);
});
});
});
it("validates discussion before confirmation and leaves the original branch on failure", async () => {
const { root, base, head } = await repository();
const program = `
const metadata = ${JSON.stringify(metadata(base, head))};
if (args[0] === "pr") process.stdout.write(JSON.stringify(metadata));
else if (args.at(-1).startsWith("repos/acme/project/issues/7/comments")) process.stdout.write("{}");
else process.stdout.write("[]");
`;
await withFakeGh(program, async () => {
const confirm = vi.fn(async () => true);
await expect(
openGitHubReview(root, "7", confirm, new AbortController().signal),
).rejects.toThrow(/discussion page must be an array/);
expect(confirm).not.toHaveBeenCalled();
expect(await git(root, "branch", "--show-current")).toBe("main");
});
});
it("leaves the original branch when comment loading is cancelled", async () => {
const { root, base, head } = await repository();
const marker = path.join(root, "comment-started");
const program = `
if (args[0] === "pr") process.stdout.write(${JSON.stringify(JSON.stringify(metadata(base, head)))});
else if (args.at(-1).startsWith("repos/acme/project/issues/7/comments")) {
require("node:fs").writeFileSync(${JSON.stringify(`${marker}.pending`)}, String(process.pid));
require("node:fs").renameSync(${JSON.stringify(`${marker}.pending`)}, ${JSON.stringify(marker)});
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10000);
} else process.stdout.write("[]");
`;
await withFakeGh(program, async () => {
await cancelAfterMarker(
marker,
"cancelled while loading comments",
(signal) => openGitHubReview(root, "7", async () => true, signal),
);
expect(await git(root, "branch", "--show-current")).toBe("main");
});
});
it("rejects metadata changed after confirmation without switching", async () => {
const { root, base, head } = await repository();
const marker = path.join(root, ".git", "metadata-loaded");
const first = metadata(base, head);
const changed = metadata(base, head, {
title: "Changed during confirmation",
});
const program = `
if (args[0] === "pr") {
const fs = require("node:fs");
const value = fs.existsSync(${JSON.stringify(marker)}) ? ${JSON.stringify(changed)} : ${JSON.stringify(first)};
fs.writeFileSync(${JSON.stringify(marker)}, "loaded");
process.stdout.write(JSON.stringify(value));
} else process.stdout.write("[]");
`;
await withFakeGh(program, async () => {
await expect(
openGitHubReview(
root,
"7",
async () => true,
new AbortController().signal,
),
).rejects.toThrow(/metadata changed after confirmation/);
expect(await git(root, "branch", "--show-current")).toBe("main");
});
});
it("rejects an existing target branch changed during confirmation", async () => {
const { root, base, head } = await repository();
await withFakeGh(fakeGh(metadata(base, head)), async () => {
await expect(
openGitHubReview(
root,
"7",
async () => {
await git(root, "branch", "-f", "feature", base);
return true;
},
new AbortController().signal,
),
).rejects.toThrow(/target branch feature changed/);
expect(await git(root, "branch", "--show-current")).toBe("main");
});
});
it("rejects a target branch created during confirmation", async () => {
const { root, base, head } = await repository();
await git(root, "branch", "-D", "feature");
const fork = metadata(base, head, {
number: 8,
url: "https://github.com/acme/project/pull/8",
headRefName: "fork/work",
headRepository: {
name: "project",
nameWithOwner: "contributor/project",
},
headRepositoryOwner: { login: "contributor" },
});
await withFakeGh(fakeGh(fork), async () => {
await expect(
openGitHubReview(
root,
"8",
async () => {
await git(root, "branch", "strata/pr-8-fork-work", base);
return true;
},
new AbortController().signal,
),
).rejects.toThrow(/target branch strata\/pr-8-fork-work changed/);
expect(await git(root, "branch", "--show-current")).toBe("main");
});
});
it("reuses the current branch for bare fork PR resolution without confirmation", async () => {
const { root, base, head } = await repository();
await git(root, "checkout", "--quiet", "feature");
const fork = metadata(base, head, {
headRepository: {
name: "project",
nameWithOwner: "contributor/project",
},
headRepositoryOwner: { login: "contributor" },
});
await withFakeGh(fakeGh(fork), async () => {
const confirm = vi.fn(async () => true);
const review = await openGitHubReview(
root,
undefined,
confirm,
new AbortController().signal,
);
expect(review.checkoutBranch).toBe("feature");
expect(confirm).not.toHaveBeenCalled();
expect(await git(root, "branch", "--show-current")).toBe("feature");
});
});
it("rejects an unpushed current branch for bare PR resolution", async () => {
const { root, base, head } = await repository();
await git(root, "checkout", "--quiet", "feature");
await writeFile(path.join(root, "local.ts"), "export {};\n");
await git(root, "add", "local.ts");
await git(root, "commit", "--quiet", "-m", "unpushed");
await withFakeGh(fakeGh(metadata(base, head)), async () => {
await expect(
openGitHubReview(
root,
undefined,
undefined,
new AbortController().signal,
),
).rejects.toThrow(/unpushed commits/);
expect(await git(root, "branch", "--show-current")).toBe("feature");
});
});
it("validates an explicit requested PR against the returned identity", async () => {
const { root, base, head } = await repository();
await withFakeGh(fakeGh(metadata(base, head)), async () => {
await expect(
openGitHubReview(
root,
"8",
async () => true,
new AbortController().signal,
),
).rejects.toThrow(/different PR than the one requested/);
expect(await git(root, "branch", "--show-current")).toBe("main");
});
});
it("validates an explicit requested PR URL against the returned identity", async () => {
const { root, base, head } = await repository();
await withFakeGh(fakeGh(metadata(base, head)), async () => {
await expect(
openGitHubReview(
root,
"https://github.com/acme/project/pull/8",
async () => true,
new AbortController().signal,
),
).rejects.toThrow(/different PR than the one requested/);
expect(await git(root, "branch", "--show-current")).toBe("main");
});
});
it("keeps a currently mapped inline comment current when authored on an older commit", async () => {
const { root, base, head } = await repository();
const pages = {
"repos/acme/project/pulls/7/comments": [
{
id: 21,
html_url: "https://github.com/acme/project/pull/7#discussion_r21",
user: { login: "reviewer" },
body: "Still mapped",
path: "change.ts",
side: "RIGHT",
line: 1,
original_commit_id: base,
position: 1,
},
],
};
await withFakeGh(fakeGh(metadata(base, head), pages), async () => {
const review = await openGitHubReview(
root,
"7",
async () => true,
new AbortController().signal,
);
expect(review.comments[0]).toMatchObject({
commit: base,
line: 1,
side: "new",
outdated: false,
});
});
});
it("rechecks the local branch after the is-current network call", async () => {
const { root, base, head } = await repository();
let review!: Awaited<ReturnType<typeof openGitHubReview>>;
await withFakeGh(fakeGh(metadata(base, head)), async () => {
review = await openGitHubReview(
root,
"7",
async () => true,
new AbortController().signal,
);
});
await git(root, "branch", "same-sha", head);
const program = `
if (args[0] === "pr") {
require("node:child_process").execFileSync("git", ["checkout", "--quiet", "same-sha"]);
process.stdout.write(${JSON.stringify(JSON.stringify(metadata(base, head)))});
} else process.stdout.write("[]");
`;
const sentinel = await repository();
try {
vi.stubEnv("GIT_DIR", path.join(sentinel.root, ".git"));
vi.stubEnv("GIT_WORK_TREE", sentinel.root);
vi.stubEnv("GIT_INDEX_FILE", path.join(sentinel.root, ".git", "index"));
await withFakeGh(program, async () => {
expect(
await isGitHubReviewCurrent(
root,
review,
new AbortController().signal,
),
).toBe(false);
});
} finally {
vi.unstubAllEnvs();
}
expect(await git(root, "branch", "--show-current")).toBe("same-sha");
expect(await git(sentinel.root, "branch", "--show-current")).toBe("main");
expect(await git(sentinel.root, "rev-parse", "HEAD")).toBe(sentinel.base);
});
it("rechecks a clean checkout after all refresh network calls", async () => {
const { root, base, head } = await repository();
let review!: Awaited<ReturnType<typeof openGitHubReview>>;
await withFakeGh(fakeGh(metadata(base, head)), async () => {
review = await openGitHubReview(
root,
"7",
async () => true,
new AbortController().signal,
);
});
const dirtyPath = path.join(root, "network-race.txt");
const program = `
if (args[0] === "pr") process.stdout.write(${JSON.stringify(JSON.stringify(metadata(base, head)))});
else {
if (args.at(-1).startsWith("repos/acme/project/pulls/7/comments")) require("node:fs").writeFileSync(${JSON.stringify(dirtyPath)}, "changed");
process.stdout.write("[]");
}
`;
await withFakeGh(program, async () => {
await expect(
refreshGitHubReview(root, review, new AbortController().signal),
).rejects.toThrow(/checkout branch, commit, or working tree changed/);
});
});
it("honors cancellation before gh acquisition", async () => {
const controller = new AbortController();
controller.abort(new Error("cancelled by test"));
await expect(
openGitHubReview("unused", "7", undefined, controller.signal),
).rejects.toThrow("cancelled by test");
});
it("honors cancellation during gh acquisition", async () => {
const active = await repository();
const marker = path.join(active.root, ".gh-started");
const blockingGh = `
if (args[0] === "pr" && args[1] === "view") {
require("node:fs").writeFileSync(${JSON.stringify(`${marker}.pending`)}, String(process.pid));
require("node:fs").renameSync(${JSON.stringify(`${marker}.pending`)}, ${JSON.stringify(marker)});
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10000);
} else process.exitCode = 92;
`;
await withFakeGh(blockingGh, async () => {
await cancelAfterMarker(marker, "cancelled in flight", (signal) =>
openGitHubReview(active.root, "7", async () => true, signal),
);
expect(await git(active.root, "branch", "--show-current")).toBe("main");
});
});
});