Add merge-history project import option

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-03-21 17:09:46 -05:00
parent 8b4850aaea
commit 5dfdbe91bb
3 changed files with 289 additions and 12 deletions

View File

@@ -115,6 +115,52 @@ function makeAttachment(overrides: Record<string, unknown> = {}) {
} as any;
}
function makeProject(overrides: Record<string, unknown> = {}) {
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<string, unknown> = {}) {
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({

View File

@@ -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<PlannedIssueInsert | PlannedIssueSkip>;
commentPlans: Array<PlannedCommentInsert | PlannedCommentSkip>;
documentPlans: Array<PlannedIssueDocumentInsert | PlannedIssueDocumentMerge | PlannedIssueDocumentSkip>;
attachmentPlans: Array<PlannedAttachmentInsert | PlannedAttachmentSkip>;
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<string>;
projectIdOverrides?: Record<string, string | null | undefined>;
}): 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<ImportAdjustment, number> = {
@@ -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<PlannedIssueInsert | PlannedIssueSkip> = [];
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,

View File

@@ -1488,20 +1488,34 @@ function renderMergePlan(plan: Awaited<ReturnType<typeof collectMergePlan>>["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<typeof parseWorktreeMergeScopes>;
importProjectIds?: Iterable<string>;
projectIdOverrides?: Record<string, string | null | undefined>;
}) {
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<string, string | null>;
};
async function promptForProjectMappings(input: {
plan: Awaited<ReturnType<typeof collectMergePlan>>["plan"];
sourceProjects: Awaited<ReturnType<typeof collectMergePlan>>["sourceProjects"];
targetProjects: Awaited<ReturnType<typeof collectMergePlan>>["targetProjects"];
}): Promise<Record<string, string | null>> {
}): Promise<ProjectMappingSelections> {
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<string, string | null> = {};
const importProjectIds = new Set<string>();
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<string | null>({
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<void> {
@@ -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<string>();
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<string>();
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 {