mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-15 04:45:11 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
894e38e5f1 | ||
|
|
47b0d8c4cc | ||
|
|
e92d934fd6 | ||
|
|
44d1693e02 | ||
|
|
aea6929372 | ||
|
|
166cb18960 | ||
|
|
09d5afd948 | ||
|
|
009fc34f65 | ||
|
|
c9f30fbb12 | ||
|
|
aab75649f7 | ||
|
|
ef4b591e09 | ||
|
|
d6c1a5e523 | ||
|
|
4cb0be2b56 |
@@ -199,6 +199,29 @@ describe("resolveWorktreeCreationIntent", () => {
|
||||
expect(intent).not.toHaveProperty("trackOriginHead");
|
||||
});
|
||||
|
||||
test("does not track an unrelated origin when the PR belongs to another remote", async () => {
|
||||
const deps = createResolverHarness();
|
||||
deps.forgeService.getPullRequestCheckoutTarget = async () => ({
|
||||
number: 42,
|
||||
baseRefName: "main",
|
||||
headRefName: "feature",
|
||||
checkoutRefs: [{ remoteName: "github", remoteRef: "refs/pull/42/head" }],
|
||||
headOwnerLogin: "upstream",
|
||||
headRepositorySshUrl: null,
|
||||
headRepositoryUrl: null,
|
||||
isCrossRepository: false,
|
||||
});
|
||||
const intent = await resolveWorktreeCreationIntent(
|
||||
{ action: "checkout", githubPrNumber: 42 },
|
||||
repoRoot,
|
||||
deps,
|
||||
);
|
||||
expect(intent).not.toHaveProperty("trackOriginHead");
|
||||
expect(intent).toHaveProperty("checkoutRefs", [
|
||||
{ remoteName: "github", remoteRef: "refs/pull/42/head" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("configures the contributor remote for fork PR targets", async () => {
|
||||
const deps = createResolverHarness();
|
||||
deps.forgeService.getPullRequestCheckoutTarget = async () => ({
|
||||
|
||||
@@ -192,7 +192,11 @@ async function resolvePrCheckoutIntent(
|
||||
const localBranchName = service.buildPrLocalBranchName?.({ headRef, checkoutTarget });
|
||||
const crossRepository = resolveCrossRepositoryFields(checkoutTarget);
|
||||
const trackOriginHead =
|
||||
!checkoutTarget.isCrossRepository && checkoutTarget.headRefKind !== "pull-ref";
|
||||
!checkoutTarget.isCrossRepository &&
|
||||
checkoutTarget.headRefKind !== "pull-ref" &&
|
||||
(checkoutTarget.checkoutRefs ?? defaultRefs).some(
|
||||
(ref) => (ref.remoteName ?? "origin") === "origin",
|
||||
);
|
||||
|
||||
return {
|
||||
kind: "checkout-change-request",
|
||||
|
||||
@@ -196,6 +196,7 @@ export interface PullRequestCreateResult {
|
||||
export type PullRequestMergeMethod = "merge" | "squash" | "rebase";
|
||||
|
||||
export interface PullRequestCommandStatus {
|
||||
url?: string;
|
||||
mergeable?: PullRequestMergeable;
|
||||
forgeSpecific?: ForgeSpecificStatusFacts;
|
||||
}
|
||||
|
||||
@@ -428,6 +428,20 @@ function pullRequestCheckoutTargetJson(): string {
|
||||
});
|
||||
}
|
||||
|
||||
function createEnterpriseOriginRepo(): string {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "github-enterprise-origin-"));
|
||||
execFileSync("git", ["init", "-q", cwd]);
|
||||
execFileSync("git", [
|
||||
"-C",
|
||||
cwd,
|
||||
"remote",
|
||||
"add",
|
||||
"origin",
|
||||
"https://github.acme.internal/acme/repo.git",
|
||||
]);
|
||||
return cwd;
|
||||
}
|
||||
|
||||
function repoViewJson(): string {
|
||||
return JSON.stringify({
|
||||
owner: { login: "getpaseo" },
|
||||
@@ -835,40 +849,82 @@ describe("ForgeService", () => {
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
vi.useRealTimers();
|
||||
const cwd = mkdtempSync(join(tmpdir(), "github-checkout-target-"));
|
||||
execFileSync("git", ["init", "-q", cwd]);
|
||||
execFileSync("git", [
|
||||
"-C",
|
||||
cwd,
|
||||
"remote",
|
||||
"add",
|
||||
"origin",
|
||||
"https://github.com/getpaseo/paseo.git",
|
||||
]);
|
||||
try {
|
||||
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: [
|
||||
await expect(service.getPullRequestCheckoutTarget?.({ cwd, number: 526 })).resolves.toEqual({
|
||||
number: 526,
|
||||
baseRefName: "main",
|
||||
headRefName: "main",
|
||||
checkoutRefs: [{ remoteName: "origin", 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,
|
||||
args: ["repo", "view", "--json", "owner,name,parent"],
|
||||
});
|
||||
expect(runner.calls[1]?.cwd).toBe(cwd);
|
||||
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");
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("checks out from origin when origin still uses a renamed repository's old name", async () => {
|
||||
vi.useRealTimers();
|
||||
const cwd = mkdtempSync(join(tmpdir(), "github-checkout-renamed-"));
|
||||
execFileSync("git", ["init", "-q", cwd]);
|
||||
execFileSync("git", [
|
||||
"-C",
|
||||
cwd,
|
||||
"remote",
|
||||
"add",
|
||||
"origin",
|
||||
"https://github.com/old-owner/old-name.git",
|
||||
]);
|
||||
try {
|
||||
const runner = createRunner([repoViewJson(), pullRequestCheckoutTargetJson()]);
|
||||
const service = createGitHubService({
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
});
|
||||
|
||||
const target = await service.getPullRequestCheckoutTarget({ cwd, number: 526 });
|
||||
|
||||
expect(target.checkoutRefs).toEqual([
|
||||
{ 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");
|
||||
]);
|
||||
expect(runner.calls[1]?.args).toContain("owner=getpaseo");
|
||||
service.dispose?.();
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("populates repoOwner/repoName from a GitHub Enterprise PR URL", async () => {
|
||||
@@ -1223,6 +1279,81 @@ describe("ForgeService", () => {
|
||||
service.dispose?.();
|
||||
});
|
||||
|
||||
it("keeps frozen PR checks separate for matching repositories on different hosts", async () => {
|
||||
let now = 0;
|
||||
const calls: RunnerCall[] = [];
|
||||
const runner: GitHubCommandRunner = async (args, options) => {
|
||||
calls.push({ args, cwd: options.cwd, envOverlay: options.envOverlay });
|
||||
const host = options.envOverlay?.GH_HOST;
|
||||
if (args[3]?.includes("PaseoBatchPullRequestChecks")) {
|
||||
return {
|
||||
stdout: batchPollStatusJson({
|
||||
t0: batchPollChecksAliasJson([
|
||||
{
|
||||
__typename: "StatusContext",
|
||||
context: "ci",
|
||||
state: host === "a.example" ? "SUCCESS" : "FAILURE",
|
||||
},
|
||||
]),
|
||||
}),
|
||||
stderr: "",
|
||||
};
|
||||
}
|
||||
return {
|
||||
stdout: batchPollStatusJson({
|
||||
t0: batchPollRepositoryJson([
|
||||
batchPollPrNodeJson({ url: `https://${host}/acme/widgets/pull/42` }),
|
||||
]),
|
||||
}),
|
||||
stderr: "",
|
||||
};
|
||||
};
|
||||
const service = createGitHubService({
|
||||
ttlMs: 0,
|
||||
runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
resolveRepoHost: async (cwd) => (cwd === "/ws-a" ? "a.example" : "b.example"),
|
||||
resolveRepoSlug: async () => "acme/widgets",
|
||||
now: () => now,
|
||||
});
|
||||
const statusesA: Array<CurrentPullRequestStatus | null> = [];
|
||||
const statusesB: Array<CurrentPullRequestStatus | null> = [];
|
||||
const subscriptionA = service.retainCurrentPullRequestStatusPoll?.({
|
||||
cwd: "/ws-a",
|
||||
headRef: "feat-a",
|
||||
onStatus: (status) => statusesA.push(status),
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await flushMicrotasks();
|
||||
expect(statusesA).toEqual([expect.objectContaining({ checksStatus: "success" })]);
|
||||
|
||||
const subscriptionB = service.retainCurrentPullRequestStatusPoll?.({
|
||||
cwd: "/ws-b",
|
||||
headRef: "feat-a",
|
||||
onStatus: (status) => statusesB.push(status),
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await flushMicrotasks();
|
||||
expect(statusesB).toEqual([expect.objectContaining({ checksStatus: "failure" })]);
|
||||
|
||||
now = EXPECTED_GITHUB_SLOW_POLL_MS;
|
||||
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_SLOW_POLL_MS);
|
||||
await flushMicrotasks();
|
||||
expect(statusesA).toHaveLength(2);
|
||||
expect(statusesB).toHaveLength(2);
|
||||
expect(statusesA.at(-1)).toEqual(expect.objectContaining({ checksStatus: "success" }));
|
||||
expect(statusesB.at(-1)).toEqual(expect.objectContaining({ checksStatus: "failure" }));
|
||||
expect(
|
||||
calls
|
||||
.filter((call) => call.args[3]?.includes("PaseoBatchPullRequestChecks"))
|
||||
.map((call) => call.envOverlay?.GH_HOST),
|
||||
).toEqual(["a.example", "b.example"]);
|
||||
|
||||
subscriptionA?.unsubscribe();
|
||||
subscriptionB?.unsubscribe();
|
||||
service.dispose?.();
|
||||
});
|
||||
|
||||
it("batches the first PR status poll after repository identity resolves", async () => {
|
||||
let now = 0;
|
||||
let resolveRepositoryIdentity!: () => void;
|
||||
@@ -4801,6 +4932,759 @@ describe("ForgeService", () => {
|
||||
expect(runner.calls[1]?.envOverlay).toMatchObject({ GH_HOST: "github.acme.internal" });
|
||||
});
|
||||
|
||||
it("finds a checked-out Enterprise fork PR in the upstream repository gh selects", async () => {
|
||||
vi.useRealTimers();
|
||||
const cwd = mkdtempSync(join(tmpdir(), "github-enterprise-upstream-poll-"));
|
||||
try {
|
||||
execFileSync("git", ["init", "-q", cwd]);
|
||||
execFileSync("git", [
|
||||
"-C",
|
||||
cwd,
|
||||
"remote",
|
||||
"add",
|
||||
"origin",
|
||||
"https://github.acme.internal/me/repo.git",
|
||||
]);
|
||||
execFileSync("git", [
|
||||
"-C",
|
||||
cwd,
|
||||
"remote",
|
||||
"add",
|
||||
"upstream",
|
||||
"https://github.acme.internal/acme/repo.git",
|
||||
]);
|
||||
const pullRequest = JSON.parse(
|
||||
currentPullRequestJson({
|
||||
url: "https://github.acme.internal/acme/repo/pull/42",
|
||||
headRepositoryOwner: { login: "contributor" },
|
||||
}),
|
||||
);
|
||||
const calls: string[][] = [];
|
||||
const runner: GitHubCommandRunner = async (args) => {
|
||||
calls.push(args);
|
||||
if (args[0] === "api") {
|
||||
return { stdout: currentPullRequestGithubFactsJson(), stderr: "" };
|
||||
}
|
||||
const inUpstream = args[args.indexOf("--repo") + 1] === "github.acme.internal/acme/repo";
|
||||
if (args[1] === "view") {
|
||||
if (inUpstream) return { stdout: JSON.stringify(pullRequest), stderr: "" };
|
||||
throw Object.assign(new Error("gh failed"), {
|
||||
stderr: 'no pull requests found for branch "feature/fork"',
|
||||
});
|
||||
}
|
||||
return { stdout: JSON.stringify(inUpstream ? [pullRequest] : []), stderr: "" };
|
||||
};
|
||||
const service = createGitHubService({
|
||||
ttlMs: 0,
|
||||
runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
resolveRepoHost: async () => "github.acme.internal",
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getCurrentPullRequestStatus({
|
||||
cwd,
|
||||
headRef: "feature/fork",
|
||||
headRepositoryOwner: "contributor",
|
||||
}),
|
||||
).resolves.toMatchObject({ number: 42, repoOwner: "acme" });
|
||||
expect(calls[0]).toContain("github.acme.internal/acme/repo");
|
||||
service.dispose?.();
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["feature", "123"])(
|
||||
"finds a checked-out same-repository upstream PR for branch %s on an Enterprise fork clone",
|
||||
async (branch) => {
|
||||
vi.useRealTimers();
|
||||
const cwd = mkdtempSync(join(tmpdir(), "github-enterprise-upstream-same-repo-"));
|
||||
try {
|
||||
execFileSync("git", ["init", "-q", cwd]);
|
||||
execFileSync("git", [
|
||||
"-C",
|
||||
cwd,
|
||||
"remote",
|
||||
"add",
|
||||
"origin",
|
||||
"https://github.acme.internal/me/repo.git",
|
||||
]);
|
||||
execFileSync("git", [
|
||||
"-C",
|
||||
cwd,
|
||||
"remote",
|
||||
"add",
|
||||
"upstream",
|
||||
"https://github.acme.internal/acme/repo.git",
|
||||
]);
|
||||
const pullRequest = JSON.parse(
|
||||
currentPullRequestJson({
|
||||
url: "https://github.acme.internal/acme/repo/pull/42",
|
||||
headRefName: branch,
|
||||
headRepositoryOwner: { login: "acme" },
|
||||
}),
|
||||
);
|
||||
const runner: GitHubCommandRunner = async (args) => {
|
||||
if (args[0] === "api") {
|
||||
if (args[1] === "graphql") {
|
||||
return { stdout: currentPullRequestGithubFactsJson(), stderr: "" };
|
||||
}
|
||||
return { stdout: "[]", stderr: "" };
|
||||
}
|
||||
if (args[0] === "repo") {
|
||||
const fork = args[2] === "me/repo" || args[2] === "github.acme.internal/me/repo";
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
owner: { login: fork ? "me" : "acme" },
|
||||
name: "repo",
|
||||
parent: fork ? { owner: { login: "acme" }, name: "repo" } : null,
|
||||
}),
|
||||
stderr: "",
|
||||
};
|
||||
}
|
||||
const inUpstream = args[args.indexOf("--repo") + 1] === "github.acme.internal/acme/repo";
|
||||
if (args[1] === "view") {
|
||||
if (inUpstream) return { stdout: JSON.stringify(pullRequest), stderr: "" };
|
||||
throw Object.assign(new Error("gh failed"), {
|
||||
stderr: `no pull requests found for branch "${branch}"`,
|
||||
});
|
||||
}
|
||||
return { stdout: JSON.stringify(inUpstream ? [pullRequest] : []), stderr: "" };
|
||||
};
|
||||
const service = createGitHubService({
|
||||
ttlMs: 0,
|
||||
runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
resolveRepoHost: async () => "github.acme.internal",
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getCurrentPullRequestStatus({ cwd, headRef: branch }),
|
||||
).resolves.toMatchObject({ number: 42, repoOwner: "acme" });
|
||||
service.dispose?.();
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["feature", "123"])(
|
||||
"prefers the fork PR carrying the checkout's commit for branch %s on an Enterprise fork clone",
|
||||
async (branch) => {
|
||||
vi.useRealTimers();
|
||||
const cwd = mkdtempSync(join(tmpdir(), "github-enterprise-two-prs-"));
|
||||
try {
|
||||
execFileSync("git", ["init", "-q", cwd]);
|
||||
execFileSync("git", [
|
||||
"-C",
|
||||
cwd,
|
||||
"remote",
|
||||
"add",
|
||||
"origin",
|
||||
"https://github.acme.internal/me/repo.git",
|
||||
]);
|
||||
execFileSync("git", [
|
||||
"-C",
|
||||
cwd,
|
||||
"remote",
|
||||
"add",
|
||||
"upstream",
|
||||
"https://github.acme.internal/acme/repo.git",
|
||||
]);
|
||||
const forkSha = "2222222222222222222222222222222222222222";
|
||||
const upstreamPullRequest = JSON.parse(
|
||||
currentPullRequestJson({
|
||||
url: "https://github.acme.internal/acme/repo/pull/42",
|
||||
headRefName: branch,
|
||||
headRepositoryOwner: { login: "acme" },
|
||||
}),
|
||||
);
|
||||
const forkPullRequest = JSON.parse(
|
||||
currentPullRequestJson({
|
||||
number: 7,
|
||||
url: "https://github.acme.internal/acme/repo/pull/7",
|
||||
headRefName: branch,
|
||||
headRefOid: forkSha,
|
||||
headRepositoryOwner: { login: "me" },
|
||||
}),
|
||||
);
|
||||
const runner: GitHubCommandRunner = async (args) => {
|
||||
if (args[0] === "api") {
|
||||
if (args[1] === "graphql") {
|
||||
return { stdout: currentPullRequestGithubFactsJson(), stderr: "" };
|
||||
}
|
||||
const ownPulls =
|
||||
args[1]?.startsWith("repos/acme/repo/pulls?") &&
|
||||
args[1].includes(`head=me%3A${branch}`);
|
||||
return {
|
||||
stdout: JSON.stringify(ownPulls ? [{ number: 7, head: { sha: forkSha } }] : []),
|
||||
stderr: "",
|
||||
};
|
||||
}
|
||||
if (args[0] === "repo") {
|
||||
const fork = args[2] === "me/repo" || args[2] === "github.acme.internal/me/repo";
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
owner: { login: fork ? "me" : "acme" },
|
||||
name: "repo",
|
||||
parent: fork ? { owner: { login: "acme" }, name: "repo" } : null,
|
||||
}),
|
||||
stderr: "",
|
||||
};
|
||||
}
|
||||
if (args[1] === "view") {
|
||||
const pullRequest = args[2] === "7" ? forkPullRequest : upstreamPullRequest;
|
||||
return { stdout: JSON.stringify(pullRequest), stderr: "" };
|
||||
}
|
||||
return { stdout: JSON.stringify([upstreamPullRequest]), stderr: "" };
|
||||
};
|
||||
const service = createGitHubService({
|
||||
ttlMs: 0,
|
||||
runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
resolveRepoHost: async () => "github.acme.internal",
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getCurrentPullRequestStatus({ cwd, headRef: branch, headSha: forkSha }),
|
||||
).resolves.toMatchObject({ number: 7, repoOwner: "acme" });
|
||||
service.dispose?.();
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["feature", "123"])(
|
||||
"finds the user's own fork PR for branch %s on an Enterprise fork clone",
|
||||
async (branch) => {
|
||||
vi.useRealTimers();
|
||||
const cwd = mkdtempSync(join(tmpdir(), "github-enterprise-own-fork-pr-"));
|
||||
try {
|
||||
execFileSync("git", ["init", "-q", cwd]);
|
||||
execFileSync("git", [
|
||||
"-C",
|
||||
cwd,
|
||||
"remote",
|
||||
"add",
|
||||
"origin",
|
||||
"https://github.acme.internal/me/repo.git",
|
||||
]);
|
||||
execFileSync("git", [
|
||||
"-C",
|
||||
cwd,
|
||||
"remote",
|
||||
"add",
|
||||
"upstream",
|
||||
"https://github.acme.internal/acme/repo.git",
|
||||
]);
|
||||
const pullRequest = JSON.parse(
|
||||
currentPullRequestJson({
|
||||
number: 7,
|
||||
url: "https://github.acme.internal/acme/repo/pull/7",
|
||||
headRefName: branch,
|
||||
headRepositoryOwner: { login: "me" },
|
||||
}),
|
||||
);
|
||||
const runner: GitHubCommandRunner = async (args) => {
|
||||
if (args[0] === "api") {
|
||||
if (args[1] === "graphql") {
|
||||
return { stdout: currentPullRequestGithubFactsJson(), stderr: "" };
|
||||
}
|
||||
const ownPulls =
|
||||
args[1]?.startsWith("repos/acme/repo/pulls?") &&
|
||||
args[1].includes(`head=me%3A${branch}`);
|
||||
return { stdout: JSON.stringify(ownPulls ? [{ number: 7 }] : []), stderr: "" };
|
||||
}
|
||||
if (args[0] === "repo") {
|
||||
const fork = args[2] === "me/repo" || args[2] === "github.acme.internal/me/repo";
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
owner: { login: fork ? "me" : "acme" },
|
||||
name: "repo",
|
||||
parent: fork ? { owner: { login: "acme" }, name: "repo" } : null,
|
||||
}),
|
||||
stderr: "",
|
||||
};
|
||||
}
|
||||
if (args[1] === "view" && args[2] === "7") {
|
||||
return { stdout: JSON.stringify(pullRequest), stderr: "" };
|
||||
}
|
||||
if (args[1] === "view") {
|
||||
throw Object.assign(new Error("gh failed"), {
|
||||
stderr: `no pull requests found for branch "${branch}"`,
|
||||
});
|
||||
}
|
||||
return { stdout: "[]", stderr: "" };
|
||||
};
|
||||
const service = createGitHubService({
|
||||
ttlMs: 0,
|
||||
runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
resolveRepoHost: async () => "github.acme.internal",
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getCurrentPullRequestStatus({ cwd, headRef: branch }),
|
||||
).resolves.toMatchObject({ number: 7, repoOwner: "acme" });
|
||||
service.dispose?.();
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("uses the selected Enterprise default repository for PR and issue lookups despite Git rewrites", async () => {
|
||||
vi.useRealTimers();
|
||||
const cwd = mkdtempSync(join(tmpdir(), "github-service-default-repo-"));
|
||||
try {
|
||||
execFileSync("git", ["init", "-q", cwd]);
|
||||
execFileSync("git", [
|
||||
"-C",
|
||||
cwd,
|
||||
"remote",
|
||||
"add",
|
||||
"origin",
|
||||
"https://github.acme.internal/fork/repo.git",
|
||||
]);
|
||||
execFileSync("git", [
|
||||
"-C",
|
||||
cwd,
|
||||
"remote",
|
||||
"add",
|
||||
"github",
|
||||
"https://github.acme.internal/acme/repo.git",
|
||||
]);
|
||||
execFileSync("git", [
|
||||
"-C",
|
||||
cwd,
|
||||
"config",
|
||||
"url.https://git.acme.internal/mirrors/.insteadOf",
|
||||
"https://github.acme.internal/",
|
||||
]);
|
||||
execFileSync("git", ["-C", cwd, "config", "remote.github.gh-resolved", "base"]);
|
||||
const calls: RunnerCall[] = [];
|
||||
const runner: GitHubCommandRunner = async (args, options) => {
|
||||
calls.push({ args, cwd: options.cwd, envOverlay: options.envOverlay });
|
||||
if (args[0] === "repo") {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
owner: { login: args.includes("github.acme.internal/acme/repo") ? "acme" : "fork" },
|
||||
name: "repo",
|
||||
}),
|
||||
stderr: "",
|
||||
};
|
||||
}
|
||||
if (args[0] === "api") {
|
||||
return { stdout: pullRequestCheckoutTargetJson(), stderr: "" };
|
||||
}
|
||||
const repo = args[args.indexOf("--repo") + 1];
|
||||
const title = repo === "github.acme.internal/acme/repo" ? "upstream" : "fork";
|
||||
const list = args[0] === "issue" ? issueJson(title) : pullRequestJson(title);
|
||||
return {
|
||||
stdout: args[1] === "view" ? JSON.stringify(JSON.parse(list)[0]) : list,
|
||||
stderr: "",
|
||||
};
|
||||
};
|
||||
const service = createGitHubService({
|
||||
ttlMs: 0,
|
||||
runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
resolveRepoHost: async () => "github.acme.internal",
|
||||
});
|
||||
expect(await service.listPullRequests({ cwd })).toEqual([
|
||||
expect.objectContaining({ title: "upstream" }),
|
||||
]);
|
||||
expect(await service.listIssues({ cwd })).toEqual([
|
||||
expect.objectContaining({ title: "upstream" }),
|
||||
]);
|
||||
expect(await service.getPullRequest({ cwd, number: 123 })).toEqual(
|
||||
expect.objectContaining({ title: "upstream" }),
|
||||
);
|
||||
expect(calls.map((call) => call.envOverlay?.GH_HOST)).toEqual([
|
||||
"github.acme.internal",
|
||||
"github.acme.internal",
|
||||
"github.acme.internal",
|
||||
]);
|
||||
expect(
|
||||
(await service.getPullRequestCheckoutTarget({ cwd, number: 526 })).checkoutRefs,
|
||||
).toEqual([{ remoteName: "github", remoteRef: "refs/pull/526/head" }]);
|
||||
expect(calls[3]?.args).toContain("github.acme.internal/acme/repo");
|
||||
expect(calls[4]?.args).toContain("owner=acme");
|
||||
execFileSync("git", ["-C", cwd, "config", "--unset", "remote.github.gh-resolved"]);
|
||||
expect(await service.listPullRequests({ cwd, query: "implicit" })).toEqual([
|
||||
expect.objectContaining({ title: "upstream" }),
|
||||
]);
|
||||
execFileSync("git", ["-C", cwd, "config", "remote.origin.gh-resolved", "acme/repo"]);
|
||||
expect(await service.listPullRequests({ cwd, query: "parent" })).toEqual([
|
||||
expect.objectContaining({ title: "upstream" }),
|
||||
]);
|
||||
execFileSync("git", ["-C", cwd, "config", "remote.origin.gh-resolved", "base"]);
|
||||
expect(await service.listPullRequests({ cwd, query: "changed" })).toEqual([
|
||||
expect.objectContaining({ title: "fork" }),
|
||||
]);
|
||||
execFileSync("git", ["-C", cwd, "config", "remote.origin.gh-resolved", "missing/repo"]);
|
||||
const missingRepoRunner: GitHubCommandRunner = async (args) => ({
|
||||
stdout:
|
||||
args[0] === "api"
|
||||
? pullRequestCheckoutTargetJson()
|
||||
: JSON.stringify({ owner: { login: "missing" }, name: "repo" }),
|
||||
stderr: "",
|
||||
});
|
||||
const missingRepoService = createGitHubService({
|
||||
runner: missingRepoRunner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
resolveRepoHost: async () => "github.acme.internal",
|
||||
});
|
||||
expect(
|
||||
(await missingRepoService.getPullRequestCheckoutTarget({ cwd, number: 526 })).checkoutRefs,
|
||||
).toEqual([
|
||||
{ remoteName: "origin", remoteRef: "refs/pull/526/head" },
|
||||
{ remoteName: "upstream", remoteRef: "refs/pull/526/head" },
|
||||
]);
|
||||
missingRepoService.dispose?.();
|
||||
service.dispose?.();
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("fetches the selected repository's PR when origin has the same PR number", async () => {
|
||||
vi.useRealTimers();
|
||||
const root = mkdtempSync(join(tmpdir(), "github-checkout-duplicate-pr-"));
|
||||
const cwd = join(root, "workspace");
|
||||
const origin = join(root, "origin.git");
|
||||
const base = join(root, "base.git");
|
||||
try {
|
||||
execFileSync("git", ["init", "-q", cwd]);
|
||||
execFileSync("git", ["init", "-q", "--bare", origin]);
|
||||
execFileSync("git", ["init", "-q", "--bare", base]);
|
||||
const commit = (message: string) => {
|
||||
execFileSync("git", [
|
||||
"-C",
|
||||
cwd,
|
||||
"-c",
|
||||
"user.name=Test",
|
||||
"-c",
|
||||
"user.email=test@example.com",
|
||||
"commit",
|
||||
"--allow-empty",
|
||||
"-qm",
|
||||
message,
|
||||
]);
|
||||
return execFileSync("git", ["-C", cwd, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
|
||||
};
|
||||
const forkSha = commit("fork PR");
|
||||
const baseSha = commit("base PR");
|
||||
execFileSync("git", ["-C", cwd, "push", "-q", origin, `${forkSha}:refs/pull/526/head`]);
|
||||
execFileSync("git", ["-C", cwd, "push", "-q", base, `${baseSha}:refs/pull/526/head`]);
|
||||
execFileSync("git", [
|
||||
"-C",
|
||||
cwd,
|
||||
"remote",
|
||||
"add",
|
||||
"origin",
|
||||
"https://github.acme.internal/fork/repo.git",
|
||||
]);
|
||||
execFileSync("git", [
|
||||
"-C",
|
||||
cwd,
|
||||
"remote",
|
||||
"add",
|
||||
"github",
|
||||
"https://github.acme.internal/acme/repo.git",
|
||||
]);
|
||||
execFileSync("git", ["-C", cwd, "config", "remote.github.gh-resolved", "base"]);
|
||||
execFileSync("git", [
|
||||
"-C",
|
||||
cwd,
|
||||
"config",
|
||||
`url.file://${origin}.insteadOf`,
|
||||
"https://github.acme.internal/fork/repo.git",
|
||||
]);
|
||||
execFileSync("git", [
|
||||
"-C",
|
||||
cwd,
|
||||
"config",
|
||||
`url.file://${base}.insteadOf`,
|
||||
"https://github.acme.internal/acme/repo.git",
|
||||
]);
|
||||
const runner = createRunner([
|
||||
JSON.stringify({ owner: { login: "acme" }, name: "repo" }),
|
||||
pullRequestCheckoutTargetJson(),
|
||||
]);
|
||||
const service = createGitHubService({
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
resolveRepoHost: async () => "github.acme.internal",
|
||||
});
|
||||
const target = await service.getPullRequestCheckoutTarget({ cwd, number: 526 });
|
||||
expect(target.checkoutRefs).toEqual([
|
||||
{ remoteName: "github", remoteRef: "refs/pull/526/head" },
|
||||
]);
|
||||
const ref = target.checkoutRefs?.[0];
|
||||
if (!ref?.remoteName) throw new Error("Missing checkout remote");
|
||||
execFileSync("git", ["-C", cwd, "fetch", "-q", ref.remoteName, ref.remoteRef]);
|
||||
expect(
|
||||
execFileSync("git", ["-C", cwd, "rev-parse", "FETCH_HEAD"], { encoding: "utf8" }).trim(),
|
||||
).toBe(baseSha);
|
||||
expect(baseSha).not.toBe(forkSha);
|
||||
service.dispose?.();
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("selects the original Enterprise repository for PR lookup when Git rewrites remotes", async () => {
|
||||
const cwd = createEnterpriseOriginRepo();
|
||||
try {
|
||||
execFileSync("git", [
|
||||
"-C",
|
||||
cwd,
|
||||
"config",
|
||||
"url.https://proxy.acme.internal/.insteadOf",
|
||||
"https://github.acme.internal/",
|
||||
]);
|
||||
const runner = createRunner([currentPullRequestJson()]);
|
||||
const service = createGitHubService({
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
resolveRepoHost: async () => "github.acme.internal",
|
||||
});
|
||||
|
||||
await service.getCurrentPullRequestStatus({ cwd, headRef: "feature/fork" });
|
||||
|
||||
expect(runner.calls[0]).toMatchObject({
|
||||
args: [
|
||||
"pr",
|
||||
"view",
|
||||
"feature/fork",
|
||||
"--json",
|
||||
CURRENT_PR_STATUS_FIELDS,
|
||||
"--repo",
|
||||
"github.acme.internal/acme/repo",
|
||||
],
|
||||
envOverlay: { GH_HOST: "github.acme.internal" },
|
||||
});
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("finds an Enterprise PR for a numeric branch without treating it as a PR number", async () => {
|
||||
const cwd = createEnterpriseOriginRepo();
|
||||
try {
|
||||
const runner = createRunner([
|
||||
JSON.stringify([
|
||||
JSON.parse(
|
||||
currentPullRequestJson({
|
||||
number: 42,
|
||||
url: "https://github.acme.internal/acme/repo/pull/42",
|
||||
headRefName: "123",
|
||||
headRepositoryOwner: { login: "acme" },
|
||||
}),
|
||||
),
|
||||
]),
|
||||
currentPullRequestGithubFactsJson(),
|
||||
]);
|
||||
const service = createGitHubService({
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
resolveRepoHost: async () => "github.acme.internal",
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getCurrentPullRequestStatus({
|
||||
cwd,
|
||||
headRef: "123",
|
||||
headRepositoryOwner: "acme",
|
||||
}),
|
||||
).resolves.toMatchObject({ number: 42, headRefName: "123", repoOwner: "acme" });
|
||||
expect(runner.calls[0]).toMatchObject({
|
||||
args: [
|
||||
"pr",
|
||||
"list",
|
||||
"--state",
|
||||
"all",
|
||||
"--head",
|
||||
"123",
|
||||
"--limit",
|
||||
"10",
|
||||
"--json",
|
||||
CURRENT_PR_STATUS_FIELDS,
|
||||
"--repo",
|
||||
"github.acme.internal/acme/repo",
|
||||
],
|
||||
envOverlay: { GH_HOST: "github.acme.internal" },
|
||||
});
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns no Enterprise PR for a numeric branch with no matches", async () => {
|
||||
const cwd = createEnterpriseOriginRepo();
|
||||
try {
|
||||
const runner = createRunner(["[]"]);
|
||||
const service = createGitHubService({
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
resolveRepoHost: async () => "github.acme.internal",
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getCurrentPullRequestStatus({
|
||||
cwd,
|
||||
headRef: "123",
|
||||
headRepositoryOwner: "acme",
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
expect(runner.calls.map((call) => call.args.slice(0, 2))).toEqual([["pr", "list"]]);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("finds an Enterprise fork PR for a numeric branch in its parent repository", async () => {
|
||||
const runner = createRunner([
|
||||
"[]",
|
||||
JSON.stringify({
|
||||
owner: { login: "forkOwner" },
|
||||
name: "repo",
|
||||
parent: { owner: { login: "parentOwner" }, name: "repo" },
|
||||
}),
|
||||
JSON.stringify([{ number: 42 }]),
|
||||
currentPullRequestJson({
|
||||
number: 42,
|
||||
url: "https://github.acme.internal/parentOwner/repo/pull/42",
|
||||
headRefName: "123",
|
||||
headRepositoryOwner: { login: "forkOwner" },
|
||||
}),
|
||||
currentPullRequestGithubFactsJson(),
|
||||
]);
|
||||
const service = createGitHubService({
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
resolveRepoHost: async () => "github.acme.internal",
|
||||
resolveRepoSlug: async () => "forkOwner/repo",
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "123" }),
|
||||
).resolves.toMatchObject({ number: 42, headRefName: "123", repoOwner: "parentOwner" });
|
||||
expect(runner.calls[2]?.args).toEqual([
|
||||
"api",
|
||||
"repos/parentOwner/repo/pulls?state=all&per_page=100&head=forkOwner%3A123",
|
||||
]);
|
||||
expect(runner.calls[3]).toMatchObject({
|
||||
args: ["pr", "view", "42", "--repo", "parentOwner/repo", "--json", CURRENT_PR_STATUS_FIELDS],
|
||||
envOverlay: { GH_HOST: "github.acme.internal" },
|
||||
});
|
||||
});
|
||||
|
||||
it("merges an Enterprise fork PR in its base repository", async () => {
|
||||
const runner = createRunner([""]);
|
||||
const service = createGitHubService({
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
resolveRepoHost: async () => "github.acme.internal",
|
||||
resolveRepoSlug: async () => "fork/repo",
|
||||
});
|
||||
|
||||
await service.mergePullRequest({
|
||||
cwd: "/repo",
|
||||
prNumber: 42,
|
||||
mergeMethod: "squash",
|
||||
status: createCurrentPullRequestStatus({
|
||||
url: "https://github.acme.internal/upstream/repo/pull/42",
|
||||
forgeSpecific: githubStatusFacts(),
|
||||
}),
|
||||
});
|
||||
expect(runner.calls[0]?.args).toEqual([
|
||||
"pr",
|
||||
"merge",
|
||||
"42",
|
||||
"--squash",
|
||||
"--repo",
|
||||
"github.acme.internal/upstream/repo",
|
||||
]);
|
||||
});
|
||||
|
||||
it("merges an Enterprise PR whose URL names another hostname than the remote", async () => {
|
||||
const runner = createRunner([""]);
|
||||
const service = createGitHubService({
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
resolveRepoHost: async () => "10.0.0.5",
|
||||
resolveRepoSlug: async () => "upstream/repo",
|
||||
});
|
||||
|
||||
await service.mergePullRequest({
|
||||
cwd: "/repo",
|
||||
prNumber: 42,
|
||||
mergeMethod: "squash",
|
||||
status: createCurrentPullRequestStatus({
|
||||
url: "https://github.acme.internal/upstream/repo/pull/42",
|
||||
forgeSpecific: githubStatusFacts(),
|
||||
}),
|
||||
});
|
||||
expect(runner.calls[0]?.args).toEqual([
|
||||
"pr",
|
||||
"merge",
|
||||
"42",
|
||||
"--squash",
|
||||
"--repo",
|
||||
"10.0.0.5/upstream/repo",
|
||||
]);
|
||||
});
|
||||
|
||||
it.skipIf(isPlatform("win32"))(
|
||||
"authenticates and creates a PR on the configured Enterprise host when Git rewrites origin",
|
||||
async () => {
|
||||
vi.useRealTimers();
|
||||
const fixture = createFakeGitHubCliFixture({
|
||||
remoteUrl: "https://github.acme.internal/acme/repo.git",
|
||||
authStatusSucceeds: true,
|
||||
});
|
||||
try {
|
||||
execFileSync("git", [
|
||||
"-C",
|
||||
fixture.cwd,
|
||||
"config",
|
||||
"url.https://git.acme.internal/mirrors/.insteadOf",
|
||||
"https://github.acme.internal/",
|
||||
]);
|
||||
expect(
|
||||
execFileSync("git", ["-C", fixture.cwd, "remote", "get-url", "origin"], {
|
||||
encoding: "utf8",
|
||||
}).trim(),
|
||||
).toBe("https://git.acme.internal/mirrors/acme/repo.git");
|
||||
const service = createGitHubService();
|
||||
|
||||
await expect(service.isAuthenticated({ cwd: fixture.cwd })).resolves.toBe(true);
|
||||
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|",
|
||||
"auth status --hostname github.acme.internal|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"))(
|
||||
"routes default gh calls to GitHub Enterprise only after host auth succeeds",
|
||||
async () => {
|
||||
@@ -4824,7 +5708,6 @@ describe("ForgeService", () => {
|
||||
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",
|
||||
]),
|
||||
);
|
||||
@@ -4869,7 +5752,6 @@ describe("ForgeService", () => {
|
||||
).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",
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -1331,20 +1331,24 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
||||
if (!ghPath) {
|
||||
throw new GitHubCliMissingError();
|
||||
}
|
||||
// Route every gh invocation to the workspace's host. `gh api`/`graphql`
|
||||
// otherwise default to github.com regardless of the resolved repository,
|
||||
// which silently queries the wrong server on GitHub Enterprise. GH_HOST is
|
||||
// safe even for auto-routing subcommands because it matches the repo's host.
|
||||
// Git may rewrite the transport URL to a proxy host. gh would then infer the
|
||||
// wrong host and repository, so name the repository gh would select from
|
||||
// the configured remote URLs instead.
|
||||
const host = await resolveRepoHostCached(runOptions.cwd);
|
||||
const slug =
|
||||
host && ["pr", "issue", "repo"].includes(args[0] ?? "")
|
||||
? await resolveGitHubDefaultRepoSlug({ cwd: runOptions.cwd, host })
|
||||
: null;
|
||||
const effectiveArgs = selectGitHubCommandRepository({ args, host, slug });
|
||||
const effectiveOptions: GitHubCommandRunnerOptions = host
|
||||
? { ...runOptions, envOverlay: { ...runOptions.envOverlay, GH_HOST: host } }
|
||||
: runOptions;
|
||||
try {
|
||||
const result = await deps.runner(args, effectiveOptions);
|
||||
const result = await deps.runner(effectiveArgs, effectiveOptions);
|
||||
return result.stdout.trim();
|
||||
} catch (error) {
|
||||
throw githubCliRunner.normalizeError(error, {
|
||||
args,
|
||||
args: effectiveArgs,
|
||||
cwd: runOptions.cwd,
|
||||
});
|
||||
}
|
||||
@@ -1364,6 +1368,43 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
||||
});
|
||||
}
|
||||
|
||||
// gh names the repository from the cwd when Paseo does not. On Enterprise
|
||||
// Paseo names it, so parent discovery must ask about origin itself: a branch
|
||||
// that tracks origin carries no head owner, and its PR lives in origin's
|
||||
// parent under origin's owner.
|
||||
async function resolveCurrentPullRequestRepositories(
|
||||
cwd: string,
|
||||
): Promise<{ explicitRepo: boolean; originRepository?: string }> {
|
||||
if ((await resolveRepoHostCached(cwd)) === null) {
|
||||
return { explicitRepo: false };
|
||||
}
|
||||
const originRepository = await deps.resolveOriginRepository(cwd);
|
||||
return { explicitRepo: true, ...(originRepository ? { originRepository } : {}) };
|
||||
}
|
||||
|
||||
async function runPullRequestMerge({
|
||||
args,
|
||||
input,
|
||||
}: {
|
||||
args: string[];
|
||||
input: { cwd: string; prNumber: number; status?: PullRequestCommandStatus | null };
|
||||
}): Promise<void> {
|
||||
const host = await resolveRepoHostCached(input.cwd);
|
||||
let effectiveArgs = args;
|
||||
if (host) {
|
||||
// The PR URL carries the instance's web hostname, which can differ from the
|
||||
// name the remote and gh use (an IP, a DNS alias, an SSH HostName), so only
|
||||
// owner and name come from the URL.
|
||||
const url = input.status?.url;
|
||||
const repo = url ? parseGitHubPullRequestRepo(url) : null;
|
||||
if (!url || !repo || !url.endsWith(`/pull/${input.prNumber}`)) {
|
||||
throw new Error("Cannot identify the GitHub repository for this pull request");
|
||||
}
|
||||
effectiveArgs = [...args, "--repo", `${host}/${repo.owner}/${repo.name}`];
|
||||
}
|
||||
await run(effectiveArgs, { cwd: input.cwd, envOverlay: { GH_PROMPT_DISABLED: "1" } });
|
||||
}
|
||||
|
||||
function getPollTargetKey(target: {
|
||||
cwd: string;
|
||||
headRef: string;
|
||||
@@ -1762,7 +1803,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
||||
}
|
||||
const frozenKey =
|
||||
node.state !== "OPEN" && node.headRefOid.length > 0
|
||||
? `${entry.owner}/${entry.name}#${node.number}@${node.headRefOid}`
|
||||
? `${entry.host}/${entry.owner}/${entry.name}#${node.number}@${node.headRefOid}`
|
||||
: null;
|
||||
const cachedRollup = frozenKey ? frozenPullRequestChecksCache.get(frozenKey) : undefined;
|
||||
if (frozenKey && cachedRollup) {
|
||||
@@ -2142,6 +2183,12 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
||||
if (!owner || !name) {
|
||||
throw new Error("Unable to resolve GitHub repository for pull request checkout");
|
||||
}
|
||||
const host = (await resolveRepoHostCached(input.cwd)) ?? "github.com";
|
||||
const remoteNames = await resolveGitHubCheckoutRemotes({
|
||||
cwd: input.cwd,
|
||||
host,
|
||||
repository: `${owner}/${name}`,
|
||||
});
|
||||
|
||||
const parsed = await runGhJson(
|
||||
[
|
||||
@@ -2160,7 +2207,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
||||
PullRequestCheckoutTargetSchema,
|
||||
"{}",
|
||||
);
|
||||
return toPullRequestCheckoutTarget(parsed);
|
||||
return toPullRequestCheckoutTarget({ parsed, remoteNames });
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -2181,6 +2228,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
||||
headRef: input.headRef,
|
||||
headSha: input.headSha,
|
||||
headRepositoryOwner: input.headRepositoryOwner,
|
||||
...(await resolveCurrentPullRequestRepositories(input.cwd)),
|
||||
run,
|
||||
});
|
||||
return addCurrentPullRequestGithubFacts({ cwd: input.cwd, status, run });
|
||||
@@ -2526,27 +2574,27 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
||||
|
||||
async mergePullRequest(input) {
|
||||
assertDirectPullRequestMergeReady(input);
|
||||
await run(["pr", "merge", String(input.prNumber), `--${input.mergeMethod}`], {
|
||||
cwd: input.cwd,
|
||||
envOverlay: { GH_PROMPT_DISABLED: "1" },
|
||||
await runPullRequestMerge({
|
||||
args: ["pr", "merge", String(input.prNumber), `--${input.mergeMethod}`],
|
||||
input,
|
||||
});
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
async enablePullRequestAutoMerge(input) {
|
||||
assertPullRequestAutoMergeEnableReady(input);
|
||||
await run(["pr", "merge", String(input.prNumber), "--auto", `--${input.mergeMethod}`], {
|
||||
cwd: input.cwd,
|
||||
envOverlay: { GH_PROMPT_DISABLED: "1" },
|
||||
await runPullRequestMerge({
|
||||
args: ["pr", "merge", String(input.prNumber), "--auto", `--${input.mergeMethod}`],
|
||||
input,
|
||||
});
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
async disablePullRequestAutoMerge(input) {
|
||||
assertPullRequestAutoMergeDisableReady(input);
|
||||
await run(["pr", "merge", String(input.prNumber), "--disable-auto"], {
|
||||
cwd: input.cwd,
|
||||
envOverlay: { GH_PROMPT_DISABLED: "1" },
|
||||
await runPullRequestMerge({
|
||||
args: ["pr", "merge", String(input.prNumber), "--disable-auto"],
|
||||
input,
|
||||
});
|
||||
return { success: true };
|
||||
},
|
||||
@@ -2693,6 +2741,33 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
||||
return api;
|
||||
}
|
||||
|
||||
function selectGitHubCommandRepository({
|
||||
args,
|
||||
host,
|
||||
slug,
|
||||
}: {
|
||||
args: string[];
|
||||
host: string | null;
|
||||
slug: string | null;
|
||||
}): string[] {
|
||||
if (args[0] === "auth" && args[1] === "status" && !args.includes("--hostname")) {
|
||||
return [...args, "--hostname", host ?? "github.com"];
|
||||
}
|
||||
if (!host || !slug) return args;
|
||||
if (args[0] === "repo" && args[1] === "view" && args[2]?.startsWith("-")) {
|
||||
return [args[0], args[1], `${host}/${slug}`, ...args.slice(2)];
|
||||
}
|
||||
if (
|
||||
(args[0] === "pr" || args[0] === "issue") &&
|
||||
args[1] !== "merge" &&
|
||||
!args.includes("--repo") &&
|
||||
!args.includes("-R")
|
||||
) {
|
||||
return [...args, "--repo", `${host}/${slug}`];
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function getGithubStatusFacts(
|
||||
status: PullRequestCommandStatus | null | undefined,
|
||||
): GitHubPullRequestStatusFacts | null {
|
||||
@@ -2984,30 +3059,97 @@ function isGitHubRateLimitError(error: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
async function tryCurrentPullRequestBranchView(options: {
|
||||
cwd: string;
|
||||
explicitRepo?: boolean;
|
||||
headRef: string;
|
||||
headRepositoryOwner?: string;
|
||||
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
|
||||
}): Promise<ResolvedPullRequestCandidate | null> {
|
||||
// gh interprets a numeric positional argument as a PR number, not a branch,
|
||||
// so list the branch's PRs and keep what `gh pr view <branch>` would return:
|
||||
// a PR whose head branch lives in the repository it targets.
|
||||
if (options.explicitRepo && /^\d+$/u.test(options.headRef)) {
|
||||
// A known head owner is matched by the branch listing that follows.
|
||||
if (options.headRepositoryOwner) return null;
|
||||
const candidates = await listCurrentPullRequestCandidates(options);
|
||||
return pickPullRequestCandidate({
|
||||
candidates: candidates.filter(isSameRepositoryPullRequestCandidate),
|
||||
headRef: options.headRef,
|
||||
});
|
||||
}
|
||||
return tryCurrentPullRequestView({
|
||||
...options,
|
||||
args: options.explicitRepo ? ["pr", "view", options.headRef] : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function isSameRepositoryPullRequestCandidate(candidate: ResolvedPullRequestCandidate): boolean {
|
||||
const owner = candidate.headRepositoryOwner?.toLowerCase();
|
||||
return owner !== undefined && owner === candidate.status.repoOwner?.toLowerCase();
|
||||
}
|
||||
|
||||
async function findCurrentPullRequestViewMatch(options: {
|
||||
cwd: string;
|
||||
headRef: string;
|
||||
headSha?: string;
|
||||
headRepositoryOwner?: string;
|
||||
explicitRepo?: boolean;
|
||||
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
|
||||
}): Promise<ResolvedPullRequestCandidate | null> {
|
||||
const viewCandidate = await tryCurrentPullRequestBranchView(options);
|
||||
if (!viewCandidate) return null;
|
||||
return pickPullRequestCandidate({
|
||||
candidates: [viewCandidate],
|
||||
headRef: options.headRef,
|
||||
headSha: options.headSha,
|
||||
headRepositoryOwner: options.headRepositoryOwner,
|
||||
});
|
||||
}
|
||||
|
||||
// Bare `gh pr view` picks the PR from the branch's tracking config. Named
|
||||
// repositories lose that, so a same-repository PR whose head is not the
|
||||
// checkout's commit must not shadow the user's fork PR on the same branch.
|
||||
function isViewMatchForCheckout({
|
||||
viewMatch,
|
||||
checkout,
|
||||
}: {
|
||||
viewMatch: ResolvedPullRequestCandidate;
|
||||
checkout: { explicitRepo?: boolean; headRepositoryOwner?: string; headSha?: string };
|
||||
}): boolean {
|
||||
return (
|
||||
!checkout.explicitRepo ||
|
||||
checkout.headRepositoryOwner !== undefined ||
|
||||
checkout.headSha === undefined ||
|
||||
viewMatch.headSha === checkout.headSha
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveCurrentPullRequestView(options: {
|
||||
cwd: string;
|
||||
headRef: string;
|
||||
headSha?: string;
|
||||
headRepositoryOwner?: string;
|
||||
explicitRepo?: boolean;
|
||||
originRepository?: string;
|
||||
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
|
||||
}): Promise<CurrentPullRequestStatus | null> {
|
||||
const viewCandidate = await tryCurrentPullRequestView(options);
|
||||
const viewMatch = viewCandidate
|
||||
? pickPullRequestCandidate({
|
||||
candidates: [viewCandidate],
|
||||
headRef: options.headRef,
|
||||
headSha: options.headSha,
|
||||
headRepositoryOwner: options.headRepositoryOwner,
|
||||
})
|
||||
: null;
|
||||
if (viewMatch?.status.state === "open") {
|
||||
const viewMatch = await findCurrentPullRequestViewMatch(options);
|
||||
if (
|
||||
viewMatch?.status.state === "open" &&
|
||||
isViewMatchForCheckout({ viewMatch, checkout: options })
|
||||
) {
|
||||
return viewMatch.status;
|
||||
}
|
||||
|
||||
let headRepositoryOwner = options.headRepositoryOwner;
|
||||
|
||||
if (!headRepositoryOwner) {
|
||||
const repo = await getGitHubRepoView(options);
|
||||
const repo = await getGitHubRepoView({
|
||||
cwd: options.cwd,
|
||||
repo: options.originRepository,
|
||||
run: options.run,
|
||||
});
|
||||
const forkOwner = repo?.owner?.login;
|
||||
const parentOwner = repo?.parent?.owner?.login;
|
||||
const parentName = repo?.parent?.name;
|
||||
@@ -3238,6 +3380,109 @@ async function runCurrentPullRequestStatusCommand(options: {
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveGitHubCheckoutRemotes({
|
||||
cwd,
|
||||
host,
|
||||
repository,
|
||||
}: {
|
||||
cwd: string;
|
||||
host: string;
|
||||
repository: string;
|
||||
}): Promise<string[]> {
|
||||
const { stdout } = await runGitCommand(
|
||||
["config", "--null", "--get-regexp", "^remote\\..*\\.url$"],
|
||||
{
|
||||
cwd,
|
||||
env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
|
||||
timeout: GIT_ORIGIN_URL_READ_TIMEOUT_MS,
|
||||
acceptExitCodes: [0, 1],
|
||||
},
|
||||
);
|
||||
const matches: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const entry of stdout.split("\0")) {
|
||||
const separator = entry.indexOf("\n");
|
||||
if (separator < 0) continue;
|
||||
const name = /^remote\.(.+)\.url$/u.exec(entry.slice(0, separator))?.[1];
|
||||
if (!name || seen.has(name)) continue;
|
||||
// Git fetch uses the first configured URL for a remote.
|
||||
seen.add(name);
|
||||
const location = parseGitRemoteLocation(entry.slice(separator + 1));
|
||||
if (!location) continue;
|
||||
const remoteHost =
|
||||
location.transport === "scp" || location.transport === "ssh"
|
||||
? ((await resolveSshHostname(location.host)) ?? location.host)
|
||||
: location.host;
|
||||
const sameHost =
|
||||
remoteHost.toLowerCase() === host.toLowerCase() ||
|
||||
(isGitHubHost(remoteHost) && isGitHubHost(host));
|
||||
if (
|
||||
sameHost &&
|
||||
parseGitHubRemoteIdentity(location.path)?.repo.toLowerCase() === repository.toLowerCase()
|
||||
) {
|
||||
matches.push(name);
|
||||
}
|
||||
}
|
||||
// A clone made before a repository was renamed or transferred keeps the old
|
||||
// owner/name in its remote URL, and GitHub still serves pull refs from it.
|
||||
return matches.length > 0 ? matches : ["origin", "upstream"];
|
||||
}
|
||||
|
||||
async function resolveGitHubDefaultRepoSlug({
|
||||
cwd,
|
||||
host,
|
||||
}: {
|
||||
cwd: string;
|
||||
host: string;
|
||||
}): Promise<string | null> {
|
||||
// `gh repo set-default` stores either "base" or an owner/name in the remote's
|
||||
// gh-resolved config. Read the raw URLs because `git remote` applies rewrites.
|
||||
let stdout: string;
|
||||
try {
|
||||
({ stdout } = await runGitCommand(
|
||||
["config", "--null", "--get-regexp", "^remote\\..*\\.(gh-resolved|url)$"],
|
||||
{
|
||||
cwd,
|
||||
env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
|
||||
timeout: GIT_ORIGIN_URL_READ_TIMEOUT_MS,
|
||||
acceptExitCodes: [0, 1],
|
||||
},
|
||||
));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const config = new Map<string, string>();
|
||||
for (const entry of stdout.split("\0")) {
|
||||
const separator = entry.indexOf("\n");
|
||||
if (separator !== -1) config.set(entry.slice(0, separator), entry.slice(separator + 1));
|
||||
}
|
||||
const priorities: Record<string, number> = { upstream: 3, github: 2, origin: 1 };
|
||||
const remotes = [...config.keys()]
|
||||
.map((key) => /^remote\.(.+)\.url$/u.exec(key)?.[1])
|
||||
.filter((name): name is string => name !== undefined)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(priorities[b.toLowerCase()] ?? 0) - (priorities[a.toLowerCase()] ?? 0) ||
|
||||
a.localeCompare(b),
|
||||
);
|
||||
const candidates: Array<{ path: string; selected?: string }> = [];
|
||||
for (const name of remotes) {
|
||||
const location = parseGitRemoteLocation(config.get(`remote.${name}.url`) ?? "");
|
||||
if (!location) continue;
|
||||
const remoteHost =
|
||||
location.transport === "scp" || location.transport === "ssh"
|
||||
? ((await resolveSshHostname(location.host)) ?? location.host)
|
||||
: location.host;
|
||||
if (remoteHost.toLowerCase() !== host.toLowerCase()) continue;
|
||||
candidates.push({ path: location.path, selected: config.get(`remote.${name}.gh-resolved`) });
|
||||
}
|
||||
const candidate = candidates.find((remote) => remote.selected) ?? candidates[0];
|
||||
if (!candidate) return null;
|
||||
const path =
|
||||
!candidate.selected || candidate.selected === "base" ? candidate.path : candidate.selected;
|
||||
return parseGitHubRemoteIdentity(path)?.repo ?? null;
|
||||
}
|
||||
|
||||
async function resolveGitHubSlugFromOrigin(cwd: string): Promise<string | null> {
|
||||
let stdout: string;
|
||||
try {
|
||||
@@ -3569,9 +3814,13 @@ function normalizeRepositorySummary(repository: {
|
||||
};
|
||||
}
|
||||
|
||||
function toPullRequestCheckoutTarget(
|
||||
parsed: z.infer<typeof PullRequestCheckoutTargetSchema>,
|
||||
): PullRequestCheckoutTarget {
|
||||
function toPullRequestCheckoutTarget({
|
||||
parsed,
|
||||
remoteNames,
|
||||
}: {
|
||||
parsed: z.infer<typeof PullRequestCheckoutTargetSchema>;
|
||||
remoteNames: string[];
|
||||
}): PullRequestCheckoutTarget {
|
||||
const pullRequest = parsed.data.repository.pullRequest;
|
||||
if (!pullRequest) {
|
||||
throw new Error("Pull request not found");
|
||||
@@ -3580,10 +3829,10 @@ function toPullRequestCheckoutTarget(
|
||||
number: pullRequest.number,
|
||||
baseRefName: pullRequest.baseRefName,
|
||||
headRefName: pullRequest.headRefName,
|
||||
checkoutRefs: [
|
||||
{ remoteName: "origin", remoteRef: `refs/pull/${pullRequest.number}/head` },
|
||||
{ remoteName: "upstream", remoteRef: `refs/pull/${pullRequest.number}/head` },
|
||||
],
|
||||
checkoutRefs: remoteNames.map((remoteName) => ({
|
||||
remoteName,
|
||||
remoteRef: `refs/pull/${pullRequest.number}/head`,
|
||||
})),
|
||||
headOwnerLogin: pullRequest.headRepositoryOwner?.login || null,
|
||||
headRepositorySshUrl: pullRequest.headRepository?.sshUrl || null,
|
||||
headRepositoryUrl: pullRequest.headRepository?.url || null,
|
||||
|
||||
Reference in New Issue
Block a user