diff --git a/cli/src/__tests__/worktree-merge-history.test.ts b/cli/src/__tests__/worktree-merge-history.test.ts index 4ae1930d..c1fcf4b7 100644 --- a/cli/src/__tests__/worktree-merge-history.test.ts +++ b/cli/src/__tests__/worktree-merge-history.test.ts @@ -139,6 +139,41 @@ describe("worktree merge history planner", () => { ]); }); + it("applies an explicit project mapping override instead of clearing the project", () => { + const plan = buildWorktreeMergePlan({ + companyId: "company-1", + companyName: "Paperclip", + issuePrefix: "PAP", + previewIssueCounterStart: 10, + scopes: ["issues"], + sourceIssues: [ + makeIssue({ + id: "issue-project-map", + identifier: "PAP-77", + projectId: "source-project-1", + projectWorkspaceId: "source-workspace-1", + }), + ], + targetIssues: [], + sourceComments: [], + targetComments: [], + targetAgents: [], + targetProjects: [{ id: "target-project-1", name: "Mapped project", status: "in_progress" }] as any, + targetProjectWorkspaces: [], + targetGoals: [{ id: "goal-1" }] as any, + projectIdOverrides: { + "source-project-1": "target-project-1", + }, + }); + + const insert = plan.issuePlans[0] as any; + expect(insert.targetProjectId).toBe("target-project-1"); + expect(insert.projectResolution).toBe("mapped"); + expect(insert.mappedProjectName).toBe("Mapped project"); + expect(insert.targetProjectWorkspaceId).toBeNull(); + expect(insert.adjustments).toEqual(["clear_project_workspace"]); + }); + it("imports comments onto shared or newly imported issues while skipping existing comments", () => { const sharedIssue = makeIssue({ id: "issue-a", identifier: "PAP-10" }); const newIssue = makeIssue({ diff --git a/cli/src/commands/worktree-merge-history-lib.ts b/cli/src/commands/worktree-merge-history-lib.ts index a207da54..1acd352e 100644 --- a/cli/src/commands/worktree-merge-history-lib.ts +++ b/cli/src/commands/worktree-merge-history-lib.ts @@ -39,6 +39,8 @@ export type PlannedIssueInsert = { targetProjectId: string | null; targetProjectWorkspaceId: string | null; targetGoalId: string | null; + projectResolution: "preserved" | "cleared" | "mapped"; + mappedProjectName: string | null; adjustments: ImportAdjustment[]; }; @@ -172,11 +174,13 @@ export function buildWorktreeMergePlan(input: { targetProjects: ProjectRow[]; targetProjectWorkspaces: ProjectWorkspaceRow[]; targetGoals: GoalRow[]; + projectIdOverrides?: Record; }): WorktreeMergePlan { const targetIssuesById = new Map(input.targetIssues.map((issue) => [issue.id, issue])); const targetCommentIds = new Set(input.targetComments.map((comment) => comment.id)); const targetAgentIds = new Set(input.targetAgents.map((agent) => agent.id)); const targetProjectIds = new Set(input.targetProjects.map((project) => project.id)); + const targetProjectsById = new Map(input.targetProjects.map((project) => [project.id, project])); const targetProjectWorkspaceIds = new Set(input.targetProjectWorkspaces.map((workspace) => workspace.id)); const targetGoalIds = new Set(input.targetGoals.map((goal) => goal.id)); const scopes = new Set(input.scopes); @@ -215,8 +219,19 @@ export function buildWorktreeMergePlan(input: { const targetCreatedByAgentId = issue.createdByAgentId && targetAgentIds.has(issue.createdByAgentId) ? issue.createdByAgentId : null; - const targetProjectId = + let targetProjectId = issue.projectId && targetProjectIds.has(issue.projectId) ? issue.projectId : null; + let projectResolution: PlannedIssueInsert["projectResolution"] = targetProjectId ? "preserved" : "cleared"; + let mappedProjectName: string | null = null; + const overrideProjectId = + issue.projectId && input.projectIdOverrides + ? input.projectIdOverrides[issue.projectId] ?? null + : null; + if (!targetProjectId && overrideProjectId && targetProjectIds.has(overrideProjectId)) { + targetProjectId = overrideProjectId; + projectResolution = "mapped"; + mappedProjectName = targetProjectsById.get(overrideProjectId)?.name ?? null; + } if (issue.projectId && !targetProjectId) { adjustments.push("clear_project"); incrementAdjustment(adjustmentCounts, "clear_project"); @@ -224,6 +239,7 @@ export function buildWorktreeMergePlan(input: { const targetProjectWorkspaceId = targetProjectId + && targetProjectId === issue.projectId && issue.projectWorkspaceId && targetProjectWorkspaceIds.has(issue.projectWorkspaceId) ? issue.projectWorkspaceId @@ -262,6 +278,8 @@ export function buildWorktreeMergePlan(input: { targetProjectId, targetProjectWorkspaceId, targetGoalId, + projectResolution, + mappedProjectName, adjustments, }); } diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index e6e4349c..400923b5 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -1224,9 +1224,13 @@ function renderMergePlan(plan: Awaited>["pla lines.push(""); lines.push("Planned issue imports"); for (const issue of issueInserts) { + const projectNote = + issue.projectResolution === "mapped" && issue.mappedProjectName + ? ` project->${issue.mappedProjectName}` + : ""; const adjustments = issue.adjustments.length > 0 ? ` [${issue.adjustments.join(", ")}]` : ""; lines.push( - `- ${issue.source.identifier ?? issue.source.id} -> ${issue.previewIdentifier} (${issue.targetStatus})${adjustments}`, + `- ${issue.source.identifier ?? issue.source.id} -> ${issue.previewIdentifier} (${issue.targetStatus}${projectNote})${adjustments}`, ); } } @@ -1263,9 +1267,10 @@ async function collectMergePlan(input: { targetDb: ClosableDb; company: ResolvedMergeCompany; scopes: ReturnType; + projectIdOverrides?: Record; }) { const companyId = input.company.id; - const [targetCompanyRow, sourceIssuesRows, targetIssuesRows, sourceCommentsRows, targetCommentsRows, targetAgentsRows, targetProjectsRows, targetProjectWorkspaceRows, targetGoalsRows, runCountRows, documentCountRows] = await Promise.all([ + const [targetCompanyRow, sourceIssuesRows, targetIssuesRows, sourceCommentsRows, targetCommentsRows, sourceProjectsRows, targetProjectsRows, targetAgentsRows, targetProjectWorkspaceRows, targetGoalsRows, runCountRows, documentCountRows] = await Promise.all([ input.targetDb .select({ issueCounter: companies.issueCounter, @@ -1293,14 +1298,18 @@ async function collectMergePlan(input: { .from(issueComments) .where(eq(issueComments.companyId, companyId)) : Promise.resolve([]), - input.targetDb + input.sourceDb .select() - .from(agents) - .where(eq(agents.companyId, companyId)), + .from(projects) + .where(eq(projects.companyId, companyId)), input.targetDb .select() .from(projects) .where(eq(projects.companyId, companyId)), + input.targetDb + .select() + .from(agents) + .where(eq(agents.companyId, companyId)), input.targetDb .select() .from(projectWorkspaces) @@ -1338,15 +1347,79 @@ async function collectMergePlan(input: { targetProjects: targetProjectsRows, targetProjectWorkspaces: targetProjectWorkspaceRows, targetGoals: targetGoalsRows, + projectIdOverrides: input.projectIdOverrides, }); return { plan, + sourceProjects: sourceProjectsRows, + targetProjects: targetProjectsRows, unsupportedRunCount: runCountRows[0]?.count ?? 0, unsupportedDocumentCount: documentCountRows[0]?.count ?? 0, }; } +async function promptForProjectMappings(input: { + plan: Awaited>["plan"]; + sourceProjects: Awaited>["sourceProjects"]; + targetProjects: Awaited>["targetProjects"]; +}): Promise> { + const missingProjectIds = [ + ...new Set( + input.plan.issuePlans + .filter((plan): plan is PlannedIssueInsert => plan.action === "insert") + .filter((plan) => !!plan.source.projectId && plan.projectResolution === "cleared") + .map((plan) => plan.source.projectId as string), + ), + ]; + if (missingProjectIds.length === 0 || input.targetProjects.length === 0) { + return {}; + } + + const sourceProjectsById = new Map(input.sourceProjects.map((project) => [project.id, project])); + const targetChoices = [...input.targetProjects] + .sort((left, right) => left.name.localeCompare(right.name)) + .map((project) => ({ + value: project.id, + label: project.name, + hint: project.status, + })); + + const mappings: Record = {}; + for (const sourceProjectId of missingProjectIds) { + const sourceProject = sourceProjectsById.get(sourceProjectId); + if (!sourceProject) continue; + const nameMatch = input.targetProjects.find( + (project) => project.name.trim().toLowerCase() === sourceProject.name.trim().toLowerCase(), + ); + const selection = await p.select({ + message: `Project "${sourceProject.name}" is missing in target. How should ${input.plan.issuePrefix} imports handle it?`, + options: [ + ...(nameMatch + ? [{ + value: nameMatch.id, + label: `Map to ${nameMatch.name}`, + hint: "Recommended: exact name match", + }] + : []), + { + value: null, + label: "Leave unset", + hint: "Keep imported issues without a project", + }, + ...targetChoices.filter((choice) => choice.value !== nameMatch?.id), + ], + initialValue: nameMatch?.id ?? null, + }); + if (p.isCancel(selection)) { + throw new Error("Project mapping cancelled."); + } + mappings[sourceProjectId] = selection; + } + + return mappings; +} + async function applyMergePlan(input: { targetDb: ClosableDb; company: ResolvedMergeCompany; @@ -1490,12 +1563,28 @@ export async function worktreeMergeHistoryCommand(sourceArg: string, opts: Workt targetDb: targetHandle.db, selector: opts.company, }); - const collected = await collectMergePlan({ + let collected = await collectMergePlan({ sourceDb: sourceHandle.db, targetDb: targetHandle.db, company, scopes, }); + if (!opts.yes) { + const projectIdOverrides = await promptForProjectMappings({ + plan: collected.plan, + sourceProjects: collected.sourceProjects, + targetProjects: collected.targetProjects, + }); + if (Object.keys(projectIdOverrides).length > 0) { + collected = await collectMergePlan({ + sourceDb: sourceHandle.db, + targetDb: targetHandle.db, + company, + scopes, + projectIdOverrides, + }); + } + } console.log(renderMergePlan(collected.plan, { sourcePath: sourceRoot,