710 lines
26 KiB
TypeScript
710 lines
26 KiB
TypeScript
import {
|
|
agents,
|
|
assets,
|
|
documentRevisions,
|
|
goals,
|
|
issueAttachments,
|
|
issueComments,
|
|
issueDocuments,
|
|
issues,
|
|
projects,
|
|
projectWorkspaces,
|
|
} from "@paperclipai/db";
|
|
|
|
type IssueRow = typeof issues.$inferSelect;
|
|
type CommentRow = typeof issueComments.$inferSelect;
|
|
type AgentRow = typeof agents.$inferSelect;
|
|
type ProjectRow = typeof projects.$inferSelect;
|
|
type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect;
|
|
type GoalRow = typeof goals.$inferSelect;
|
|
type IssueDocumentLinkRow = typeof issueDocuments.$inferSelect;
|
|
type DocumentRevisionTableRow = typeof documentRevisions.$inferSelect;
|
|
type IssueAttachmentTableRow = typeof issueAttachments.$inferSelect;
|
|
type AssetRow = typeof assets.$inferSelect;
|
|
|
|
export const WORKTREE_MERGE_SCOPES = ["issues", "comments"] as const;
|
|
export type WorktreeMergeScope = (typeof WORKTREE_MERGE_SCOPES)[number];
|
|
|
|
export type ImportAdjustment =
|
|
| "clear_assignee_agent"
|
|
| "clear_project"
|
|
| "clear_project_workspace"
|
|
| "clear_goal"
|
|
| "clear_author_agent"
|
|
| "coerce_in_progress_to_todo"
|
|
| "clear_document_agent"
|
|
| "clear_document_revision_agent"
|
|
| "clear_attachment_agent";
|
|
|
|
export type IssueMergeAction = "skip_existing" | "insert";
|
|
export type CommentMergeAction = "skip_existing" | "skip_missing_parent" | "insert";
|
|
|
|
export type PlannedIssueInsert = {
|
|
source: IssueRow;
|
|
action: "insert";
|
|
previewIssueNumber: number;
|
|
previewIdentifier: string;
|
|
targetStatus: string;
|
|
targetAssigneeAgentId: string | null;
|
|
targetCreatedByAgentId: string | null;
|
|
targetProjectId: string | null;
|
|
targetProjectWorkspaceId: string | null;
|
|
targetGoalId: string | null;
|
|
projectResolution: "preserved" | "cleared" | "mapped";
|
|
mappedProjectName: string | null;
|
|
adjustments: ImportAdjustment[];
|
|
};
|
|
|
|
export type PlannedIssueSkip = {
|
|
source: IssueRow;
|
|
action: "skip_existing";
|
|
driftKeys: string[];
|
|
};
|
|
|
|
export type PlannedCommentInsert = {
|
|
source: CommentRow;
|
|
action: "insert";
|
|
targetAuthorAgentId: string | null;
|
|
adjustments: ImportAdjustment[];
|
|
};
|
|
|
|
export type PlannedCommentSkip = {
|
|
source: CommentRow;
|
|
action: "skip_existing" | "skip_missing_parent";
|
|
};
|
|
|
|
export type IssueDocumentRow = {
|
|
id: IssueDocumentLinkRow["id"];
|
|
companyId: IssueDocumentLinkRow["companyId"];
|
|
issueId: IssueDocumentLinkRow["issueId"];
|
|
documentId: IssueDocumentLinkRow["documentId"];
|
|
key: IssueDocumentLinkRow["key"];
|
|
linkCreatedAt: IssueDocumentLinkRow["createdAt"];
|
|
linkUpdatedAt: IssueDocumentLinkRow["updatedAt"];
|
|
title: string | null;
|
|
format: string;
|
|
latestBody: string;
|
|
latestRevisionId: string | null;
|
|
latestRevisionNumber: number;
|
|
createdByAgentId: string | null;
|
|
createdByUserId: string | null;
|
|
updatedByAgentId: string | null;
|
|
updatedByUserId: string | null;
|
|
documentCreatedAt: Date;
|
|
documentUpdatedAt: Date;
|
|
};
|
|
|
|
export type DocumentRevisionRow = {
|
|
id: DocumentRevisionTableRow["id"];
|
|
companyId: DocumentRevisionTableRow["companyId"];
|
|
documentId: DocumentRevisionTableRow["documentId"];
|
|
revisionNumber: DocumentRevisionTableRow["revisionNumber"];
|
|
body: DocumentRevisionTableRow["body"];
|
|
changeSummary: DocumentRevisionTableRow["changeSummary"];
|
|
createdByAgentId: string | null;
|
|
createdByUserId: string | null;
|
|
createdAt: Date;
|
|
};
|
|
|
|
export type IssueAttachmentRow = {
|
|
id: IssueAttachmentTableRow["id"];
|
|
companyId: IssueAttachmentTableRow["companyId"];
|
|
issueId: IssueAttachmentTableRow["issueId"];
|
|
issueCommentId: IssueAttachmentTableRow["issueCommentId"];
|
|
assetId: IssueAttachmentTableRow["assetId"];
|
|
provider: AssetRow["provider"];
|
|
objectKey: AssetRow["objectKey"];
|
|
contentType: AssetRow["contentType"];
|
|
byteSize: AssetRow["byteSize"];
|
|
sha256: AssetRow["sha256"];
|
|
originalFilename: AssetRow["originalFilename"];
|
|
createdByAgentId: string | null;
|
|
createdByUserId: string | null;
|
|
assetCreatedAt: Date;
|
|
assetUpdatedAt: Date;
|
|
attachmentCreatedAt: Date;
|
|
attachmentUpdatedAt: Date;
|
|
};
|
|
|
|
export type PlannedDocumentRevisionInsert = {
|
|
source: DocumentRevisionRow;
|
|
targetRevisionNumber: number;
|
|
targetCreatedByAgentId: string | null;
|
|
adjustments: ImportAdjustment[];
|
|
};
|
|
|
|
export type PlannedIssueDocumentInsert = {
|
|
source: IssueDocumentRow;
|
|
action: "insert";
|
|
targetCreatedByAgentId: string | null;
|
|
targetUpdatedByAgentId: string | null;
|
|
latestRevisionId: string | null;
|
|
latestRevisionNumber: number;
|
|
revisionsToInsert: PlannedDocumentRevisionInsert[];
|
|
adjustments: ImportAdjustment[];
|
|
};
|
|
|
|
export type PlannedIssueDocumentMerge = {
|
|
source: IssueDocumentRow;
|
|
action: "merge_existing";
|
|
targetCreatedByAgentId: string | null;
|
|
targetUpdatedByAgentId: string | null;
|
|
latestRevisionId: string | null;
|
|
latestRevisionNumber: number;
|
|
revisionsToInsert: PlannedDocumentRevisionInsert[];
|
|
adjustments: ImportAdjustment[];
|
|
};
|
|
|
|
export type PlannedIssueDocumentSkip = {
|
|
source: IssueDocumentRow;
|
|
action: "skip_existing" | "skip_missing_parent" | "skip_conflicting_key";
|
|
};
|
|
|
|
export type PlannedAttachmentInsert = {
|
|
source: IssueAttachmentRow;
|
|
action: "insert";
|
|
targetIssueCommentId: string | null;
|
|
targetCreatedByAgentId: string | null;
|
|
adjustments: ImportAdjustment[];
|
|
};
|
|
|
|
export type PlannedAttachmentSkip = {
|
|
source: IssueAttachmentRow;
|
|
action: "skip_existing" | "skip_missing_parent";
|
|
};
|
|
|
|
export type WorktreeMergePlan = {
|
|
companyId: string;
|
|
companyName: string;
|
|
issuePrefix: string;
|
|
previewIssueCounterStart: number;
|
|
scopes: WorktreeMergeScope[];
|
|
issuePlans: Array<PlannedIssueInsert | PlannedIssueSkip>;
|
|
commentPlans: Array<PlannedCommentInsert | PlannedCommentSkip>;
|
|
documentPlans: Array<PlannedIssueDocumentInsert | PlannedIssueDocumentMerge | PlannedIssueDocumentSkip>;
|
|
attachmentPlans: Array<PlannedAttachmentInsert | PlannedAttachmentSkip>;
|
|
counts: {
|
|
issuesToInsert: number;
|
|
issuesExisting: number;
|
|
issueDrift: number;
|
|
commentsToInsert: number;
|
|
commentsExisting: number;
|
|
commentsMissingParent: number;
|
|
documentsToInsert: number;
|
|
documentsToMerge: number;
|
|
documentsExisting: number;
|
|
documentsConflictingKey: number;
|
|
documentsMissingParent: number;
|
|
documentRevisionsToInsert: number;
|
|
attachmentsToInsert: number;
|
|
attachmentsExisting: number;
|
|
attachmentsMissingParent: number;
|
|
};
|
|
adjustments: Record<ImportAdjustment, number>;
|
|
};
|
|
|
|
function compareIssueCoreFields(source: IssueRow, target: IssueRow): string[] {
|
|
const driftKeys: string[] = [];
|
|
if (source.title !== target.title) driftKeys.push("title");
|
|
if ((source.description ?? null) !== (target.description ?? null)) driftKeys.push("description");
|
|
if (source.status !== target.status) driftKeys.push("status");
|
|
if (source.priority !== target.priority) driftKeys.push("priority");
|
|
if ((source.parentId ?? null) !== (target.parentId ?? null)) driftKeys.push("parentId");
|
|
if ((source.projectId ?? null) !== (target.projectId ?? null)) driftKeys.push("projectId");
|
|
if ((source.projectWorkspaceId ?? null) !== (target.projectWorkspaceId ?? null)) driftKeys.push("projectWorkspaceId");
|
|
if ((source.goalId ?? null) !== (target.goalId ?? null)) driftKeys.push("goalId");
|
|
if ((source.assigneeAgentId ?? null) !== (target.assigneeAgentId ?? null)) driftKeys.push("assigneeAgentId");
|
|
if ((source.assigneeUserId ?? null) !== (target.assigneeUserId ?? null)) driftKeys.push("assigneeUserId");
|
|
return driftKeys;
|
|
}
|
|
|
|
function incrementAdjustment(
|
|
counts: Record<ImportAdjustment, number>,
|
|
adjustment: ImportAdjustment,
|
|
): void {
|
|
counts[adjustment] += 1;
|
|
}
|
|
|
|
function groupBy<T>(rows: T[], keyFor: (row: T) => string): Map<string, T[]> {
|
|
const out = new Map<string, T[]>();
|
|
for (const row of rows) {
|
|
const key = keyFor(row);
|
|
const existing = out.get(key);
|
|
if (existing) {
|
|
existing.push(row);
|
|
} else {
|
|
out.set(key, [row]);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function sameDate(left: Date, right: Date): boolean {
|
|
return left.getTime() === right.getTime();
|
|
}
|
|
|
|
function sortDocumentRows(rows: IssueDocumentRow[]): IssueDocumentRow[] {
|
|
return [...rows].sort((left, right) => {
|
|
const createdDelta = left.documentCreatedAt.getTime() - right.documentCreatedAt.getTime();
|
|
if (createdDelta !== 0) return createdDelta;
|
|
const linkDelta = left.linkCreatedAt.getTime() - right.linkCreatedAt.getTime();
|
|
if (linkDelta !== 0) return linkDelta;
|
|
return left.documentId.localeCompare(right.documentId);
|
|
});
|
|
}
|
|
|
|
function sortDocumentRevisions(rows: DocumentRevisionRow[]): DocumentRevisionRow[] {
|
|
return [...rows].sort((left, right) => {
|
|
const revisionDelta = left.revisionNumber - right.revisionNumber;
|
|
if (revisionDelta !== 0) return revisionDelta;
|
|
const createdDelta = left.createdAt.getTime() - right.createdAt.getTime();
|
|
if (createdDelta !== 0) return createdDelta;
|
|
return left.id.localeCompare(right.id);
|
|
});
|
|
}
|
|
|
|
function sortAttachments(rows: IssueAttachmentRow[]): IssueAttachmentRow[] {
|
|
return [...rows].sort((left, right) => {
|
|
const createdDelta = left.attachmentCreatedAt.getTime() - right.attachmentCreatedAt.getTime();
|
|
if (createdDelta !== 0) return createdDelta;
|
|
return left.id.localeCompare(right.id);
|
|
});
|
|
}
|
|
|
|
function sortIssuesForImport(sourceIssues: IssueRow[]): IssueRow[] {
|
|
const byId = new Map(sourceIssues.map((issue) => [issue.id, issue]));
|
|
const memoDepth = new Map<string, number>();
|
|
|
|
const depthFor = (issue: IssueRow, stack = new Set<string>()): number => {
|
|
const memoized = memoDepth.get(issue.id);
|
|
if (memoized !== undefined) return memoized;
|
|
if (!issue.parentId) {
|
|
memoDepth.set(issue.id, 0);
|
|
return 0;
|
|
}
|
|
if (stack.has(issue.id)) {
|
|
memoDepth.set(issue.id, 0);
|
|
return 0;
|
|
}
|
|
const parent = byId.get(issue.parentId);
|
|
if (!parent) {
|
|
memoDepth.set(issue.id, 0);
|
|
return 0;
|
|
}
|
|
stack.add(issue.id);
|
|
const depth = depthFor(parent, stack) + 1;
|
|
stack.delete(issue.id);
|
|
memoDepth.set(issue.id, depth);
|
|
return depth;
|
|
};
|
|
|
|
return [...sourceIssues].sort((left, right) => {
|
|
const depthDelta = depthFor(left) - depthFor(right);
|
|
if (depthDelta !== 0) return depthDelta;
|
|
const createdDelta = left.createdAt.getTime() - right.createdAt.getTime();
|
|
if (createdDelta !== 0) return createdDelta;
|
|
return left.id.localeCompare(right.id);
|
|
});
|
|
}
|
|
|
|
export function parseWorktreeMergeScopes(rawValue: string | undefined): WorktreeMergeScope[] {
|
|
if (!rawValue || rawValue.trim().length === 0) {
|
|
return ["issues", "comments"];
|
|
}
|
|
|
|
const parsed = rawValue
|
|
.split(",")
|
|
.map((value) => value.trim().toLowerCase())
|
|
.filter((value): value is WorktreeMergeScope =>
|
|
(WORKTREE_MERGE_SCOPES as readonly string[]).includes(value),
|
|
);
|
|
|
|
if (parsed.length === 0) {
|
|
throw new Error(
|
|
`Invalid scope "${rawValue}". Expected a comma-separated list of: ${WORKTREE_MERGE_SCOPES.join(", ")}.`,
|
|
);
|
|
}
|
|
|
|
return [...new Set(parsed)];
|
|
}
|
|
|
|
export function buildWorktreeMergePlan(input: {
|
|
companyId: string;
|
|
companyName: string;
|
|
issuePrefix: string;
|
|
previewIssueCounterStart: number;
|
|
scopes: WorktreeMergeScope[];
|
|
sourceIssues: IssueRow[];
|
|
targetIssues: IssueRow[];
|
|
sourceComments: CommentRow[];
|
|
targetComments: CommentRow[];
|
|
sourceDocuments?: IssueDocumentRow[];
|
|
targetDocuments?: IssueDocumentRow[];
|
|
sourceDocumentRevisions?: DocumentRevisionRow[];
|
|
targetDocumentRevisions?: DocumentRevisionRow[];
|
|
sourceAttachments?: IssueAttachmentRow[];
|
|
targetAttachments?: IssueAttachmentRow[];
|
|
targetAgents: AgentRow[];
|
|
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);
|
|
|
|
const adjustmentCounts: Record<ImportAdjustment, number> = {
|
|
clear_assignee_agent: 0,
|
|
clear_project: 0,
|
|
clear_project_workspace: 0,
|
|
clear_goal: 0,
|
|
clear_author_agent: 0,
|
|
coerce_in_progress_to_todo: 0,
|
|
clear_document_agent: 0,
|
|
clear_document_revision_agent: 0,
|
|
clear_attachment_agent: 0,
|
|
};
|
|
|
|
const issuePlans: Array<PlannedIssueInsert | PlannedIssueSkip> = [];
|
|
let nextPreviewIssueNumber = input.previewIssueCounterStart;
|
|
for (const issue of sortIssuesForImport(input.sourceIssues)) {
|
|
const existing = targetIssuesById.get(issue.id);
|
|
if (existing) {
|
|
issuePlans.push({
|
|
source: issue,
|
|
action: "skip_existing",
|
|
driftKeys: compareIssueCoreFields(issue, existing),
|
|
});
|
|
continue;
|
|
}
|
|
|
|
nextPreviewIssueNumber += 1;
|
|
const adjustments: ImportAdjustment[] = [];
|
|
const targetAssigneeAgentId =
|
|
issue.assigneeAgentId && targetAgentIds.has(issue.assigneeAgentId) ? issue.assigneeAgentId : null;
|
|
if (issue.assigneeAgentId && !targetAssigneeAgentId) {
|
|
adjustments.push("clear_assignee_agent");
|
|
incrementAdjustment(adjustmentCounts, "clear_assignee_agent");
|
|
}
|
|
|
|
const targetCreatedByAgentId =
|
|
issue.createdByAgentId && targetAgentIds.has(issue.createdByAgentId) ? issue.createdByAgentId : null;
|
|
|
|
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");
|
|
}
|
|
|
|
const targetProjectWorkspaceId =
|
|
targetProjectId
|
|
&& targetProjectId === issue.projectId
|
|
&& issue.projectWorkspaceId
|
|
&& targetProjectWorkspaceIds.has(issue.projectWorkspaceId)
|
|
? issue.projectWorkspaceId
|
|
: null;
|
|
if (issue.projectWorkspaceId && !targetProjectWorkspaceId) {
|
|
adjustments.push("clear_project_workspace");
|
|
incrementAdjustment(adjustmentCounts, "clear_project_workspace");
|
|
}
|
|
|
|
const targetGoalId =
|
|
issue.goalId && targetGoalIds.has(issue.goalId) ? issue.goalId : null;
|
|
if (issue.goalId && !targetGoalId) {
|
|
adjustments.push("clear_goal");
|
|
incrementAdjustment(adjustmentCounts, "clear_goal");
|
|
}
|
|
|
|
let targetStatus = issue.status;
|
|
if (
|
|
targetStatus === "in_progress"
|
|
&& !targetAssigneeAgentId
|
|
&& !(issue.assigneeUserId && issue.assigneeUserId.trim().length > 0)
|
|
) {
|
|
targetStatus = "todo";
|
|
adjustments.push("coerce_in_progress_to_todo");
|
|
incrementAdjustment(adjustmentCounts, "coerce_in_progress_to_todo");
|
|
}
|
|
|
|
issuePlans.push({
|
|
source: issue,
|
|
action: "insert",
|
|
previewIssueNumber: nextPreviewIssueNumber,
|
|
previewIdentifier: `${input.issuePrefix}-${nextPreviewIssueNumber}`,
|
|
targetStatus,
|
|
targetAssigneeAgentId,
|
|
targetCreatedByAgentId,
|
|
targetProjectId,
|
|
targetProjectWorkspaceId,
|
|
targetGoalId,
|
|
projectResolution,
|
|
mappedProjectName,
|
|
adjustments,
|
|
});
|
|
}
|
|
|
|
const issueIdsAvailableAfterImport = new Set<string>([
|
|
...input.targetIssues.map((issue) => issue.id),
|
|
...issuePlans.filter((plan): plan is PlannedIssueInsert => plan.action === "insert").map((plan) => plan.source.id),
|
|
]);
|
|
|
|
const commentPlans: Array<PlannedCommentInsert | PlannedCommentSkip> = [];
|
|
if (scopes.has("comments")) {
|
|
const sortedComments = [...input.sourceComments].sort((left, right) => {
|
|
const createdDelta = left.createdAt.getTime() - right.createdAt.getTime();
|
|
if (createdDelta !== 0) return createdDelta;
|
|
return left.id.localeCompare(right.id);
|
|
});
|
|
|
|
for (const comment of sortedComments) {
|
|
if (targetCommentIds.has(comment.id)) {
|
|
commentPlans.push({ source: comment, action: "skip_existing" });
|
|
continue;
|
|
}
|
|
if (!issueIdsAvailableAfterImport.has(comment.issueId)) {
|
|
commentPlans.push({ source: comment, action: "skip_missing_parent" });
|
|
continue;
|
|
}
|
|
|
|
const adjustments: ImportAdjustment[] = [];
|
|
const targetAuthorAgentId =
|
|
comment.authorAgentId && targetAgentIds.has(comment.authorAgentId) ? comment.authorAgentId : null;
|
|
if (comment.authorAgentId && !targetAuthorAgentId) {
|
|
adjustments.push("clear_author_agent");
|
|
incrementAdjustment(adjustmentCounts, "clear_author_agent");
|
|
}
|
|
|
|
commentPlans.push({
|
|
source: comment,
|
|
action: "insert",
|
|
targetAuthorAgentId,
|
|
adjustments,
|
|
});
|
|
}
|
|
}
|
|
|
|
const sourceDocuments = input.sourceDocuments ?? [];
|
|
const targetDocuments = input.targetDocuments ?? [];
|
|
const sourceDocumentRevisions = input.sourceDocumentRevisions ?? [];
|
|
const targetDocumentRevisions = input.targetDocumentRevisions ?? [];
|
|
|
|
const targetDocumentsById = new Map(targetDocuments.map((document) => [document.documentId, document]));
|
|
const targetDocumentsByIssueKey = new Map(targetDocuments.map((document) => [`${document.issueId}:${document.key}`, document]));
|
|
const sourceRevisionsByDocumentId = groupBy(sourceDocumentRevisions, (revision) => revision.documentId);
|
|
const targetRevisionsByDocumentId = groupBy(targetDocumentRevisions, (revision) => revision.documentId);
|
|
const commentIdsAvailableAfterImport = new Set<string>([
|
|
...input.targetComments.map((comment) => comment.id),
|
|
...commentPlans.filter((plan): plan is PlannedCommentInsert => plan.action === "insert").map((plan) => plan.source.id),
|
|
]);
|
|
|
|
const documentPlans: Array<PlannedIssueDocumentInsert | PlannedIssueDocumentMerge | PlannedIssueDocumentSkip> = [];
|
|
for (const document of sortDocumentRows(sourceDocuments)) {
|
|
if (!issueIdsAvailableAfterImport.has(document.issueId)) {
|
|
documentPlans.push({ source: document, action: "skip_missing_parent" });
|
|
continue;
|
|
}
|
|
|
|
const existingDocument = targetDocumentsById.get(document.documentId);
|
|
const conflictingIssueKeyDocument = targetDocumentsByIssueKey.get(`${document.issueId}:${document.key}`);
|
|
if (!existingDocument && conflictingIssueKeyDocument && conflictingIssueKeyDocument.documentId !== document.documentId) {
|
|
documentPlans.push({ source: document, action: "skip_conflicting_key" });
|
|
continue;
|
|
}
|
|
|
|
const adjustments: ImportAdjustment[] = [];
|
|
const targetCreatedByAgentId =
|
|
document.createdByAgentId && targetAgentIds.has(document.createdByAgentId) ? document.createdByAgentId : null;
|
|
const targetUpdatedByAgentId =
|
|
document.updatedByAgentId && targetAgentIds.has(document.updatedByAgentId) ? document.updatedByAgentId : null;
|
|
if (
|
|
(document.createdByAgentId && !targetCreatedByAgentId)
|
|
|| (document.updatedByAgentId && !targetUpdatedByAgentId)
|
|
) {
|
|
adjustments.push("clear_document_agent");
|
|
incrementAdjustment(adjustmentCounts, "clear_document_agent");
|
|
}
|
|
|
|
const sourceRevisions = sortDocumentRevisions(sourceRevisionsByDocumentId.get(document.documentId) ?? []);
|
|
const targetRevisions = sortDocumentRevisions(targetRevisionsByDocumentId.get(document.documentId) ?? []);
|
|
const existingRevisionIds = new Set(targetRevisions.map((revision) => revision.id));
|
|
const usedRevisionNumbers = new Set(targetRevisions.map((revision) => revision.revisionNumber));
|
|
let nextRevisionNumber = targetRevisions.reduce(
|
|
(maxValue, revision) => Math.max(maxValue, revision.revisionNumber),
|
|
0,
|
|
) + 1;
|
|
|
|
const targetRevisionNumberById = new Map<string, number>(
|
|
targetRevisions.map((revision) => [revision.id, revision.revisionNumber]),
|
|
);
|
|
const revisionsToInsert: PlannedDocumentRevisionInsert[] = [];
|
|
|
|
for (const revision of sourceRevisions) {
|
|
if (existingRevisionIds.has(revision.id)) continue;
|
|
let targetRevisionNumber = revision.revisionNumber;
|
|
if (usedRevisionNumbers.has(targetRevisionNumber)) {
|
|
while (usedRevisionNumbers.has(nextRevisionNumber)) {
|
|
nextRevisionNumber += 1;
|
|
}
|
|
targetRevisionNumber = nextRevisionNumber;
|
|
nextRevisionNumber += 1;
|
|
}
|
|
usedRevisionNumbers.add(targetRevisionNumber);
|
|
targetRevisionNumberById.set(revision.id, targetRevisionNumber);
|
|
|
|
const revisionAdjustments: ImportAdjustment[] = [];
|
|
const targetCreatedByAgentId =
|
|
revision.createdByAgentId && targetAgentIds.has(revision.createdByAgentId) ? revision.createdByAgentId : null;
|
|
if (revision.createdByAgentId && !targetCreatedByAgentId) {
|
|
revisionAdjustments.push("clear_document_revision_agent");
|
|
incrementAdjustment(adjustmentCounts, "clear_document_revision_agent");
|
|
}
|
|
|
|
revisionsToInsert.push({
|
|
source: revision,
|
|
targetRevisionNumber,
|
|
targetCreatedByAgentId,
|
|
adjustments: revisionAdjustments,
|
|
});
|
|
}
|
|
|
|
const latestRevisionId = document.latestRevisionId ?? existingDocument?.latestRevisionId ?? null;
|
|
const latestRevisionNumber =
|
|
(latestRevisionId ? targetRevisionNumberById.get(latestRevisionId) : undefined)
|
|
?? document.latestRevisionNumber
|
|
?? existingDocument?.latestRevisionNumber
|
|
?? 0;
|
|
|
|
if (!existingDocument) {
|
|
documentPlans.push({
|
|
source: document,
|
|
action: "insert",
|
|
targetCreatedByAgentId,
|
|
targetUpdatedByAgentId,
|
|
latestRevisionId,
|
|
latestRevisionNumber,
|
|
revisionsToInsert,
|
|
adjustments,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const documentAlreadyMatches =
|
|
existingDocument.key === document.key
|
|
&& existingDocument.title === document.title
|
|
&& existingDocument.format === document.format
|
|
&& existingDocument.latestBody === document.latestBody
|
|
&& (existingDocument.latestRevisionId ?? null) === latestRevisionId
|
|
&& existingDocument.latestRevisionNumber === latestRevisionNumber
|
|
&& (existingDocument.updatedByAgentId ?? null) === targetUpdatedByAgentId
|
|
&& (existingDocument.updatedByUserId ?? null) === (document.updatedByUserId ?? null)
|
|
&& sameDate(existingDocument.documentUpdatedAt, document.documentUpdatedAt)
|
|
&& sameDate(existingDocument.linkUpdatedAt, document.linkUpdatedAt)
|
|
&& revisionsToInsert.length === 0;
|
|
|
|
if (documentAlreadyMatches) {
|
|
documentPlans.push({ source: document, action: "skip_existing" });
|
|
continue;
|
|
}
|
|
|
|
documentPlans.push({
|
|
source: document,
|
|
action: "merge_existing",
|
|
targetCreatedByAgentId,
|
|
targetUpdatedByAgentId,
|
|
latestRevisionId,
|
|
latestRevisionNumber,
|
|
revisionsToInsert,
|
|
adjustments,
|
|
});
|
|
}
|
|
|
|
const sourceAttachments = input.sourceAttachments ?? [];
|
|
const targetAttachmentIds = new Set((input.targetAttachments ?? []).map((attachment) => attachment.id));
|
|
const attachmentPlans: Array<PlannedAttachmentInsert | PlannedAttachmentSkip> = [];
|
|
for (const attachment of sortAttachments(sourceAttachments)) {
|
|
if (targetAttachmentIds.has(attachment.id)) {
|
|
attachmentPlans.push({ source: attachment, action: "skip_existing" });
|
|
continue;
|
|
}
|
|
if (!issueIdsAvailableAfterImport.has(attachment.issueId)) {
|
|
attachmentPlans.push({ source: attachment, action: "skip_missing_parent" });
|
|
continue;
|
|
}
|
|
|
|
const adjustments: ImportAdjustment[] = [];
|
|
const targetCreatedByAgentId =
|
|
attachment.createdByAgentId && targetAgentIds.has(attachment.createdByAgentId)
|
|
? attachment.createdByAgentId
|
|
: null;
|
|
if (attachment.createdByAgentId && !targetCreatedByAgentId) {
|
|
adjustments.push("clear_attachment_agent");
|
|
incrementAdjustment(adjustmentCounts, "clear_attachment_agent");
|
|
}
|
|
|
|
attachmentPlans.push({
|
|
source: attachment,
|
|
action: "insert",
|
|
targetIssueCommentId:
|
|
attachment.issueCommentId && commentIdsAvailableAfterImport.has(attachment.issueCommentId)
|
|
? attachment.issueCommentId
|
|
: null,
|
|
targetCreatedByAgentId,
|
|
adjustments,
|
|
});
|
|
}
|
|
|
|
const counts = {
|
|
issuesToInsert: issuePlans.filter((plan) => plan.action === "insert").length,
|
|
issuesExisting: issuePlans.filter((plan) => plan.action === "skip_existing").length,
|
|
issueDrift: issuePlans.filter((plan) => plan.action === "skip_existing" && plan.driftKeys.length > 0).length,
|
|
commentsToInsert: commentPlans.filter((plan) => plan.action === "insert").length,
|
|
commentsExisting: commentPlans.filter((plan) => plan.action === "skip_existing").length,
|
|
commentsMissingParent: commentPlans.filter((plan) => plan.action === "skip_missing_parent").length,
|
|
documentsToInsert: documentPlans.filter((plan) => plan.action === "insert").length,
|
|
documentsToMerge: documentPlans.filter((plan) => plan.action === "merge_existing").length,
|
|
documentsExisting: documentPlans.filter((plan) => plan.action === "skip_existing").length,
|
|
documentsConflictingKey: documentPlans.filter((plan) => plan.action === "skip_conflicting_key").length,
|
|
documentsMissingParent: documentPlans.filter((plan) => plan.action === "skip_missing_parent").length,
|
|
documentRevisionsToInsert: documentPlans.reduce(
|
|
(sum, plan) =>
|
|
sum + (plan.action === "insert" || plan.action === "merge_existing" ? plan.revisionsToInsert.length : 0),
|
|
0,
|
|
),
|
|
attachmentsToInsert: attachmentPlans.filter((plan) => plan.action === "insert").length,
|
|
attachmentsExisting: attachmentPlans.filter((plan) => plan.action === "skip_existing").length,
|
|
attachmentsMissingParent: attachmentPlans.filter((plan) => plan.action === "skip_missing_parent").length,
|
|
};
|
|
|
|
return {
|
|
companyId: input.companyId,
|
|
companyName: input.companyName,
|
|
issuePrefix: input.issuePrefix,
|
|
previewIssueCounterStart: input.previewIssueCounterStart,
|
|
scopes: input.scopes,
|
|
issuePlans,
|
|
commentPlans,
|
|
documentPlans,
|
|
attachmentPlans,
|
|
counts,
|
|
adjustments: adjustmentCounts,
|
|
};
|
|
}
|