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
6 changed files with 186 additions and 224 deletions
@@ -643,7 +643,6 @@ interface PendingPermission {
resolve: (response: RequestPermissionResponse) => void;
reject: (error: Error) => void;
turnId: string | null;
toolCallId: string;
}
interface PendingUserMessage {
@@ -2645,7 +2644,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
resolve,
reject,
turnId: this.activeForegroundTurnId,
toolCallId: params.toolCall.toolCallId,
});
});
@@ -3209,9 +3207,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
snapshot = this.toolSnapshotTransformer(snapshot);
}
this.toolCalls.set(toolCallId, snapshot);
if (update.status === "completed" || update.status === "failed") {
this.cancelPendingPermissions((pending) => pending.toolCallId === toolCallId);
}
return [this.wrapTimeline(mapToolSnapshotToTimeline(snapshot, this.terminalEntries))];
}
@@ -3419,11 +3414,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>,
): void {
this.deliverTranslatedEvents(this.flushPendingUserMessage());
// Failed and canceled turns clear every request in the manager. Answer the
// same set here so an outside-turn request cannot keep the provider blocked.
this.cancelPendingPermissions(
(pending) => event.type !== "turn_completed" || pending.turnId === event.turnId,
);
// A turn that ends without completing leaves no way to answer its open
// permission requests, so tell the agent they were cancelled.
if (event.type !== "turn_completed") {
this.cancelPendingPermissions();
}
this.activeForegroundTurnId = null;
this.fallbackAssistantMessageId = null;
if (this.submittedUserMessageTurnId === event.turnId) {
@@ -3432,21 +3427,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.pushEvent(event);
}
private cancelPendingPermissions(
matches: (pending: PendingPermission) => boolean = () => true,
): void {
for (const [requestId, pending] of this.pendingPermissions) {
if (!matches(pending)) continue;
this.pendingPermissions.delete(requestId);
private cancelPendingPermissions(): void {
for (const pending of this.pendingPermissions.values()) {
pending.resolve({ outcome: { outcome: "cancelled" } });
this.pushEvent({
type: "permission_resolved",
provider: this.provider,
requestId,
resolution: { behavior: "deny", message: "Permission request canceled" },
turnId: pending.turnId ?? undefined,
});
}
this.pendingPermissions.clear();
}
private emitBootstrapThreadEvent(): void {
@@ -5,7 +5,6 @@ import path from "node:path";
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { AgentManager } from "../agent-manager.js";
import type { AgentSession, AgentStreamEvent } from "../agent-sdk-types.js";
import { GenericACPAgentClient } from "./generic-acp-agent.js";
@@ -13,7 +12,6 @@ describe("GenericACPAgentClient permission requests when a turn ends early", ()
test.each([
{ ending: "fail", terminalEvent: "turn_failed" },
{ ending: "cancel", terminalEvent: "turn_canceled" },
{ ending: "end_turn", terminalEvent: "turn_completed" },
] as const)(
"answers a permission request left open by a $terminalEvent turn with cancelled",
async ({ ending, terminalEvent }) => {
@@ -27,16 +25,6 @@ describe("GenericACPAgentClient permission requests when a turn ends early", ()
await waitForEvent(events, terminalEvent);
expect(hasEvent(events, "permission_requested")).toBe(true);
expect(session.getPendingPermissions()).toEqual([]);
const requested = permissionRequests(events)[0]!;
expect(resolvedEvents(events)).toEqual([
{
type: "permission_resolved",
provider: "acp",
requestId: requested.request.id,
resolution: { behavior: "deny", message: "Permission request canceled" },
turnId: requested.turnId,
},
]);
const replyStart = events.length;
await session.startTurn("turn two");
@@ -51,84 +39,6 @@ describe("GenericACPAgentClient permission requests when a turn ends early", ()
});
},
);
test.each(["completed", "failed"] as const)(
"cancels only the matching permission when its tool becomes %s",
async (status) => {
await withFakeACPAgent(status, async (command, cwd) => {
const client = new GenericACPAgentClient({ logger: createTestLogger(), command });
const session = await client.createSession({ provider: "acp", cwd });
try {
const events = collectEvents(session);
await session.startTurn("turn one");
await waitForToolUpdate(events);
expect(pendingToolIds(session)).toEqual(["tool-2"]);
expect(resolvedEvents(events)).toEqual([
expect.objectContaining({ requestId: permissionRequests(events)[0]!.request.id }),
]);
} finally {
await session.close();
}
});
},
);
test("keeps a permission raised outside a turn answerable after another turn completes", async () => {
await withFakeACPAgent("outside_turn", async (command, cwd) => {
const client = new GenericACPAgentClient({ logger: createTestLogger(), command });
const session = await client.createSession({ provider: "acp", cwd });
try {
const outside = session.getPendingPermissions();
expect(pendingToolIds(session)).toEqual(["tool-outside"]);
const events = collectEvents(session);
await session.startTurn("turn one");
await waitForEvent(events, "turn_completed");
expect(session.getPendingPermissions()).toEqual(outside);
await session.respondToPermission(outside[0]!.id, { behavior: "allow" });
expect(session.getPendingPermissions()).toEqual([]);
} finally {
await session.close();
}
});
});
test.each(["fail", "cancel"] as const)(
"settles an outside-turn permission that the manager clears when a turn ends with %s",
async (ending) => {
await withFakeACPAgent(`outside_${ending}`, async (command, cwd) => {
const logger = createTestLogger();
const client = new GenericACPAgentClient({
logger,
command,
waitForInitialCommands: false,
});
const manager = new AgentManager({ clients: { acp: client }, logger });
const agent = await manager.createAgent({ provider: "acp", cwd }, undefined, {
workspaceId: undefined,
});
try {
await waitForManagedPermission(manager, agent.id);
if (ending === "fail") {
await expect(manager.runAgent(agent.id, "turn one")).rejects.toThrow(
"turn failed with a permission open",
);
} else {
await expect(manager.runAgent(agent.id, "turn one")).resolves.toMatchObject({
canceled: true,
});
}
expect(manager.getPendingPermissions(agent.id)).toEqual([]);
const result = await manager.runAgent(agent.id, "turn two");
expect(result.finalText).toBe(
'perm-outside answered: {"outcome":{"outcome":"cancelled"}}',
);
} finally {
await manager.closeAgent(agent.id);
await manager.flush();
}
});
},
);
});
function collectEvents(session: AgentSession): AgentStreamEvent[] {
@@ -137,10 +47,6 @@ function collectEvents(session: AgentSession): AgentStreamEvent[] {
return events;
}
async function waitForManagedPermission(manager: AgentManager, agentId: string): Promise<void> {
await expect.poll(() => manager.getPendingPermissions(agentId).length).toBe(1);
}
function hasEvent(
events: AgentStreamEvent[],
type: AgentStreamEvent["type"],
@@ -171,32 +77,8 @@ function assistantText(events: AgentStreamEvent[]): string {
.join("");
}
function permissionRequests(events: AgentStreamEvent[]) {
return events.filter((event) => event.type === "permission_requested");
}
function resolvedEvents(events: AgentStreamEvent[]) {
return events.filter((event) => event.type === "permission_resolved");
}
function pendingToolIds(session: AgentSession) {
return session.getPendingPermissions().map((request) => request.metadata?.toolCallId);
}
async function waitForToolUpdate(events: AgentStreamEvent[]): Promise<void> {
await expect.poll(() => assistantText(events)).toBe("tool terminal");
}
async function withFakeACPAgent(
ending:
| "fail"
| "cancel"
| "end_turn"
| "completed"
| "failed"
| "outside_turn"
| "outside_fail"
| "outside_cancel",
ending: "fail" | "cancel",
run: (command: [string, ...string[]], cwd: string) => Promise<void>,
): Promise<void> {
const testDir = await mkdtemp(path.join(tmpdir(), "paseo-acp-permission-turn-failure-"));
@@ -209,7 +91,8 @@ async function withFakeACPAgent(
}
}
// Turn one ends while its permission request is still open.
// Turn one asks for permission, then fails or is cancelled while the request is
// still open.
// Later turns report whether that request was ever answered.
const fakeACPAgentScript = `
const readline = require("node:readline");
@@ -218,34 +101,15 @@ const ending = process.argv[2];
const rl = readline.createInterface({ input: process.stdin });
let promptCount = 0;
let permissionAnswer = null;
let outsidePermissionAnswer = null;
function write(message) {
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\\n");
}
function ask(sessionId, requestId, toolCallId) {
const toolCall = { toolCallId, title: "echo hello", kind: "execute", status: "pending" };
write({ method: "session/update", params: { sessionId, update: { sessionUpdate: "tool_call", ...toolCall } } });
write({
id: requestId,
method: "session/request_permission",
params: {
sessionId,
toolCall,
options: [
{ optionId: "allow", name: "Allow once", kind: "allow_once" },
{ optionId: "reject", name: "Reject", kind: "reject_once" },
],
},
});
}
rl.on("line", (line) => {
const message = JSON.parse(line);
if (message.method === undefined) {
if (message.id === "perm-1") permissionAnswer = message.result ?? { error: message.error };
if (message.id === "perm-outside") outsidePermissionAnswer = message.result ?? { error: message.error };
if (message.method === undefined && message.id === "perm-1") {
permissionAnswer = message.result ?? { error: message.error };
return;
}
if (message.method === "initialize") {
@@ -253,7 +117,6 @@ rl.on("line", (line) => {
return;
}
if (message.method === "session/new") {
if (ending.startsWith("outside_")) ask("session-1", "perm-outside", "tool-outside");
write({ id: message.id, result: { sessionId: "session-1" } });
return;
}
@@ -261,32 +124,35 @@ rl.on("line", (line) => {
const sessionId = message.params.sessionId;
promptCount += 1;
if (promptCount === 1) {
ask(sessionId, "perm-1", "tool-1");
if (ending === "completed" || ending === "failed") {
ask(sessionId, "perm-2", "tool-2");
write({ method: "session/update", params: { sessionId, update: { sessionUpdate: "tool_call_update", toolCallId: "tool-1", status: ending } } });
write({ method: "session/update", params: { sessionId, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "tool terminal" } } } });
return;
}
const toolCall = { toolCallId: "tool-1", title: "echo hello", kind: "execute", status: "pending" };
write({ method: "session/update", params: { sessionId, update: { sessionUpdate: "tool_call", ...toolCall } } });
write({
id: "perm-1",
method: "session/request_permission",
params: {
sessionId,
toolCall,
options: [
{ optionId: "allow", name: "Allow once", kind: "allow_once" },
{ optionId: "reject", name: "Reject", kind: "reject_once" },
],
},
});
setTimeout(() => {
if (ending === "cancel" || ending === "outside_cancel") {
if (ending === "cancel") {
write({ id: message.id, result: { stopReason: "cancelled" } });
} else if (ending === "end_turn" || ending === "outside_turn") {
write({ id: message.id, result: { stopReason: "end_turn" } });
} else {
write({ id: message.id, error: { code: -32603, message: "turn failed with a permission open" } });
}
}, 50);
return;
}
const answer = ending.startsWith("outside_") ? outsidePermissionAnswer : permissionAnswer;
const requestId = ending.startsWith("outside_") ? "perm-outside" : "perm-1";
const state = answer === null ? "unanswered" : "answered: " + JSON.stringify(answer);
const state = permissionAnswer === null ? "unanswered" : "answered: " + JSON.stringify(permissionAnswer);
write({
method: "session/update",
params: {
sessionId,
update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: requestId + " " + state } },
update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "perm-1 " + state } },
},
});
write({ id: message.id, result: { stopReason: "end_turn" } });
@@ -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",
);
@@ -558,31 +558,34 @@ describe("runWorktreeSetupInBackground", () => {
});
test("runs setup from an exact workspace subdirectory", async () => {
const tempDir = realpathSync.native(mkdtempSync(path.join(tmpdir(), "worktree-setup-test-")));
const { tempDir, repoDir } = createGitRepo();
cleanupPaths.push(tempDir);
const repoDir = path.join(tempDir, "source");
const sourceWorkspaceCwd = path.join(repoDir, "packages", "app");
const worktree: WorktreeConfig = {
branchName: "feature-subdirectory-setup",
worktreePath: path.join(tempDir, "worktree"),
};
const workspaceCwd = path.join(worktree.worktreePath, "packages", "app");
// Setup consumes an existing workspace. Git creation is covered by the workflow tests.
mkdirSync(sourceWorkspaceCwd, { recursive: true });
mkdirSync(workspaceCwd, { recursive: true });
execFileSync("git", ["init", "-b", worktree.branchName], {
cwd: worktree.worktreePath,
stdio: "pipe",
});
writeFileSync(
path.join(workspaceCwd, "paseo.json"),
path.join(sourceWorkspaceCwd, "paseo.json"),
JSON.stringify({
worktree: {
setup: ["pwd > setup-cwd.txt"],
},
}),
);
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add app setup"], {
cwd: repoDir,
stdio: "pipe",
});
const paseoHome = path.join(tempDir, ".paseo");
const createdWorktree = await createLegacyWorktreeForTest({
branchName: "feature-subdirectory-setup",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "feature-subdirectory-setup",
paseoHome,
});
const workspaceCwd = path.join(createdWorktree.worktreePath, "packages", "app");
await runWorktreeSetupInBackground(
{
@@ -597,16 +600,16 @@ describe("runWorktreeSetupInBackground", () => {
requestCwd: sourceWorkspaceCwd,
repoRoot: repoDir,
workspaceId: "ws-subdirectory-setup",
worktree,
worktree: createdWorktree,
shouldBootstrap: true,
slug: "feature-subdirectory-setup",
worktreePath: worktree.worktreePath,
worktreePath: createdWorktree.worktreePath,
workspaceCwd,
},
);
expect(existsSync(path.join(workspaceCwd, "setup-cwd.txt"))).toBe(true);
expect(existsSync(path.join(worktree.worktreePath, "setup-cwd.txt"))).toBe(false);
expect(existsSync(path.join(createdWorktree.worktreePath, "setup-cwd.txt"))).toBe(false);
});
test("emits running then completed snapshots for no-setup workspaces without auto-starting scripts", async () => {
@@ -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",
};
},