Add project mapping prompts for worktree imports

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-03-20 15:13:35 -05:00
parent 0ec79d4295
commit 220a5ec5dd
3 changed files with 149 additions and 7 deletions

View File

@@ -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", () => { it("imports comments onto shared or newly imported issues while skipping existing comments", () => {
const sharedIssue = makeIssue({ id: "issue-a", identifier: "PAP-10" }); const sharedIssue = makeIssue({ id: "issue-a", identifier: "PAP-10" });
const newIssue = makeIssue({ const newIssue = makeIssue({

View File

@@ -39,6 +39,8 @@ export type PlannedIssueInsert = {
targetProjectId: string | null; targetProjectId: string | null;
targetProjectWorkspaceId: string | null; targetProjectWorkspaceId: string | null;
targetGoalId: string | null; targetGoalId: string | null;
projectResolution: "preserved" | "cleared" | "mapped";
mappedProjectName: string | null;
adjustments: ImportAdjustment[]; adjustments: ImportAdjustment[];
}; };
@@ -172,11 +174,13 @@ export function buildWorktreeMergePlan(input: {
targetProjects: ProjectRow[]; targetProjects: ProjectRow[];
targetProjectWorkspaces: ProjectWorkspaceRow[]; targetProjectWorkspaces: ProjectWorkspaceRow[];
targetGoals: GoalRow[]; targetGoals: GoalRow[];
projectIdOverrides?: Record<string, string | null | undefined>;
}): WorktreeMergePlan { }): WorktreeMergePlan {
const targetIssuesById = new Map(input.targetIssues.map((issue) => [issue.id, issue])); const targetIssuesById = new Map(input.targetIssues.map((issue) => [issue.id, issue]));
const targetCommentIds = new Set(input.targetComments.map((comment) => comment.id)); const targetCommentIds = new Set(input.targetComments.map((comment) => comment.id));
const targetAgentIds = new Set(input.targetAgents.map((agent) => agent.id)); const targetAgentIds = new Set(input.targetAgents.map((agent) => agent.id));
const targetProjectIds = new Set(input.targetProjects.map((project) => project.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 targetProjectWorkspaceIds = new Set(input.targetProjectWorkspaces.map((workspace) => workspace.id));
const targetGoalIds = new Set(input.targetGoals.map((goal) => goal.id)); const targetGoalIds = new Set(input.targetGoals.map((goal) => goal.id));
const scopes = new Set(input.scopes); const scopes = new Set(input.scopes);
@@ -215,8 +219,19 @@ export function buildWorktreeMergePlan(input: {
const targetCreatedByAgentId = const targetCreatedByAgentId =
issue.createdByAgentId && targetAgentIds.has(issue.createdByAgentId) ? issue.createdByAgentId : null; issue.createdByAgentId && targetAgentIds.has(issue.createdByAgentId) ? issue.createdByAgentId : null;
const targetProjectId = let targetProjectId =
issue.projectId && targetProjectIds.has(issue.projectId) ? issue.projectId : null; 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) { if (issue.projectId && !targetProjectId) {
adjustments.push("clear_project"); adjustments.push("clear_project");
incrementAdjustment(adjustmentCounts, "clear_project"); incrementAdjustment(adjustmentCounts, "clear_project");
@@ -224,6 +239,7 @@ export function buildWorktreeMergePlan(input: {
const targetProjectWorkspaceId = const targetProjectWorkspaceId =
targetProjectId targetProjectId
&& targetProjectId === issue.projectId
&& issue.projectWorkspaceId && issue.projectWorkspaceId
&& targetProjectWorkspaceIds.has(issue.projectWorkspaceId) && targetProjectWorkspaceIds.has(issue.projectWorkspaceId)
? issue.projectWorkspaceId ? issue.projectWorkspaceId
@@ -262,6 +278,8 @@ export function buildWorktreeMergePlan(input: {
targetProjectId, targetProjectId,
targetProjectWorkspaceId, targetProjectWorkspaceId,
targetGoalId, targetGoalId,
projectResolution,
mappedProjectName,
adjustments, adjustments,
}); });
} }

View File

@@ -1224,9 +1224,13 @@ function renderMergePlan(plan: Awaited<ReturnType<typeof collectMergePlan>>["pla
lines.push(""); lines.push("");
lines.push("Planned issue imports"); lines.push("Planned issue imports");
for (const issue of issueInserts) { for (const issue of issueInserts) {
const projectNote =
issue.projectResolution === "mapped" && issue.mappedProjectName
? ` project->${issue.mappedProjectName}`
: "";
const adjustments = issue.adjustments.length > 0 ? ` [${issue.adjustments.join(", ")}]` : ""; const adjustments = issue.adjustments.length > 0 ? ` [${issue.adjustments.join(", ")}]` : "";
lines.push( 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; targetDb: ClosableDb;
company: ResolvedMergeCompany; company: ResolvedMergeCompany;
scopes: ReturnType<typeof parseWorktreeMergeScopes>; scopes: ReturnType<typeof parseWorktreeMergeScopes>;
projectIdOverrides?: Record<string, string | null | undefined>;
}) { }) {
const companyId = input.company.id; 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 input.targetDb
.select({ .select({
issueCounter: companies.issueCounter, issueCounter: companies.issueCounter,
@@ -1293,14 +1298,18 @@ async function collectMergePlan(input: {
.from(issueComments) .from(issueComments)
.where(eq(issueComments.companyId, companyId)) .where(eq(issueComments.companyId, companyId))
: Promise.resolve([]), : Promise.resolve([]),
input.targetDb input.sourceDb
.select() .select()
.from(agents) .from(projects)
.where(eq(agents.companyId, companyId)), .where(eq(projects.companyId, companyId)),
input.targetDb input.targetDb
.select() .select()
.from(projects) .from(projects)
.where(eq(projects.companyId, companyId)), .where(eq(projects.companyId, companyId)),
input.targetDb
.select()
.from(agents)
.where(eq(agents.companyId, companyId)),
input.targetDb input.targetDb
.select() .select()
.from(projectWorkspaces) .from(projectWorkspaces)
@@ -1338,15 +1347,79 @@ async function collectMergePlan(input: {
targetProjects: targetProjectsRows, targetProjects: targetProjectsRows,
targetProjectWorkspaces: targetProjectWorkspaceRows, targetProjectWorkspaces: targetProjectWorkspaceRows,
targetGoals: targetGoalsRows, targetGoals: targetGoalsRows,
projectIdOverrides: input.projectIdOverrides,
}); });
return { return {
plan, plan,
sourceProjects: sourceProjectsRows,
targetProjects: targetProjectsRows,
unsupportedRunCount: runCountRows[0]?.count ?? 0, unsupportedRunCount: runCountRows[0]?.count ?? 0,
unsupportedDocumentCount: documentCountRows[0]?.count ?? 0, unsupportedDocumentCount: documentCountRows[0]?.count ?? 0,
}; };
} }
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>> {
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<string, string | null> = {};
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<string | null>({
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: { async function applyMergePlan(input: {
targetDb: ClosableDb; targetDb: ClosableDb;
company: ResolvedMergeCompany; company: ResolvedMergeCompany;
@@ -1490,12 +1563,28 @@ export async function worktreeMergeHistoryCommand(sourceArg: string, opts: Workt
targetDb: targetHandle.db, targetDb: targetHandle.db,
selector: opts.company, selector: opts.company,
}); });
const collected = await collectMergePlan({ let collected = await collectMergePlan({
sourceDb: sourceHandle.db, sourceDb: sourceHandle.db,
targetDb: targetHandle.db, targetDb: targetHandle.db,
company, company,
scopes, 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, { console.log(renderMergePlan(collected.plan, {
sourcePath: sourceRoot, sourcePath: sourceRoot,