Add project mapping prompts for worktree imports
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user