mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 14:43:01 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a078dd9bc5 | ||
|
|
eecac800b6 | ||
|
|
ea3f2ccd4a |
@@ -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 {
|
||||
|
||||
+22
-156
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user