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", () => {
|
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({
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user