Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] a078dd9bc5 Merge remote-tracking branch 'origin/main' into fix/gitlab-fork-checkout-8159 2026-11-02 09:54:43 +00:00
paseo-bot[bot]andquickwind eecac800b6 fix(gitlab): identify fork checkouts by their source project
Merge request authors do not necessarily own the source fork. Resolve its namespace and repository URLs before naming the checkout and configuring its push remote, retaining the target MR ref when the source returns 404.

Co-authored-by: quickwind <wallacewei@live.cn>
2026-11-02 09:54:18 +00:00
quickwind ea3f2ccd4a fix(gitlab): resolve headOwnerLogin and local branch name for fork merge requests 2026-10-29 12:03:10 +11:00
3 changed files with 136 additions and 28 deletions
@@ -806,6 +806,25 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
test("checks out the authoritative GitLab MR ref before a same-named origin branch", async () => {
const { tempDir, repoDir, paseoHome } = createGitLabMrWithConflictingOriginBranchRepo();
cleanupPaths.push(tempDir);
const sourceRemote = path.join(tempDir, "fork.git");
execFileSync("git", ["clone", "--bare", path.join(tempDir, "origin.git"), sourceRemote], {
stdio: "pipe",
});
const mrHead = execFileSync(
"git",
[
`--git-dir=${path.join(tempDir, "origin.git")}`,
"rev-parse",
"refs/merge-requests/14/head",
],
{ encoding: "utf8" },
).trim();
execFileSync("git", [
`--git-dir=${sourceRemote}`,
"update-ref",
"refs/heads/feature/gitlab-mr",
mrHead,
]);
const gitlab = createGitLabService({
resolveGlabPath: async () => "/usr/bin/glab",
resolveRemoteUrl: async () => "git@gitlab.example.com:example-group/example-project.git",
@@ -820,12 +839,23 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
state: "opened",
source_branch: "feature/gitlab-mr",
target_branch: "main",
author: { username: "mr-author" },
source_project_id: 202,
target_project_id: 101,
}),
stderr: "",
};
}
if (args[0] === "api" && args[1] === "projects/202") {
return {
stdout: JSON.stringify({
namespace: { full_path: "team" },
ssh_url_to_repo: sourceRemote,
http_url_to_repo: "https://gitlab.example.com/team/fork.git",
}),
stderr: "",
};
}
throw new Error(`unexpected glab args: ${args.join(" ")}`);
},
});
@@ -843,11 +873,17 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
expect(result.intent).toMatchObject({
kind: "checkout-change-request",
forge: "gitlab",
headRepository: "team/fork",
localBranchName: "team/feature/gitlab-mr",
checkoutRefs: [
{ remoteName: "origin", remoteRef: "refs/merge-requests/14/head" },
{ remoteName: "origin", remoteRef: "refs/heads/feature/gitlab-mr" },
],
});
expect(result.worktree.branchName).toBe("team/feature/gitlab-mr");
expect(getGitConfigValue(result.worktree.worktreePath, "remote.paseo-pr-14.url")).toBe(
sourceRemote,
);
expect(readFileSync(path.join(result.worktree.worktreePath, "README.md"), "utf8")).toBe(
"authoritative merge request head\n",
);
@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest";
import { resolveWorktreeCreationIntent } from "../server/resolve-worktree-creation-intent.js";
import type { PullRequestCommandStatus } from "./forge-service.js";
import { GITLAB_ACTIVE_PIPELINE_STATUS_SET } from "@getpaseo/protocol/gitlab-pipeline";
import type { GitLabStatusFacts } from "./gitlab-facts.js";
@@ -463,9 +465,8 @@ describe("createGitLabService", () => {
it("maps a same-repo merge request view to a checkout target", async () => {
const { service, calls } = makeService(() => ok(JSON.stringify(OPEN_MR)));
await expect(
service.getPullRequestCheckoutTarget?.({ cwd: "/repo", number: 14 }),
).resolves.toEqual({
const target = await service.getPullRequestCheckoutTarget?.({ cwd: "/repo", number: 14 });
expect(target).toEqual({
number: 14,
baseRefName: "main",
headRefName: "release/v0.4.0",
@@ -479,30 +480,76 @@ describe("createGitLabService", () => {
isCrossRepository: false,
headRefKind: "branch",
});
expect(calls[0]).toEqual(["mr", "view", "14", "-F", "json"]);
expect(
service.buildPrLocalBranchName?.({
headRef: target!.headRefName,
checkoutTarget: target!,
}),
).toBe("release/v0.4.0");
expect(calls).toEqual([["mr", "view", "14", "-F", "json"]]);
});
it("marks fork merge request checkout targets as cross-repository", async () => {
const { service } = makeService(() =>
ok(
JSON.stringify({
...OPEN_MR,
source_project_id: 202,
target_project_id: 101,
}),
),
);
it.each(["contributor", "team"])(
"resolves the source project rather than the MR author for %s forks",
async (owner) => {
const { service, calls } = makeService((args) => {
if (args[0] === "api")
return ok(
JSON.stringify({
namespace: { full_path: owner },
ssh_url_to_repo: `git@gitlab.example.com:${owner}/fork.git`,
http_url_to_repo: `https://gitlab.example.com/${owner}/fork.git`,
}),
);
return ok(
JSON.stringify({ ...OPEN_MR, source_project_id: 202, author: { username: "mr-author" } }),
);
});
const intent = await resolveWorktreeCreationIntent(
{
action: "checkout",
checkoutSource: { kind: "change_request", forge: "gitlab", number: 14 },
},
"/repo",
{ forge: "gitlab", forgeService: service, resolveDefaultBranch: async () => "main" },
);
expect(intent).toMatchObject({
kind: "checkout-change-request",
headRepositoryOwner: owner,
headRepository: `${owner}/fork`,
pushRemoteUrl: `git@gitlab.example.com:${owner}/fork.git`,
localBranchName: `${owner}/release/v0.4.0`,
checkoutRefs: [
{ remoteName: "origin", remoteRef: "refs/merge-requests/14/head" },
{ remoteName: "origin", remoteRef: "refs/heads/release/v0.4.0" },
],
});
expect(calls).toEqual([
["mr", "view", "14", "-F", "json"],
["api", "projects/202"],
]);
},
);
await expect(
service.getPullRequestCheckoutTarget?.({ cwd: "/repo", number: 14 }),
).resolves.toMatchObject({
number: 14,
headRefName: "release/v0.4.0",
it("keeps the target MR ref usable when its source project is inaccessible", async () => {
const { service } = makeService((args) => {
if (args[0] === "api") throw { code: 1, stderr: "404 Project Not Found" };
return ok(JSON.stringify({ ...OPEN_MR, source_project_id: 202 }));
});
const target = await service.getPullRequestCheckoutTarget!({ cwd: "/repo", number: 14 });
expect(target).toMatchObject({
isCrossRepository: true,
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
checkoutRefs: [
{ remoteName: "origin", remoteRef: "refs/merge-requests/14/head" },
{ remoteName: "origin", remoteRef: "refs/heads/release/v0.4.0" },
],
});
expect(
service.buildPrLocalBranchName!({ headRef: target.headRefName, checkoutTarget: target }),
).toBe("pr-14/release/v0.4.0");
});
it("creates a merge request and parses the URL and iid from glab output", async () => {
+34 -9
View File
@@ -60,6 +60,7 @@ import {
} from "@getpaseo/protocol/check-traits";
import { GITLAB_ACTIVE_PIPELINE_STATUS_SET } from "@getpaseo/protocol/gitlab-pipeline";
import { isGitLabStatusFacts, type GitLabStatusFacts } from "./gitlab-facts.js";
import { buildForkLocalBranchName } from "../utils/change-request-checkout.js";
const GLAB_ENV = {
GIT_TERMINAL_PROMPT: "0",
@@ -144,6 +145,12 @@ const GitLabPipelineDetailsSchema = z
})
.passthrough();
const GitLabSourceProjectSchema = z.object({
namespace: z.object({ full_path: z.string() }),
ssh_url_to_repo: z.string(),
http_url_to_repo: z.string(),
});
const GitLabMergeRequestSchema = z
.object({
iid: z.number(),
@@ -949,6 +956,16 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F
return runJson(["mr", "view", ref, "-F", "json"], { cwd }, GitLabMergeRequestSchema);
}
async function loadSourceProject(cwd: string, projectId: number) {
try {
return await runJson(["api", `projects/${projectId}`], { cwd }, GitLabSourceProjectSchema);
} catch (error) {
// The target's MR ref remains fetchable even if the source project is private.
if (error instanceof GlabCommandError && /\b404\b/.test(error.stderr)) return null;
throw error;
}
}
async function listMergeRequestsBySourceBranch(
cwd: string,
sourceBranch: string,
@@ -1183,10 +1200,23 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F
return mr.source_branch;
},
buildPrLocalBranchName({ headRef, checkoutTarget }) {
return buildForkLocalBranchName({ headRef, ...checkoutTarget });
},
async getPullRequestCheckoutTarget(
input: GetPullRequestOptions,
): Promise<PullRequestCheckoutTarget> {
const mr = await viewMergeRequest(input.cwd, String(input.number));
const isCrossRepository =
mr.source_project_id !== undefined &&
mr.source_project_id !== null &&
mr.target_project_id !== undefined &&
mr.target_project_id !== null &&
mr.source_project_id !== mr.target_project_id;
const sourceProject = isCrossRepository
? await loadSourceProject(input.cwd, mr.source_project_id!)
: null;
return {
number: mr.iid,
baseRefName: mr.target_branch,
@@ -1195,15 +1225,10 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F
{ remoteName: "origin", remoteRef: `refs/merge-requests/${mr.iid}/head` },
{ remoteName: "origin", remoteRef: `refs/heads/${mr.source_branch}` },
],
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository:
mr.source_project_id !== undefined &&
mr.source_project_id !== null &&
mr.target_project_id !== undefined &&
mr.target_project_id !== null &&
mr.source_project_id !== mr.target_project_id,
headOwnerLogin: sourceProject?.namespace.full_path ?? null,
headRepositorySshUrl: sourceProject?.ssh_url_to_repo ?? null,
headRepositoryUrl: sourceProject?.http_url_to_repo ?? null,
isCrossRepository,
headRefKind: "branch",
};
},