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", () => {
const sharedIssue = makeIssue({ id: "issue-a", identifier: "PAP-10" });
const newIssue = makeIssue({

View File

@@ -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<string, string | null | undefined>;
}): 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,
});
}

View File

@@ -1224,9 +1224,13 @@ function renderMergePlan(plan: Awaited<ReturnType<typeof collectMergePlan>>["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<typeof parseWorktreeMergeScopes>;
projectIdOverrides?: Record<string, string | null | undefined>;
}) {
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<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: {
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,