diff --git a/cli/src/__tests__/worktree-merge-history.test.ts b/cli/src/__tests__/worktree-merge-history.test.ts index 7a4d6b8b..fa910872 100644 --- a/cli/src/__tests__/worktree-merge-history.test.ts +++ b/cli/src/__tests__/worktree-merge-history.test.ts @@ -115,6 +115,52 @@ function makeAttachment(overrides: Record = {}) { } as any; } +function makeProject(overrides: Record = {}) { + return { + id: "project-1", + companyId: "company-1", + goalId: null, + name: "Project", + description: null, + status: "in_progress", + leadAgentId: null, + targetDate: null, + color: "#22c55e", + pauseReason: null, + pausedAt: null, + executionWorkspacePolicy: null, + archivedAt: null, + createdAt: new Date("2026-03-20T00:00:00.000Z"), + updatedAt: new Date("2026-03-20T00:00:00.000Z"), + ...overrides, + } as any; +} + +function makeProjectWorkspace(overrides: Record = {}) { + return { + id: "workspace-1", + companyId: "company-1", + projectId: "project-1", + name: "Workspace", + sourceType: "local_path", + cwd: "/tmp/project", + repoUrl: "https://github.com/example/project.git", + repoRef: "main", + defaultRef: "main", + visibility: "default", + setupCommand: null, + cleanupCommand: null, + remoteProvider: null, + remoteWorkspaceRef: null, + sharedWorkspaceKey: null, + metadata: null, + isPrimary: true, + createdAt: new Date("2026-03-20T00:00:00.000Z"), + updatedAt: new Date("2026-03-20T00:00:00.000Z"), + ...overrides, + } as any; +} + describe("worktree merge history planner", () => { it("parses default scopes", () => { expect(parseWorktreeMergeScopes(undefined)).toEqual(["issues", "comments"]); @@ -236,6 +282,60 @@ describe("worktree merge history planner", () => { expect(insert.adjustments).toEqual(["clear_project_workspace"]); }); + it("plans selected project imports and preserves project workspace links", () => { + const sourceProject = makeProject({ + id: "source-project-1", + name: "Paperclip Evals", + goalId: "goal-1", + }); + const sourceWorkspace = makeProjectWorkspace({ + id: "source-workspace-1", + projectId: "source-project-1", + cwd: "/Users/dotta/paperclip-evals", + repoUrl: "https://github.com/paperclipai/paperclip-evals.git", + }); + + const plan = buildWorktreeMergePlan({ + companyId: "company-1", + companyName: "Paperclip", + issuePrefix: "PAP", + previewIssueCounterStart: 10, + scopes: ["issues"], + sourceIssues: [ + makeIssue({ + id: "issue-project-import", + identifier: "PAP-88", + projectId: "source-project-1", + projectWorkspaceId: "source-workspace-1", + }), + ], + targetIssues: [], + sourceComments: [], + targetComments: [], + sourceProjects: [sourceProject], + sourceProjectWorkspaces: [sourceWorkspace], + targetAgents: [], + targetProjects: [], + targetProjectWorkspaces: [], + targetGoals: [{ id: "goal-1" }] as any, + importProjectIds: ["source-project-1"], + }); + + expect(plan.counts.projectsToImport).toBe(1); + expect(plan.projectImports[0]).toMatchObject({ + source: { id: "source-project-1", name: "Paperclip Evals" }, + targetGoalId: "goal-1", + workspaces: [{ id: "source-workspace-1" }], + }); + + const insert = plan.issuePlans[0] as any; + expect(insert.targetProjectId).toBe("source-project-1"); + expect(insert.targetProjectWorkspaceId).toBe("source-workspace-1"); + expect(insert.projectResolution).toBe("imported"); + expect(insert.mappedProjectName).toBe("Paperclip Evals"); + expect(insert.adjustments).toEqual([]); + }); + 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 a55a22d3..6b16ecb8 100644 --- a/cli/src/commands/worktree-merge-history-lib.ts +++ b/cli/src/commands/worktree-merge-history-lib.ts @@ -50,7 +50,7 @@ export type PlannedIssueInsert = { targetProjectId: string | null; targetProjectWorkspaceId: string | null; targetGoalId: string | null; - projectResolution: "preserved" | "cleared" | "mapped"; + projectResolution: "preserved" | "cleared" | "mapped" | "imported"; mappedProjectName: string | null; adjustments: ImportAdjustment[]; }; @@ -173,17 +173,26 @@ export type PlannedAttachmentSkip = { action: "skip_existing" | "skip_missing_parent"; }; +export type PlannedProjectImport = { + source: ProjectRow; + targetLeadAgentId: string | null; + targetGoalId: string | null; + workspaces: ProjectWorkspaceRow[]; +}; + export type WorktreeMergePlan = { companyId: string; companyName: string; issuePrefix: string; previewIssueCounterStart: number; scopes: WorktreeMergeScope[]; + projectImports: PlannedProjectImport[]; issuePlans: Array; commentPlans: Array; documentPlans: Array; attachmentPlans: Array; counts: { + projectsToImport: number; issuesToInsert: number; issuesExisting: number; issueDrift: number; @@ -338,6 +347,8 @@ export function buildWorktreeMergePlan(input: { targetIssues: IssueRow[]; sourceComments: CommentRow[]; targetComments: CommentRow[]; + sourceProjects?: ProjectRow[]; + sourceProjectWorkspaces?: ProjectWorkspaceRow[]; sourceDocuments?: IssueDocumentRow[]; targetDocuments?: IssueDocumentRow[]; sourceDocumentRevisions?: DocumentRevisionRow[]; @@ -348,6 +359,7 @@ export function buildWorktreeMergePlan(input: { targetProjects: ProjectRow[]; targetProjectWorkspaces: ProjectWorkspaceRow[]; targetGoals: GoalRow[]; + importProjectIds?: Iterable; projectIdOverrides?: Record; }): WorktreeMergePlan { const targetIssuesById = new Map(input.targetIssues.map((issue) => [issue.id, issue])); @@ -357,6 +369,10 @@ export function buildWorktreeMergePlan(input: { 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 sourceProjectsById = new Map((input.sourceProjects ?? []).map((project) => [project.id, project])); + const sourceProjectWorkspaces = input.sourceProjectWorkspaces ?? []; + const sourceProjectWorkspacesByProjectId = groupBy(sourceProjectWorkspaces, (workspace) => workspace.projectId); + const importProjectIds = new Set(input.importProjectIds ?? []); const scopes = new Set(input.scopes); const adjustmentCounts: Record = { @@ -371,6 +387,34 @@ export function buildWorktreeMergePlan(input: { clear_attachment_agent: 0, }; + const projectImports: PlannedProjectImport[] = []; + for (const projectId of importProjectIds) { + if (targetProjectIds.has(projectId)) continue; + const sourceProject = sourceProjectsById.get(projectId); + if (!sourceProject) continue; + projectImports.push({ + source: sourceProject, + targetLeadAgentId: + sourceProject.leadAgentId && targetAgentIds.has(sourceProject.leadAgentId) + ? sourceProject.leadAgentId + : null, + targetGoalId: + sourceProject.goalId && targetGoalIds.has(sourceProject.goalId) + ? sourceProject.goalId + : null, + workspaces: [...(sourceProjectWorkspacesByProjectId.get(projectId) ?? [])].sort((left, right) => { + const primaryDelta = Number(right.isPrimary) - Number(left.isPrimary); + if (primaryDelta !== 0) return primaryDelta; + const createdDelta = left.createdAt.getTime() - right.createdAt.getTime(); + if (createdDelta !== 0) return createdDelta; + return left.id.localeCompare(right.id); + }), + }); + } + const importedProjectWorkspaceIds = new Set( + projectImports.flatMap((project) => project.workspaces.map((workspace) => workspace.id)), + ); + const issuePlans: Array = []; let nextPreviewIssueNumber = input.previewIssueCounterStart; for (const issue of sortIssuesForImport(input.sourceIssues)) { @@ -409,6 +453,14 @@ export function buildWorktreeMergePlan(input: { projectResolution = "mapped"; mappedProjectName = targetProjectsById.get(overrideProjectId)?.name ?? null; } + if (!targetProjectId && issue.projectId && importProjectIds.has(issue.projectId)) { + const sourceProject = sourceProjectsById.get(issue.projectId); + if (sourceProject) { + targetProjectId = sourceProject.id; + projectResolution = "imported"; + mappedProjectName = sourceProject.name; + } + } if (issue.projectId && !targetProjectId) { adjustments.push("clear_project"); incrementAdjustment(adjustmentCounts, "clear_project"); @@ -418,7 +470,8 @@ export function buildWorktreeMergePlan(input: { targetProjectId && targetProjectId === issue.projectId && issue.projectWorkspaceId - && targetProjectWorkspaceIds.has(issue.projectWorkspaceId) + && (targetProjectWorkspaceIds.has(issue.projectWorkspaceId) + || importedProjectWorkspaceIds.has(issue.projectWorkspaceId)) ? issue.projectWorkspaceId : null; if (issue.projectWorkspaceId && !targetProjectWorkspaceId) { @@ -672,6 +725,7 @@ export function buildWorktreeMergePlan(input: { } const counts = { + projectsToImport: projectImports.length, issuesToInsert: issuePlans.filter((plan) => plan.action === "insert").length, issuesExisting: issuePlans.filter((plan) => plan.action === "skip_existing").length, issueDrift: issuePlans.filter((plan) => plan.action === "skip_existing" && plan.driftKeys.length > 0).length, @@ -699,6 +753,7 @@ export function buildWorktreeMergePlan(input: { issuePrefix: input.issuePrefix, previewIssueCounterStart: input.previewIssueCounterStart, scopes: input.scopes, + projectImports, issuePlans, commentPlans, documentPlans, diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index 57166d8f..877f6bdd 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -1488,20 +1488,34 @@ function renderMergePlan(plan: Awaited>["pla `Target: ${extras.targetPath}`, `Company: ${plan.companyName} (${plan.issuePrefix})`, "", + "Projects", + `- import: ${plan.counts.projectsToImport}`, + "", "Issues", `- insert: ${plan.counts.issuesToInsert}`, `- already present: ${plan.counts.issuesExisting}`, `- shared/imported issues with drift: ${plan.counts.issueDrift}`, ]; + if (plan.projectImports.length > 0) { + lines.push(""); + lines.push("Planned project imports"); + for (const project of plan.projectImports) { + lines.push( + `- ${project.source.name} (${project.workspaces.length} workspace${project.workspaces.length === 1 ? "" : "s"})`, + ); + } + } + const issueInserts = plan.issuePlans.filter((item): item is PlannedIssueInsert => item.action === "insert"); if (issueInserts.length > 0) { lines.push(""); lines.push("Planned issue imports"); for (const issue of issueInserts) { const projectNote = - issue.projectResolution === "mapped" && issue.mappedProjectName - ? ` project->${issue.mappedProjectName}` + (issue.projectResolution === "mapped" || issue.projectResolution === "imported") + && issue.mappedProjectName + ? ` project->${issue.projectResolution === "imported" ? "import:" : ""}${issue.mappedProjectName}` : ""; const adjustments = issue.adjustments.length > 0 ? ` [${issue.adjustments.join(", ")}]` : ""; const prefix = `- ${issue.source.identifier ?? issue.source.id} -> ${issue.previewIdentifier} (${issue.targetStatus}${projectNote})`; @@ -1562,6 +1576,7 @@ async function collectMergePlan(input: { targetDb: ClosableDb; company: ResolvedMergeCompany; scopes: ReturnType; + importProjectIds?: Iterable; projectIdOverrides?: Record; }) { const companyId = input.company.id; @@ -1578,6 +1593,7 @@ async function collectMergePlan(input: { sourceAttachmentRows, targetAttachmentRows, sourceProjectsRows, + sourceProjectWorkspaceRows, targetProjectsRows, targetAgentsRows, targetProjectWorkspaceRows, @@ -1743,6 +1759,10 @@ async function collectMergePlan(input: { .select() .from(projects) .where(eq(projects.companyId, companyId)), + input.sourceDb + .select() + .from(projectWorkspaces) + .where(eq(projectWorkspaces.companyId, companyId)), input.targetDb .select() .from(projects) @@ -1779,6 +1799,8 @@ async function collectMergePlan(input: { targetIssues: targetIssuesRows, sourceComments: sourceCommentsRows, targetComments: targetCommentsRows, + sourceProjects: sourceProjectsRows, + sourceProjectWorkspaces: sourceProjectWorkspaceRows, sourceDocuments: sourceIssueDocumentsRows as IssueDocumentRow[], targetDocuments: targetIssueDocumentsRows as IssueDocumentRow[], sourceDocumentRevisions: sourceDocumentRevisionRows as DocumentRevisionRow[], @@ -1789,6 +1811,7 @@ async function collectMergePlan(input: { targetProjects: targetProjectsRows, targetProjectWorkspaces: targetProjectWorkspaceRows, targetGoals: targetGoalsRows, + importProjectIds: input.importProjectIds, projectIdOverrides: input.projectIdOverrides, }); @@ -1800,11 +1823,16 @@ async function collectMergePlan(input: { }; } +type ProjectMappingSelections = { + importProjectIds: string[]; + projectIdOverrides: Record; +}; + async function promptForProjectMappings(input: { plan: Awaited>["plan"]; sourceProjects: Awaited>["sourceProjects"]; targetProjects: Awaited>["targetProjects"]; -}): Promise> { +}): Promise { const missingProjectIds = [ ...new Set( input.plan.issuePlans @@ -1813,8 +1841,11 @@ async function promptForProjectMappings(input: { .map((plan) => plan.source.projectId as string), ), ]; - if (missingProjectIds.length === 0 || input.targetProjects.length === 0) { - return {}; + if (missingProjectIds.length === 0) { + return { + importProjectIds: [], + projectIdOverrides: {}, + }; } const sourceProjectsById = new Map(input.sourceProjects.map((project) => [project.id, project])); @@ -1827,15 +1858,22 @@ async function promptForProjectMappings(input: { })); const mappings: Record = {}; + const importProjectIds = new Set(); 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 importSelectionValue = `__import__:${sourceProjectId}`; const selection = await p.select({ message: `Project "${sourceProject.name}" is missing in target. How should ${input.plan.issuePrefix} imports handle it?`, options: [ + { + value: importSelectionValue, + label: `Import ${sourceProject.name}`, + hint: "Create the project and copy its workspace settings", + }, ...(nameMatch ? [{ value: nameMatch.id, @@ -1855,10 +1893,17 @@ async function promptForProjectMappings(input: { if (p.isCancel(selection)) { throw new Error("Project mapping cancelled."); } + if (selection === importSelectionValue) { + importProjectIds.add(sourceProjectId); + continue; + } mappings[sourceProjectId] = selection; } - return mappings; + return { + importProjectIds: [...importProjectIds], + projectIdOverrides: mappings, + }; } export async function worktreeListCommand(opts: WorktreeListOptions): Promise { @@ -1976,6 +2021,77 @@ async function applyMergePlan(input: { const companyId = input.company.id; return await input.targetDb.transaction(async (tx) => { + const importedProjectIds = input.plan.projectImports.map((project) => project.source.id); + const existingImportedProjectIds = importedProjectIds.length > 0 + ? new Set( + (await tx + .select({ id: projects.id }) + .from(projects) + .where(inArray(projects.id, importedProjectIds))) + .map((row) => row.id), + ) + : new Set(); + const projectImports = input.plan.projectImports.filter((project) => !existingImportedProjectIds.has(project.source.id)); + const importedWorkspaceIds = projectImports.flatMap((project) => project.workspaces.map((workspace) => workspace.id)); + const existingImportedWorkspaceIds = importedWorkspaceIds.length > 0 + ? new Set( + (await tx + .select({ id: projectWorkspaces.id }) + .from(projectWorkspaces) + .where(inArray(projectWorkspaces.id, importedWorkspaceIds))) + .map((row) => row.id), + ) + : new Set(); + + let insertedProjects = 0; + let insertedProjectWorkspaces = 0; + for (const project of projectImports) { + await tx.insert(projects).values({ + id: project.source.id, + companyId, + goalId: project.targetGoalId, + name: project.source.name, + description: project.source.description, + status: project.source.status, + leadAgentId: project.targetLeadAgentId, + targetDate: project.source.targetDate, + color: project.source.color, + pauseReason: project.source.pauseReason, + pausedAt: project.source.pausedAt, + executionWorkspacePolicy: project.source.executionWorkspacePolicy, + archivedAt: project.source.archivedAt, + createdAt: project.source.createdAt, + updatedAt: project.source.updatedAt, + }); + insertedProjects += 1; + + for (const workspace of project.workspaces) { + if (existingImportedWorkspaceIds.has(workspace.id)) continue; + await tx.insert(projectWorkspaces).values({ + id: workspace.id, + companyId, + projectId: project.source.id, + name: workspace.name, + sourceType: workspace.sourceType, + cwd: workspace.cwd, + repoUrl: workspace.repoUrl, + repoRef: workspace.repoRef, + defaultRef: workspace.defaultRef, + visibility: workspace.visibility, + setupCommand: workspace.setupCommand, + cleanupCommand: workspace.cleanupCommand, + remoteProvider: workspace.remoteProvider, + remoteWorkspaceRef: workspace.remoteWorkspaceRef, + sharedWorkspaceKey: workspace.sharedWorkspaceKey, + metadata: workspace.metadata, + isPrimary: workspace.isPrimary, + createdAt: workspace.createdAt, + updatedAt: workspace.updatedAt, + }); + insertedProjectWorkspaces += 1; + } + } + const issueCandidates = input.plan.issuePlans.filter( (plan): plan is PlannedIssueInsert => plan.action === "insert", ); @@ -2274,6 +2390,8 @@ async function applyMergePlan(input: { } return { + insertedProjects, + insertedProjectWorkspaces, insertedIssues, insertedComments, insertedDocuments, @@ -2330,18 +2448,22 @@ export async function worktreeMergeHistoryCommand(sourceArg: string | undefined, scopes, }); if (!opts.yes) { - const projectIdOverrides = await promptForProjectMappings({ + const projectSelections = await promptForProjectMappings({ plan: collected.plan, sourceProjects: collected.sourceProjects, targetProjects: collected.targetProjects, }); - if (Object.keys(projectIdOverrides).length > 0) { + if ( + projectSelections.importProjectIds.length > 0 + || Object.keys(projectSelections.projectIdOverrides).length > 0 + ) { collected = await collectMergePlan({ sourceDb: sourceHandle.db, targetDb: targetHandle.db, company, scopes, - projectIdOverrides, + importProjectIds: projectSelections.importProjectIds, + projectIdOverrides: projectSelections.projectIdOverrides, }); } } @@ -2381,7 +2503,7 @@ export async function worktreeMergeHistoryCommand(sourceArg: string | undefined, } p.outro( pc.green( - `Imported ${applied.insertedIssues} issues, ${applied.insertedComments} comments, ${applied.insertedDocuments} documents (${applied.insertedDocumentRevisions} revisions, ${applied.mergedDocuments} merged), and ${applied.insertedAttachments} attachments into ${company.issuePrefix}.`, + `Imported ${applied.insertedProjects} projects (${applied.insertedProjectWorkspaces} workspaces), ${applied.insertedIssues} issues, ${applied.insertedComments} comments, ${applied.insertedDocuments} documents (${applied.insertedDocumentRevisions} revisions, ${applied.mergedDocuments} merged), and ${applied.insertedAttachments} attachments into ${company.issuePrefix}.`, ), ); } finally {