Start new workspaces from pasted pull requests (#2290)
* fix(workspaces): start pasted pull requests from their branch Recognized pull request links now select their branch directly instead of waiting for a separate confirmation. Ensure single-branch clones also record the fetched branch so the new worktree can track it. * fix(workspaces): keep pasted PR checkout target-safe * fix(workspaces): make pasted PR resolution deterministic * fix(workspaces): preserve explicit checkout selection A recognized pull request selects itself only when its attachment is added. Once the user chooses a branch, later composer edits must not derive checkout state from existing attachments. * fix(composer): ignore stale pull request lookups A lookup may finish after the workspace target changes. Only apply its attachment and selection event when the host, working directory, client, and remote still match the target that started it. * fix(workspaces): make checkout choices authoritative Arm automatic pull request selection only when a new PR link is detected. An explicit picker choice cancels that pending selection, existing draft attachments never infer checkout state, and target changes suppress carried-over lookups. * fix(workspaces): stabilize pull request auto-selection Keep the first PR selected when one edit contains several links, and accept a completed lookup when only the transport client changed for the same host and checkout. * fix(composer): release removed pull request lookups Release each auto-attach invocation when its composer state is abandoned, while continuing to discard any stale response.
This commit is contained in:
@@ -2,7 +2,8 @@ import { describe, expect, it } from "vitest";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import {
|
||||
clearPickerPrAttachmentForTargetChange,
|
||||
findCheckoutHintPrAttachment,
|
||||
initialPickerSelectionState,
|
||||
reducePickerSelection,
|
||||
syncPickerPrAttachment,
|
||||
} from "./new-workspace-picker-state";
|
||||
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
|
||||
@@ -28,21 +29,28 @@ function prAttachment(
|
||||
return { kind: "github_pr", item, ...(owner ? { owner } : {}) };
|
||||
}
|
||||
|
||||
function issueAttachment(number: number): UserComposerAttachment {
|
||||
function forgePrAttachment(
|
||||
item: ForgeSearchItem,
|
||||
): Extract<UserComposerAttachment, { kind: "forge_change_request" }> {
|
||||
return { kind: "forge_change_request", item };
|
||||
}
|
||||
|
||||
function makeIssueItem(number: number): ForgeSearchItem {
|
||||
return {
|
||||
kind: "github_issue",
|
||||
item: {
|
||||
kind: "issue",
|
||||
number,
|
||||
title: `Issue ${number}`,
|
||||
url: `https://example.com/issues/${number}`,
|
||||
state: "open",
|
||||
body: null,
|
||||
labels: [],
|
||||
},
|
||||
kind: "issue",
|
||||
number,
|
||||
title: `Issue ${number}`,
|
||||
url: `https://example.com/issues/${number}`,
|
||||
state: "open",
|
||||
body: null,
|
||||
labels: [],
|
||||
};
|
||||
}
|
||||
|
||||
function issueAttachment(number: number): UserComposerAttachment {
|
||||
return { kind: "github_issue", item: makeIssueItem(number) };
|
||||
}
|
||||
|
||||
describe("syncPickerPrAttachment", () => {
|
||||
it("selects a PR when no previous picker PR is set", () => {
|
||||
const pr = makePrItem(202, "Refactor picker");
|
||||
@@ -91,6 +99,15 @@ describe("syncPickerPrAttachment", () => {
|
||||
expect(result).toEqual([prAttachment(pr)]);
|
||||
});
|
||||
|
||||
it("does not duplicate a generalized PR attachment", () => {
|
||||
const pr = makePrItem(202, "Refactor picker");
|
||||
const result = syncPickerPrAttachment({
|
||||
attachments: [forgePrAttachment(pr)],
|
||||
item: { kind: "github-pr", item: pr },
|
||||
});
|
||||
expect(result).toEqual([forgePrAttachment(pr)]);
|
||||
});
|
||||
|
||||
it("clears a persisted picker selection without removing user-added attachments", () => {
|
||||
const pickerPr = prAttachment(makePrItem(202, "Picker PR"), "new-workspace-picker");
|
||||
const manuallyAttachedPr = prAttachment(makePrItem(303, "Manual PR"));
|
||||
@@ -119,67 +136,82 @@ describe("clearPickerPrAttachmentForTargetChange", () => {
|
||||
).toBe(attachments);
|
||||
});
|
||||
|
||||
it("clears only the picker-owned PR when the target changes", () => {
|
||||
it("clears all PR attachments when the target changes", () => {
|
||||
const pickerPr = prAttachment(makePrItem(202, "Picker PR"), "new-workspace-picker");
|
||||
const manualPr = prAttachment(makePrItem(303, "Manual PR"));
|
||||
const forgePr = forgePrAttachment(makePrItem(404, "Forge PR"));
|
||||
const issue = issueAttachment(44);
|
||||
|
||||
expect(
|
||||
clearPickerPrAttachmentForTargetChange({
|
||||
attachments: [pickerPr, manualPr],
|
||||
attachments: [issue, pickerPr, manualPr, forgePr],
|
||||
currentTargetId: "server-a",
|
||||
nextTargetId: "server-b",
|
||||
}),
|
||||
).toEqual([manualPr]);
|
||||
).toEqual([issue]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findCheckoutHintPrAttachment", () => {
|
||||
it("returns the first attached PR that is not selected or dismissed", () => {
|
||||
const first = prAttachment(makePrItem(101, "A"));
|
||||
const second = prAttachment(makePrItem(202, "B"));
|
||||
describe("reducePickerSelection", () => {
|
||||
it("selects a PR that was newly detected and added", () => {
|
||||
const item = { kind: "github-pr" as const, item: makePrItem(101, "A") };
|
||||
const detected = reducePickerSelection(initialPickerSelectionState, { type: "pr-detected" });
|
||||
|
||||
expect(
|
||||
findCheckoutHintPrAttachment({
|
||||
attachments: [issueAttachment(44), first, second],
|
||||
selectedItem: null,
|
||||
dismissedPrNumbers: new Set(),
|
||||
}),
|
||||
).toBe(first);
|
||||
expect(reducePickerSelection(detected, { type: "pr-added", item })).toEqual({
|
||||
selectedItem: item,
|
||||
allowAutoPrSelection: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("skips the selected PR and offers the next attached PR", () => {
|
||||
const selected = prAttachment(makePrItem(101, "A"));
|
||||
const next = prAttachment(makePrItem(202, "B"));
|
||||
it("keeps the first PR selected when one edit adds multiple PRs", () => {
|
||||
const detected = reducePickerSelection(initialPickerSelectionState, { type: "pr-detected" });
|
||||
const first = reducePickerSelection(detected, {
|
||||
type: "pr-added",
|
||||
item: { kind: "github-pr", item: makePrItem(101, "A") },
|
||||
});
|
||||
|
||||
expect(
|
||||
findCheckoutHintPrAttachment({
|
||||
attachments: [selected, next],
|
||||
selectedItem: { kind: "github-pr", item: selected.item },
|
||||
dismissedPrNumbers: new Set(),
|
||||
reducePickerSelection(first, {
|
||||
type: "pr-added",
|
||||
item: { kind: "github-pr", item: makePrItem(202, "B") },
|
||||
}),
|
||||
).toBe(next);
|
||||
).toEqual(first);
|
||||
});
|
||||
|
||||
it("skips dismissed PRs and ignores issues", () => {
|
||||
const dismissed = prAttachment(makePrItem(101, "A"));
|
||||
const next = prAttachment(makePrItem(202, "B"));
|
||||
it("keeps a branch selected after a pending PR is added", () => {
|
||||
const detected = reducePickerSelection(initialPickerSelectionState, { type: "pr-detected" });
|
||||
const branchSelected = reducePickerSelection(detected, {
|
||||
type: "picker-selected",
|
||||
item: { kind: "branch", name: "main" },
|
||||
});
|
||||
|
||||
expect(
|
||||
findCheckoutHintPrAttachment({
|
||||
attachments: [issueAttachment(44), dismissed, next],
|
||||
selectedItem: null,
|
||||
dismissedPrNumbers: new Set([101]),
|
||||
reducePickerSelection(branchSelected, {
|
||||
type: "pr-added",
|
||||
item: { kind: "github-pr", item: makePrItem(101, "A") },
|
||||
}),
|
||||
).toBe(next);
|
||||
).toEqual(branchSelected);
|
||||
});
|
||||
|
||||
it("returns null when only issues qualify", () => {
|
||||
it("does not derive checkout selection from an existing attachment", () => {
|
||||
expect(
|
||||
findCheckoutHintPrAttachment({
|
||||
attachments: [issueAttachment(44)],
|
||||
selectedItem: null,
|
||||
dismissedPrNumbers: new Set(),
|
||||
reducePickerSelection(initialPickerSelectionState, {
|
||||
type: "pr-added",
|
||||
item: { kind: "github-pr", item: makePrItem(101, "A") },
|
||||
}),
|
||||
).toBeNull();
|
||||
).toEqual(initialPickerSelectionState);
|
||||
});
|
||||
|
||||
it("lets a newly detected PR replace an earlier explicit branch", () => {
|
||||
const branchSelected = reducePickerSelection(initialPickerSelectionState, {
|
||||
type: "picker-selected",
|
||||
item: { kind: "branch", name: "main" },
|
||||
});
|
||||
const detected = reducePickerSelection(branchSelected, { type: "pr-detected" });
|
||||
const pr = { kind: "github-pr" as const, item: makePrItem(101, "A") };
|
||||
|
||||
expect(reducePickerSelection(detected, { type: "pr-added", item: pr }).selectedItem).toEqual(
|
||||
pr,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user