Add merge-history project import option
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
8b4850aaea
commit
5dfdbe91bb
3 changed files with 289 additions and 12 deletions
|
|
@ -115,6 +115,52 @@ function makeAttachment(overrides: Record<string, unknown> = {}) {
|
||||||
} as any;
|
} 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", () => {
|
describe("worktree merge history planner", () => {
|
||||||
it("parses default scopes", () => {
|
it("parses default scopes", () => {
|
||||||
expect(parseWorktreeMergeScopes(undefined)).toEqual(["issues", "comments"]);
|
expect(parseWorktreeMergeScopes(undefined)).toEqual(["issues", "comments"]);
|
||||||
|
|
@ -236,6 +282,60 @@ describe("worktree merge history planner", () => {
|
||||||
expect(insert.adjustments).toEqual(["clear_project_workspace"]);
|
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", () => {
|
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({
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ 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";
|
projectResolution: "preserved" | "cleared" | "mapped" | "imported";
|
||||||
mappedProjectName: string | null;
|
mappedProjectName: string | null;
|
||||||
adjustments: ImportAdjustment[];
|
adjustments: ImportAdjustment[];
|
||||||
};
|
};
|
||||||
|
|
@ -173,17 +173,26 @@ export type PlannedAttachmentSkip = {
|
||||||
action: "skip_existing" | "skip_missing_parent";
|
action: "skip_existing" | "skip_missing_parent";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PlannedProjectImport = {
|
||||||
|
source: ProjectRow;
|
||||||
|
targetLeadAgentId: string | null;
|
||||||
|
targetGoalId: string | null;
|
||||||
|
workspaces: ProjectWorkspaceRow[];
|
||||||
|
};
|
||||||
|
|
||||||
export type WorktreeMergePlan = {
|
export type WorktreeMergePlan = {
|
||||||
companyId: string;
|
companyId: string;
|
||||||
companyName: string;
|
companyName: string;
|
||||||
issuePrefix: string;
|
issuePrefix: string;
|
||||||
previewIssueCounterStart: number;
|
previewIssueCounterStart: number;
|
||||||
scopes: WorktreeMergeScope[];
|
scopes: WorktreeMergeScope[];
|
||||||
|
projectImports: PlannedProjectImport[];
|
||||||
issuePlans: Array<PlannedIssueInsert | PlannedIssueSkip>;
|
issuePlans: Array<PlannedIssueInsert | PlannedIssueSkip>;
|
||||||
commentPlans: Array<PlannedCommentInsert | PlannedCommentSkip>;
|
commentPlans: Array<PlannedCommentInsert | PlannedCommentSkip>;
|
||||||
documentPlans: Array<PlannedIssueDocumentInsert | PlannedIssueDocumentMerge | PlannedIssueDocumentSkip>;
|
documentPlans: Array<PlannedIssueDocumentInsert | PlannedIssueDocumentMerge | PlannedIssueDocumentSkip>;
|
||||||
attachmentPlans: Array<PlannedAttachmentInsert | PlannedAttachmentSkip>;
|
attachmentPlans: Array<PlannedAttachmentInsert | PlannedAttachmentSkip>;
|
||||||
counts: {
|
counts: {
|
||||||
|
projectsToImport: number;
|
||||||
issuesToInsert: number;
|
issuesToInsert: number;
|
||||||
issuesExisting: number;
|
issuesExisting: number;
|
||||||
issueDrift: number;
|
issueDrift: number;
|
||||||
|
|
@ -338,6 +347,8 @@ export function buildWorktreeMergePlan(input: {
|
||||||
targetIssues: IssueRow[];
|
targetIssues: IssueRow[];
|
||||||
sourceComments: CommentRow[];
|
sourceComments: CommentRow[];
|
||||||
targetComments: CommentRow[];
|
targetComments: CommentRow[];
|
||||||
|
sourceProjects?: ProjectRow[];
|
||||||
|
sourceProjectWorkspaces?: ProjectWorkspaceRow[];
|
||||||
sourceDocuments?: IssueDocumentRow[];
|
sourceDocuments?: IssueDocumentRow[];
|
||||||
targetDocuments?: IssueDocumentRow[];
|
targetDocuments?: IssueDocumentRow[];
|
||||||
sourceDocumentRevisions?: DocumentRevisionRow[];
|
sourceDocumentRevisions?: DocumentRevisionRow[];
|
||||||
|
|
@ -348,6 +359,7 @@ export function buildWorktreeMergePlan(input: {
|
||||||
targetProjects: ProjectRow[];
|
targetProjects: ProjectRow[];
|
||||||
targetProjectWorkspaces: ProjectWorkspaceRow[];
|
targetProjectWorkspaces: ProjectWorkspaceRow[];
|
||||||
targetGoals: GoalRow[];
|
targetGoals: GoalRow[];
|
||||||
|
importProjectIds?: Iterable<string>;
|
||||||
projectIdOverrides?: Record<string, string | null | undefined>;
|
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]));
|
||||||
|
|
@ -357,6 +369,10 @@ export function buildWorktreeMergePlan(input: {
|
||||||
const targetProjectsById = new Map(input.targetProjects.map((project) => [project.id, project]));
|
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 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 scopes = new Set(input.scopes);
|
||||||
|
|
||||||
const adjustmentCounts: Record<ImportAdjustment, number> = {
|
const adjustmentCounts: Record<ImportAdjustment, number> = {
|
||||||
|
|
@ -371,6 +387,34 @@ export function buildWorktreeMergePlan(input: {
|
||||||
clear_attachment_agent: 0,
|
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> = [];
|
const issuePlans: Array<PlannedIssueInsert | PlannedIssueSkip> = [];
|
||||||
let nextPreviewIssueNumber = input.previewIssueCounterStart;
|
let nextPreviewIssueNumber = input.previewIssueCounterStart;
|
||||||
for (const issue of sortIssuesForImport(input.sourceIssues)) {
|
for (const issue of sortIssuesForImport(input.sourceIssues)) {
|
||||||
|
|
@ -409,6 +453,14 @@ export function buildWorktreeMergePlan(input: {
|
||||||
projectResolution = "mapped";
|
projectResolution = "mapped";
|
||||||
mappedProjectName = targetProjectsById.get(overrideProjectId)?.name ?? null;
|
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) {
|
if (issue.projectId && !targetProjectId) {
|
||||||
adjustments.push("clear_project");
|
adjustments.push("clear_project");
|
||||||
incrementAdjustment(adjustmentCounts, "clear_project");
|
incrementAdjustment(adjustmentCounts, "clear_project");
|
||||||
|
|
@ -418,7 +470,8 @@ export function buildWorktreeMergePlan(input: {
|
||||||
targetProjectId
|
targetProjectId
|
||||||
&& targetProjectId === issue.projectId
|
&& targetProjectId === issue.projectId
|
||||||
&& issue.projectWorkspaceId
|
&& issue.projectWorkspaceId
|
||||||
&& targetProjectWorkspaceIds.has(issue.projectWorkspaceId)
|
&& (targetProjectWorkspaceIds.has(issue.projectWorkspaceId)
|
||||||
|
|| importedProjectWorkspaceIds.has(issue.projectWorkspaceId))
|
||||||
? issue.projectWorkspaceId
|
? issue.projectWorkspaceId
|
||||||
: null;
|
: null;
|
||||||
if (issue.projectWorkspaceId && !targetProjectWorkspaceId) {
|
if (issue.projectWorkspaceId && !targetProjectWorkspaceId) {
|
||||||
|
|
@ -672,6 +725,7 @@ export function buildWorktreeMergePlan(input: {
|
||||||
}
|
}
|
||||||
|
|
||||||
const counts = {
|
const counts = {
|
||||||
|
projectsToImport: projectImports.length,
|
||||||
issuesToInsert: issuePlans.filter((plan) => plan.action === "insert").length,
|
issuesToInsert: issuePlans.filter((plan) => plan.action === "insert").length,
|
||||||
issuesExisting: issuePlans.filter((plan) => plan.action === "skip_existing").length,
|
issuesExisting: issuePlans.filter((plan) => plan.action === "skip_existing").length,
|
||||||
issueDrift: issuePlans.filter((plan) => plan.action === "skip_existing" && plan.driftKeys.length > 0).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,
|
issuePrefix: input.issuePrefix,
|
||||||
previewIssueCounterStart: input.previewIssueCounterStart,
|
previewIssueCounterStart: input.previewIssueCounterStart,
|
||||||
scopes: input.scopes,
|
scopes: input.scopes,
|
||||||
|
projectImports,
|
||||||
issuePlans,
|
issuePlans,
|
||||||
commentPlans,
|
commentPlans,
|
||||||
documentPlans,
|
documentPlans,
|
||||||
|
|
|
||||||
|
|
@ -1488,20 +1488,34 @@ function renderMergePlan(plan: Awaited<ReturnType<typeof collectMergePlan>>["pla
|
||||||
`Target: ${extras.targetPath}`,
|
`Target: ${extras.targetPath}`,
|
||||||
`Company: ${plan.companyName} (${plan.issuePrefix})`,
|
`Company: ${plan.companyName} (${plan.issuePrefix})`,
|
||||||
"",
|
"",
|
||||||
|
"Projects",
|
||||||
|
`- import: ${plan.counts.projectsToImport}`,
|
||||||
|
"",
|
||||||
"Issues",
|
"Issues",
|
||||||
`- insert: ${plan.counts.issuesToInsert}`,
|
`- insert: ${plan.counts.issuesToInsert}`,
|
||||||
`- already present: ${plan.counts.issuesExisting}`,
|
`- already present: ${plan.counts.issuesExisting}`,
|
||||||
`- shared/imported issues with drift: ${plan.counts.issueDrift}`,
|
`- 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");
|
const issueInserts = plan.issuePlans.filter((item): item is PlannedIssueInsert => item.action === "insert");
|
||||||
if (issueInserts.length > 0) {
|
if (issueInserts.length > 0) {
|
||||||
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 =
|
const projectNote =
|
||||||
issue.projectResolution === "mapped" && issue.mappedProjectName
|
(issue.projectResolution === "mapped" || issue.projectResolution === "imported")
|
||||||
? ` project->${issue.mappedProjectName}`
|
&& issue.mappedProjectName
|
||||||
|
? ` project->${issue.projectResolution === "imported" ? "import:" : ""}${issue.mappedProjectName}`
|
||||||
: "";
|
: "";
|
||||||
const adjustments = issue.adjustments.length > 0 ? ` [${issue.adjustments.join(", ")}]` : "";
|
const adjustments = issue.adjustments.length > 0 ? ` [${issue.adjustments.join(", ")}]` : "";
|
||||||
const prefix = `- ${issue.source.identifier ?? issue.source.id} -> ${issue.previewIdentifier} (${issue.targetStatus}${projectNote})`;
|
const prefix = `- ${issue.source.identifier ?? issue.source.id} -> ${issue.previewIdentifier} (${issue.targetStatus}${projectNote})`;
|
||||||
|
|
@ -1562,6 +1576,7 @@ async function collectMergePlan(input: {
|
||||||
targetDb: ClosableDb;
|
targetDb: ClosableDb;
|
||||||
company: ResolvedMergeCompany;
|
company: ResolvedMergeCompany;
|
||||||
scopes: ReturnType<typeof parseWorktreeMergeScopes>;
|
scopes: ReturnType<typeof parseWorktreeMergeScopes>;
|
||||||
|
importProjectIds?: Iterable<string>;
|
||||||
projectIdOverrides?: Record<string, string | null | undefined>;
|
projectIdOverrides?: Record<string, string | null | undefined>;
|
||||||
}) {
|
}) {
|
||||||
const companyId = input.company.id;
|
const companyId = input.company.id;
|
||||||
|
|
@ -1578,6 +1593,7 @@ async function collectMergePlan(input: {
|
||||||
sourceAttachmentRows,
|
sourceAttachmentRows,
|
||||||
targetAttachmentRows,
|
targetAttachmentRows,
|
||||||
sourceProjectsRows,
|
sourceProjectsRows,
|
||||||
|
sourceProjectWorkspaceRows,
|
||||||
targetProjectsRows,
|
targetProjectsRows,
|
||||||
targetAgentsRows,
|
targetAgentsRows,
|
||||||
targetProjectWorkspaceRows,
|
targetProjectWorkspaceRows,
|
||||||
|
|
@ -1743,6 +1759,10 @@ async function collectMergePlan(input: {
|
||||||
.select()
|
.select()
|
||||||
.from(projects)
|
.from(projects)
|
||||||
.where(eq(projects.companyId, companyId)),
|
.where(eq(projects.companyId, companyId)),
|
||||||
|
input.sourceDb
|
||||||
|
.select()
|
||||||
|
.from(projectWorkspaces)
|
||||||
|
.where(eq(projectWorkspaces.companyId, companyId)),
|
||||||
input.targetDb
|
input.targetDb
|
||||||
.select()
|
.select()
|
||||||
.from(projects)
|
.from(projects)
|
||||||
|
|
@ -1779,6 +1799,8 @@ async function collectMergePlan(input: {
|
||||||
targetIssues: targetIssuesRows,
|
targetIssues: targetIssuesRows,
|
||||||
sourceComments: sourceCommentsRows,
|
sourceComments: sourceCommentsRows,
|
||||||
targetComments: targetCommentsRows,
|
targetComments: targetCommentsRows,
|
||||||
|
sourceProjects: sourceProjectsRows,
|
||||||
|
sourceProjectWorkspaces: sourceProjectWorkspaceRows,
|
||||||
sourceDocuments: sourceIssueDocumentsRows as IssueDocumentRow[],
|
sourceDocuments: sourceIssueDocumentsRows as IssueDocumentRow[],
|
||||||
targetDocuments: targetIssueDocumentsRows as IssueDocumentRow[],
|
targetDocuments: targetIssueDocumentsRows as IssueDocumentRow[],
|
||||||
sourceDocumentRevisions: sourceDocumentRevisionRows as DocumentRevisionRow[],
|
sourceDocumentRevisions: sourceDocumentRevisionRows as DocumentRevisionRow[],
|
||||||
|
|
@ -1789,6 +1811,7 @@ async function collectMergePlan(input: {
|
||||||
targetProjects: targetProjectsRows,
|
targetProjects: targetProjectsRows,
|
||||||
targetProjectWorkspaces: targetProjectWorkspaceRows,
|
targetProjectWorkspaces: targetProjectWorkspaceRows,
|
||||||
targetGoals: targetGoalsRows,
|
targetGoals: targetGoalsRows,
|
||||||
|
importProjectIds: input.importProjectIds,
|
||||||
projectIdOverrides: input.projectIdOverrides,
|
projectIdOverrides: input.projectIdOverrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1800,11 +1823,16 @@ async function collectMergePlan(input: {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProjectMappingSelections = {
|
||||||
|
importProjectIds: string[];
|
||||||
|
projectIdOverrides: Record<string, string | null>;
|
||||||
|
};
|
||||||
|
|
||||||
async function promptForProjectMappings(input: {
|
async function promptForProjectMappings(input: {
|
||||||
plan: Awaited<ReturnType<typeof collectMergePlan>>["plan"];
|
plan: Awaited<ReturnType<typeof collectMergePlan>>["plan"];
|
||||||
sourceProjects: Awaited<ReturnType<typeof collectMergePlan>>["sourceProjects"];
|
sourceProjects: Awaited<ReturnType<typeof collectMergePlan>>["sourceProjects"];
|
||||||
targetProjects: Awaited<ReturnType<typeof collectMergePlan>>["targetProjects"];
|
targetProjects: Awaited<ReturnType<typeof collectMergePlan>>["targetProjects"];
|
||||||
}): Promise<Record<string, string | null>> {
|
}): Promise<ProjectMappingSelections> {
|
||||||
const missingProjectIds = [
|
const missingProjectIds = [
|
||||||
...new Set(
|
...new Set(
|
||||||
input.plan.issuePlans
|
input.plan.issuePlans
|
||||||
|
|
@ -1813,8 +1841,11 @@ async function promptForProjectMappings(input: {
|
||||||
.map((plan) => plan.source.projectId as string),
|
.map((plan) => plan.source.projectId as string),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
if (missingProjectIds.length === 0 || input.targetProjects.length === 0) {
|
if (missingProjectIds.length === 0) {
|
||||||
return {};
|
return {
|
||||||
|
importProjectIds: [],
|
||||||
|
projectIdOverrides: {},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceProjectsById = new Map(input.sourceProjects.map((project) => [project.id, project]));
|
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 mappings: Record<string, string | null> = {};
|
||||||
|
const importProjectIds = new Set<string>();
|
||||||
for (const sourceProjectId of missingProjectIds) {
|
for (const sourceProjectId of missingProjectIds) {
|
||||||
const sourceProject = sourceProjectsById.get(sourceProjectId);
|
const sourceProject = sourceProjectsById.get(sourceProjectId);
|
||||||
if (!sourceProject) continue;
|
if (!sourceProject) continue;
|
||||||
const nameMatch = input.targetProjects.find(
|
const nameMatch = input.targetProjects.find(
|
||||||
(project) => project.name.trim().toLowerCase() === sourceProject.name.trim().toLowerCase(),
|
(project) => project.name.trim().toLowerCase() === sourceProject.name.trim().toLowerCase(),
|
||||||
);
|
);
|
||||||
|
const importSelectionValue = `__import__:${sourceProjectId}`;
|
||||||
const selection = await p.select<string | null>({
|
const selection = await p.select<string | null>({
|
||||||
message: `Project "${sourceProject.name}" is missing in target. How should ${input.plan.issuePrefix} imports handle it?`,
|
message: `Project "${sourceProject.name}" is missing in target. How should ${input.plan.issuePrefix} imports handle it?`,
|
||||||
options: [
|
options: [
|
||||||
|
{
|
||||||
|
value: importSelectionValue,
|
||||||
|
label: `Import ${sourceProject.name}`,
|
||||||
|
hint: "Create the project and copy its workspace settings",
|
||||||
|
},
|
||||||
...(nameMatch
|
...(nameMatch
|
||||||
? [{
|
? [{
|
||||||
value: nameMatch.id,
|
value: nameMatch.id,
|
||||||
|
|
@ -1855,10 +1893,17 @@ async function promptForProjectMappings(input: {
|
||||||
if (p.isCancel(selection)) {
|
if (p.isCancel(selection)) {
|
||||||
throw new Error("Project mapping cancelled.");
|
throw new Error("Project mapping cancelled.");
|
||||||
}
|
}
|
||||||
|
if (selection === importSelectionValue) {
|
||||||
|
importProjectIds.add(sourceProjectId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
mappings[sourceProjectId] = selection;
|
mappings[sourceProjectId] = selection;
|
||||||
}
|
}
|
||||||
|
|
||||||
return mappings;
|
return {
|
||||||
|
importProjectIds: [...importProjectIds],
|
||||||
|
projectIdOverrides: mappings,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function worktreeListCommand(opts: WorktreeListOptions): Promise<void> {
|
export async function worktreeListCommand(opts: WorktreeListOptions): Promise<void> {
|
||||||
|
|
@ -1976,6 +2021,77 @@ async function applyMergePlan(input: {
|
||||||
const companyId = input.company.id;
|
const companyId = input.company.id;
|
||||||
|
|
||||||
return await input.targetDb.transaction(async (tx) => {
|
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(
|
const issueCandidates = input.plan.issuePlans.filter(
|
||||||
(plan): plan is PlannedIssueInsert => plan.action === "insert",
|
(plan): plan is PlannedIssueInsert => plan.action === "insert",
|
||||||
);
|
);
|
||||||
|
|
@ -2274,6 +2390,8 @@ async function applyMergePlan(input: {
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
insertedProjects,
|
||||||
|
insertedProjectWorkspaces,
|
||||||
insertedIssues,
|
insertedIssues,
|
||||||
insertedComments,
|
insertedComments,
|
||||||
insertedDocuments,
|
insertedDocuments,
|
||||||
|
|
@ -2330,18 +2448,22 @@ export async function worktreeMergeHistoryCommand(sourceArg: string | undefined,
|
||||||
scopes,
|
scopes,
|
||||||
});
|
});
|
||||||
if (!opts.yes) {
|
if (!opts.yes) {
|
||||||
const projectIdOverrides = await promptForProjectMappings({
|
const projectSelections = await promptForProjectMappings({
|
||||||
plan: collected.plan,
|
plan: collected.plan,
|
||||||
sourceProjects: collected.sourceProjects,
|
sourceProjects: collected.sourceProjects,
|
||||||
targetProjects: collected.targetProjects,
|
targetProjects: collected.targetProjects,
|
||||||
});
|
});
|
||||||
if (Object.keys(projectIdOverrides).length > 0) {
|
if (
|
||||||
|
projectSelections.importProjectIds.length > 0
|
||||||
|
|| Object.keys(projectSelections.projectIdOverrides).length > 0
|
||||||
|
) {
|
||||||
collected = await collectMergePlan({
|
collected = await collectMergePlan({
|
||||||
sourceDb: sourceHandle.db,
|
sourceDb: sourceHandle.db,
|
||||||
targetDb: targetHandle.db,
|
targetDb: targetHandle.db,
|
||||||
company,
|
company,
|
||||||
scopes,
|
scopes,
|
||||||
projectIdOverrides,
|
importProjectIds: projectSelections.importProjectIds,
|
||||||
|
projectIdOverrides: projectSelections.projectIdOverrides,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2381,7 +2503,7 @@ export async function worktreeMergeHistoryCommand(sourceArg: string | undefined,
|
||||||
}
|
}
|
||||||
p.outro(
|
p.outro(
|
||||||
pc.green(
|
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 {
|
} finally {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue