diff --git a/cli/src/__tests__/worktree-merge-history.test.ts b/cli/src/__tests__/worktree-merge-history.test.ts index c1fcf4b7..7a4d6b8b 100644 --- a/cli/src/__tests__/worktree-merge-history.test.ts +++ b/cli/src/__tests__/worktree-merge-history.test.ts @@ -53,6 +53,68 @@ function makeComment(overrides: Record = {}) { } as any; } +function makeIssueDocument(overrides: Record = {}) { + return { + id: "issue-document-1", + companyId: "company-1", + issueId: "issue-1", + documentId: "document-1", + key: "plan", + linkCreatedAt: new Date("2026-03-20T00:00:00.000Z"), + linkUpdatedAt: new Date("2026-03-20T00:00:00.000Z"), + title: "Plan", + format: "markdown", + latestBody: "# Plan", + latestRevisionId: "revision-1", + latestRevisionNumber: 1, + createdByAgentId: null, + createdByUserId: "local-board", + updatedByAgentId: null, + updatedByUserId: "local-board", + documentCreatedAt: new Date("2026-03-20T00:00:00.000Z"), + documentUpdatedAt: new Date("2026-03-20T00:00:00.000Z"), + ...overrides, + } as any; +} + +function makeDocumentRevision(overrides: Record = {}) { + return { + id: "revision-1", + companyId: "company-1", + documentId: "document-1", + revisionNumber: 1, + body: "# Plan", + changeSummary: null, + createdByAgentId: null, + createdByUserId: "local-board", + createdAt: new Date("2026-03-20T00:00:00.000Z"), + ...overrides, + } as any; +} + +function makeAttachment(overrides: Record = {}) { + return { + id: "attachment-1", + companyId: "company-1", + issueId: "issue-1", + issueCommentId: null, + assetId: "asset-1", + provider: "local_disk", + objectKey: "company-1/issues/issue-1/2026/03/20/asset.png", + contentType: "image/png", + byteSize: 12, + sha256: "deadbeef", + originalFilename: "asset.png", + createdByAgentId: null, + createdByUserId: "local-board", + assetCreatedAt: new Date("2026-03-20T00:00:00.000Z"), + assetUpdatedAt: new Date("2026-03-20T00:00:00.000Z"), + attachmentCreatedAt: new Date("2026-03-20T00:00:00.000Z"), + attachmentUpdatedAt: new Date("2026-03-20T00:00:00.000Z"), + ...overrides, + } as any; +} + describe("worktree merge history planner", () => { it("parses default scopes", () => { expect(parseWorktreeMergeScopes(undefined)).toEqual(["issues", "comments"]); @@ -214,4 +276,117 @@ describe("worktree merge history planner", () => { ]); expect(plan.adjustments.clear_author_agent).toBe(1); }); + + it("merges document revisions onto an existing shared document and renumbers conflicts", () => { + const sharedIssue = makeIssue({ id: "issue-a", identifier: "PAP-10" }); + const sourceDocument = makeIssueDocument({ + issueId: "issue-a", + documentId: "document-a", + latestBody: "# Branch plan", + latestRevisionId: "revision-branch-2", + latestRevisionNumber: 2, + documentUpdatedAt: new Date("2026-03-20T02:00:00.000Z"), + linkUpdatedAt: new Date("2026-03-20T02:00:00.000Z"), + }); + const targetDocument = makeIssueDocument({ + issueId: "issue-a", + documentId: "document-a", + latestBody: "# Main plan", + latestRevisionId: "revision-main-2", + latestRevisionNumber: 2, + documentUpdatedAt: new Date("2026-03-20T01:00:00.000Z"), + linkUpdatedAt: new Date("2026-03-20T01:00:00.000Z"), + }); + const sourceRevisionOne = makeDocumentRevision({ documentId: "document-a", id: "revision-1" }); + const sourceRevisionTwo = makeDocumentRevision({ + documentId: "document-a", + id: "revision-branch-2", + revisionNumber: 2, + body: "# Branch plan", + createdAt: new Date("2026-03-20T02:00:00.000Z"), + }); + const targetRevisionOne = makeDocumentRevision({ documentId: "document-a", id: "revision-1" }); + const targetRevisionTwo = makeDocumentRevision({ + documentId: "document-a", + id: "revision-main-2", + revisionNumber: 2, + body: "# Main plan", + createdAt: new Date("2026-03-20T01:00:00.000Z"), + }); + + const plan = buildWorktreeMergePlan({ + companyId: "company-1", + companyName: "Paperclip", + issuePrefix: "PAP", + previewIssueCounterStart: 10, + scopes: ["issues", "comments"], + sourceIssues: [sharedIssue], + targetIssues: [sharedIssue], + sourceComments: [], + targetComments: [], + sourceDocuments: [sourceDocument], + targetDocuments: [targetDocument], + sourceDocumentRevisions: [sourceRevisionOne, sourceRevisionTwo], + targetDocumentRevisions: [targetRevisionOne, targetRevisionTwo], + sourceAttachments: [], + targetAttachments: [], + targetAgents: [], + targetProjects: [], + targetProjectWorkspaces: [], + targetGoals: [{ id: "goal-1" }] as any, + }); + + expect(plan.counts.documentsToMerge).toBe(1); + expect(plan.counts.documentRevisionsToInsert).toBe(1); + expect(plan.documentPlans[0]).toMatchObject({ + action: "merge_existing", + latestRevisionId: "revision-branch-2", + latestRevisionNumber: 3, + }); + const mergePlan = plan.documentPlans[0] as any; + expect(mergePlan.revisionsToInsert).toHaveLength(1); + expect(mergePlan.revisionsToInsert[0]).toMatchObject({ + source: { id: "revision-branch-2" }, + targetRevisionNumber: 3, + }); + }); + + it("imports attachments while clearing missing comment and author references", () => { + const sharedIssue = makeIssue({ id: "issue-a", identifier: "PAP-10" }); + const attachment = makeAttachment({ + issueId: "issue-a", + issueCommentId: "comment-missing", + createdByAgentId: "agent-missing", + }); + + const plan = buildWorktreeMergePlan({ + companyId: "company-1", + companyName: "Paperclip", + issuePrefix: "PAP", + previewIssueCounterStart: 10, + scopes: ["issues"], + sourceIssues: [sharedIssue], + targetIssues: [sharedIssue], + sourceComments: [], + targetComments: [], + sourceDocuments: [], + targetDocuments: [], + sourceDocumentRevisions: [], + targetDocumentRevisions: [], + sourceAttachments: [attachment], + targetAttachments: [], + targetAgents: [], + targetProjects: [], + targetProjectWorkspaces: [], + targetGoals: [{ id: "goal-1" }] as any, + }); + + expect(plan.counts.attachmentsToInsert).toBe(1); + expect(plan.adjustments.clear_attachment_agent).toBe(1); + expect(plan.attachmentPlans[0]).toMatchObject({ + action: "insert", + targetIssueCommentId: null, + targetCreatedByAgentId: null, + }); + }); }); diff --git a/cli/src/commands/worktree-merge-history-lib.ts b/cli/src/commands/worktree-merge-history-lib.ts index 1acd352e..a55a22d3 100644 --- a/cli/src/commands/worktree-merge-history-lib.ts +++ b/cli/src/commands/worktree-merge-history-lib.ts @@ -1,7 +1,11 @@ import { agents, + assets, + documentRevisions, goals, + issueAttachments, issueComments, + issueDocuments, issues, projects, projectWorkspaces, @@ -13,6 +17,10 @@ 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]; @@ -23,7 +31,10 @@ export type ImportAdjustment = | "clear_project_workspace" | "clear_goal" | "clear_author_agent" - | "coerce_in_progress_to_todo"; + | "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"; @@ -62,6 +73,106 @@ export type PlannedCommentSkip = { 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; @@ -70,6 +181,8 @@ export type WorktreeMergePlan = { scopes: WorktreeMergeScope[]; issuePlans: Array; commentPlans: Array; + documentPlans: Array; + attachmentPlans: Array; counts: { issuesToInsert: number; issuesExisting: number; @@ -77,6 +190,15 @@ export type WorktreeMergePlan = { 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; }; @@ -103,6 +225,52 @@ function incrementAdjustment( counts[adjustment] += 1; } +function groupBy(rows: T[], keyFor: (row: T) => string): Map { + const out = new Map(); + 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(); @@ -170,6 +338,12 @@ export function buildWorktreeMergePlan(input: { targetIssues: IssueRow[]; sourceComments: CommentRow[]; targetComments: CommentRow[]; + sourceDocuments?: IssueDocumentRow[]; + targetDocuments?: IssueDocumentRow[]; + sourceDocumentRevisions?: DocumentRevisionRow[]; + targetDocumentRevisions?: DocumentRevisionRow[]; + sourceAttachments?: IssueAttachmentRow[]; + targetAttachments?: IssueAttachmentRow[]; targetAgents: AgentRow[]; targetProjects: ProjectRow[]; targetProjectWorkspaces: ProjectWorkspaceRow[]; @@ -192,6 +366,9 @@ export function buildWorktreeMergePlan(input: { 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 = []; @@ -324,6 +501,176 @@ export function buildWorktreeMergePlan(input: { } } + 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([ + ...input.targetComments.map((comment) => comment.id), + ...commentPlans.filter((plan): plan is PlannedCommentInsert => plan.action === "insert").map((plan) => plan.source.id), + ]); + + const documentPlans: Array = []; + 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( + 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 = []; + 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, @@ -331,6 +678,19 @@ export function buildWorktreeMergePlan(input: { 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 { @@ -341,6 +701,8 @@ export function buildWorktreeMergePlan(input: { scopes: input.scopes, issuePlans, commentPlans, + documentPlans, + attachmentPlans, counts, adjustments: adjustmentCounts, }; diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index 1f9bfbdf..f167ea43 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -3,6 +3,7 @@ import { copyFileSync, existsSync, mkdirSync, + promises as fsPromises, readdirSync, readFileSync, readlinkSync, @@ -15,18 +16,23 @@ import os from "node:os"; import path from "node:path"; import { execFileSync } from "node:child_process"; import { createServer } from "node:net"; +import { Readable } from "node:stream"; import * as p from "@clack/prompts"; import pc from "picocolors"; import { and, eq, inArray, sql } from "drizzle-orm"; import { applyPendingMigrations, agents, + assets, companies, createDb, + documentRevisions, + documents, ensurePostgresDatabase, formatDatabaseBackupResult, goals, heartbeatRuns, + issueAttachments, issueComments, issueDocuments, issues, @@ -59,7 +65,13 @@ import { import { buildWorktreeMergePlan, parseWorktreeMergeScopes, + type IssueAttachmentRow, + type IssueDocumentRow, + type DocumentRevisionRow, + type PlannedAttachmentInsert, type PlannedCommentInsert, + type PlannedIssueDocumentInsert, + type PlannedIssueDocumentMerge, type PlannedIssueInsert, } from "./worktree-merge-history-lib.js"; @@ -181,6 +193,162 @@ function resolveWorktreeStartPoint(explicit?: string): string | undefined { return explicit ?? nonEmpty(process.env.PAPERCLIP_WORKTREE_START_POINT) ?? undefined; } +type ConfiguredStorage = { + getObject(companyId: string, objectKey: string): Promise; + putObject(companyId: string, objectKey: string, body: Buffer, contentType: string): Promise; +}; + +function assertStorageCompanyPrefix(companyId: string, objectKey: string): void { + if (!objectKey.startsWith(`${companyId}/`) || objectKey.includes("..")) { + throw new Error(`Invalid object key for company ${companyId}.`); + } +} + +function normalizeStorageObjectKey(objectKey: string): string { + const normalized = objectKey.replace(/\\/g, "/").trim(); + if (!normalized || normalized.startsWith("/")) { + throw new Error("Invalid object key."); + } + const parts = normalized.split("/").filter((part) => part.length > 0); + if (parts.length === 0 || parts.some((part) => part === "." || part === "..")) { + throw new Error("Invalid object key."); + } + return parts.join("/"); +} + +function resolveLocalStoragePath(baseDir: string, objectKey: string): string { + const resolved = path.resolve(baseDir, normalizeStorageObjectKey(objectKey)); + const root = path.resolve(baseDir); + if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) { + throw new Error("Invalid object key path."); + } + return resolved; +} + +async function s3BodyToBuffer(body: unknown): Promise { + if (!body) { + throw new Error("Object not found."); + } + if (Buffer.isBuffer(body)) { + return body; + } + if (body instanceof Readable) { + return await streamToBuffer(body); + } + + const candidate = body as { + transformToWebStream?: () => ReadableStream; + arrayBuffer?: () => Promise; + }; + if (typeof candidate.transformToWebStream === "function") { + const webStream = candidate.transformToWebStream(); + const reader = webStream.getReader(); + const chunks: Uint8Array[] = []; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value) chunks.push(value); + } + return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))); + } + if (typeof candidate.arrayBuffer === "function") { + return Buffer.from(await candidate.arrayBuffer()); + } + + throw new Error("Unsupported storage response body."); +} + +function normalizeS3Prefix(prefix: string | undefined): string { + if (!prefix) return ""; + return prefix.trim().replace(/^\/+/, "").replace(/\/+$/, ""); +} + +function buildS3ObjectKey(prefix: string, objectKey: string): string { + return prefix ? `${prefix}/${objectKey}` : objectKey; +} + +const dynamicImport = new Function("specifier", "return import(specifier);") as (specifier: string) => Promise; + +function createConfiguredStorageFromPaperclipConfig(config: PaperclipConfig): ConfiguredStorage { + if (config.storage.provider === "local_disk") { + const baseDir = expandHomePrefix(config.storage.localDisk.baseDir); + return { + async getObject(companyId: string, objectKey: string) { + assertStorageCompanyPrefix(companyId, objectKey); + return await fsPromises.readFile(resolveLocalStoragePath(baseDir, objectKey)); + }, + async putObject(companyId: string, objectKey: string, body: Buffer) { + assertStorageCompanyPrefix(companyId, objectKey); + const filePath = resolveLocalStoragePath(baseDir, objectKey); + await fsPromises.mkdir(path.dirname(filePath), { recursive: true }); + await fsPromises.writeFile(filePath, body); + }, + }; + } + + const prefix = normalizeS3Prefix(config.storage.s3.prefix); + let s3ClientPromise: Promise | null = null; + async function getS3Client() { + if (!s3ClientPromise) { + s3ClientPromise = (async () => { + const sdk = await dynamicImport("@aws-sdk/client-s3"); + return { + sdk, + client: new sdk.S3Client({ + region: config.storage.s3.region, + endpoint: config.storage.s3.endpoint, + forcePathStyle: config.storage.s3.forcePathStyle, + }), + }; + })(); + } + return await s3ClientPromise; + } + const bucket = config.storage.s3.bucket; + return { + async getObject(companyId: string, objectKey: string) { + assertStorageCompanyPrefix(companyId, objectKey); + const { sdk, client } = await getS3Client(); + const response = await client.send( + new sdk.GetObjectCommand({ + Bucket: bucket, + Key: buildS3ObjectKey(prefix, objectKey), + }), + ); + return await s3BodyToBuffer(response.Body); + }, + async putObject(companyId: string, objectKey: string, body: Buffer, contentType: string) { + assertStorageCompanyPrefix(companyId, objectKey); + const { sdk, client } = await getS3Client(); + await client.send( + new sdk.PutObjectCommand({ + Bucket: bucket, + Key: buildS3ObjectKey(prefix, objectKey), + Body: body, + ContentType: contentType, + ContentLength: body.length, + }), + ); + }, + }; +} + +function openConfiguredStorage(configPath: string): ConfiguredStorage { + const config = readConfig(configPath); + if (!config) { + throw new Error(`Config not found at ${configPath}.`); + } + return createConfiguredStorageFromPaperclipConfig(config); +} + +async function streamToBuffer(stream: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks); +} + export function resolveWorktreeMakeTargetPath(name: string): string { return path.resolve(os.homedir(), resolveWorktreeMakeName(name)); } @@ -1244,7 +1412,6 @@ function renderMergePlan(plan: Awaited>["pla sourcePath: string; targetPath: string; unsupportedRunCount: number; - unsupportedDocumentCount: number; }): string { const terminalWidth = Math.max(60, process.stdout.columns ?? 100); const oneLine = (value: string) => value.replace(/\s+/g, " ").trim(); @@ -1292,6 +1459,21 @@ function renderMergePlan(plan: Awaited>["pla lines.push(`- skipped (missing parent): ${plan.counts.commentsMissingParent}`); } + lines.push(""); + lines.push("Documents"); + lines.push(`- insert: ${plan.counts.documentsToInsert}`); + lines.push(`- merge existing: ${plan.counts.documentsToMerge}`); + lines.push(`- already present: ${plan.counts.documentsExisting}`); + lines.push(`- skipped (conflicting key): ${plan.counts.documentsConflictingKey}`); + lines.push(`- skipped (missing parent): ${plan.counts.documentsMissingParent}`); + lines.push(`- revisions insert: ${plan.counts.documentRevisionsToInsert}`); + + lines.push(""); + lines.push("Attachments"); + lines.push(`- insert: ${plan.counts.attachmentsToInsert}`); + lines.push(`- already present: ${plan.counts.attachmentsExisting}`); + lines.push(`- skipped (missing parent): ${plan.counts.attachmentsMissingParent}`); + lines.push(""); lines.push("Adjustments"); lines.push(`- cleared assignee agents: ${plan.adjustments.clear_assignee_agent}`); @@ -1299,12 +1481,14 @@ function renderMergePlan(plan: Awaited>["pla lines.push(`- cleared project workspaces: ${plan.adjustments.clear_project_workspace}`); lines.push(`- cleared goals: ${plan.adjustments.clear_goal}`); lines.push(`- cleared comment author agents: ${plan.adjustments.clear_author_agent}`); + lines.push(`- cleared document agents: ${plan.adjustments.clear_document_agent}`); + lines.push(`- cleared document revision agents: ${plan.adjustments.clear_document_revision_agent}`); + lines.push(`- cleared attachment author agents: ${plan.adjustments.clear_attachment_agent}`); lines.push(`- coerced in_progress to todo: ${plan.adjustments.coerce_in_progress_to_todo}`); lines.push(""); lines.push("Not imported in this phase"); lines.push(`- heartbeat runs: ${extras.unsupportedRunCount}`); - lines.push(`- issue documents: ${extras.unsupportedDocumentCount}`); lines.push(""); lines.push("Identifiers shown above are provisional preview values. `--apply` reserves fresh issue numbers at write time."); @@ -1319,7 +1503,25 @@ async function collectMergePlan(input: { projectIdOverrides?: Record; }) { const companyId = input.company.id; - const [targetCompanyRow, sourceIssuesRows, targetIssuesRows, sourceCommentsRows, targetCommentsRows, sourceProjectsRows, targetProjectsRows, targetAgentsRows, targetProjectWorkspaceRows, targetGoalsRows, runCountRows, documentCountRows] = await Promise.all([ + const [ + targetCompanyRow, + sourceIssuesRows, + targetIssuesRows, + sourceCommentsRows, + targetCommentsRows, + sourceIssueDocumentsRows, + targetIssueDocumentsRows, + sourceDocumentRevisionRows, + targetDocumentRevisionRows, + sourceAttachmentRows, + targetAttachmentRows, + sourceProjectsRows, + targetProjectsRows, + targetAgentsRows, + targetProjectWorkspaceRows, + targetGoalsRows, + runCountRows, + ] = await Promise.all([ input.targetDb .select({ issueCounter: companies.issueCounter, @@ -1341,12 +1543,140 @@ async function collectMergePlan(input: { .from(issueComments) .where(eq(issueComments.companyId, companyId)) : Promise.resolve([]), - input.scopes.includes("comments") - ? input.targetDb - .select() - .from(issueComments) - .where(eq(issueComments.companyId, companyId)) - : Promise.resolve([]), + input.targetDb + .select() + .from(issueComments) + .where(eq(issueComments.companyId, companyId)), + input.sourceDb + .select({ + id: issueDocuments.id, + companyId: issueDocuments.companyId, + issueId: issueDocuments.issueId, + documentId: issueDocuments.documentId, + key: issueDocuments.key, + linkCreatedAt: issueDocuments.createdAt, + linkUpdatedAt: issueDocuments.updatedAt, + title: documents.title, + format: documents.format, + latestBody: documents.latestBody, + latestRevisionId: documents.latestRevisionId, + latestRevisionNumber: documents.latestRevisionNumber, + createdByAgentId: documents.createdByAgentId, + createdByUserId: documents.createdByUserId, + updatedByAgentId: documents.updatedByAgentId, + updatedByUserId: documents.updatedByUserId, + documentCreatedAt: documents.createdAt, + documentUpdatedAt: documents.updatedAt, + }) + .from(issueDocuments) + .innerJoin(documents, eq(issueDocuments.documentId, documents.id)) + .innerJoin(issues, eq(issueDocuments.issueId, issues.id)) + .where(eq(issues.companyId, companyId)), + input.targetDb + .select({ + id: issueDocuments.id, + companyId: issueDocuments.companyId, + issueId: issueDocuments.issueId, + documentId: issueDocuments.documentId, + key: issueDocuments.key, + linkCreatedAt: issueDocuments.createdAt, + linkUpdatedAt: issueDocuments.updatedAt, + title: documents.title, + format: documents.format, + latestBody: documents.latestBody, + latestRevisionId: documents.latestRevisionId, + latestRevisionNumber: documents.latestRevisionNumber, + createdByAgentId: documents.createdByAgentId, + createdByUserId: documents.createdByUserId, + updatedByAgentId: documents.updatedByAgentId, + updatedByUserId: documents.updatedByUserId, + documentCreatedAt: documents.createdAt, + documentUpdatedAt: documents.updatedAt, + }) + .from(issueDocuments) + .innerJoin(documents, eq(issueDocuments.documentId, documents.id)) + .innerJoin(issues, eq(issueDocuments.issueId, issues.id)) + .where(eq(issues.companyId, companyId)), + input.sourceDb + .select({ + id: documentRevisions.id, + companyId: documentRevisions.companyId, + documentId: documentRevisions.documentId, + revisionNumber: documentRevisions.revisionNumber, + body: documentRevisions.body, + changeSummary: documentRevisions.changeSummary, + createdByAgentId: documentRevisions.createdByAgentId, + createdByUserId: documentRevisions.createdByUserId, + createdAt: documentRevisions.createdAt, + }) + .from(documentRevisions) + .innerJoin(issueDocuments, eq(documentRevisions.documentId, issueDocuments.documentId)) + .innerJoin(issues, eq(issueDocuments.issueId, issues.id)) + .where(eq(issues.companyId, companyId)), + input.targetDb + .select({ + id: documentRevisions.id, + companyId: documentRevisions.companyId, + documentId: documentRevisions.documentId, + revisionNumber: documentRevisions.revisionNumber, + body: documentRevisions.body, + changeSummary: documentRevisions.changeSummary, + createdByAgentId: documentRevisions.createdByAgentId, + createdByUserId: documentRevisions.createdByUserId, + createdAt: documentRevisions.createdAt, + }) + .from(documentRevisions) + .innerJoin(issueDocuments, eq(documentRevisions.documentId, issueDocuments.documentId)) + .innerJoin(issues, eq(issueDocuments.issueId, issues.id)) + .where(eq(issues.companyId, companyId)), + input.sourceDb + .select({ + id: issueAttachments.id, + companyId: issueAttachments.companyId, + issueId: issueAttachments.issueId, + issueCommentId: issueAttachments.issueCommentId, + assetId: issueAttachments.assetId, + provider: assets.provider, + objectKey: assets.objectKey, + contentType: assets.contentType, + byteSize: assets.byteSize, + sha256: assets.sha256, + originalFilename: assets.originalFilename, + createdByAgentId: assets.createdByAgentId, + createdByUserId: assets.createdByUserId, + assetCreatedAt: assets.createdAt, + assetUpdatedAt: assets.updatedAt, + attachmentCreatedAt: issueAttachments.createdAt, + attachmentUpdatedAt: issueAttachments.updatedAt, + }) + .from(issueAttachments) + .innerJoin(assets, eq(issueAttachments.assetId, assets.id)) + .innerJoin(issues, eq(issueAttachments.issueId, issues.id)) + .where(eq(issues.companyId, companyId)), + input.targetDb + .select({ + id: issueAttachments.id, + companyId: issueAttachments.companyId, + issueId: issueAttachments.issueId, + issueCommentId: issueAttachments.issueCommentId, + assetId: issueAttachments.assetId, + provider: assets.provider, + objectKey: assets.objectKey, + contentType: assets.contentType, + byteSize: assets.byteSize, + sha256: assets.sha256, + originalFilename: assets.originalFilename, + createdByAgentId: assets.createdByAgentId, + createdByUserId: assets.createdByUserId, + assetCreatedAt: assets.createdAt, + assetUpdatedAt: assets.updatedAt, + attachmentCreatedAt: issueAttachments.createdAt, + attachmentUpdatedAt: issueAttachments.updatedAt, + }) + .from(issueAttachments) + .innerJoin(assets, eq(issueAttachments.assetId, assets.id)) + .innerJoin(issues, eq(issueAttachments.issueId, issues.id)) + .where(eq(issues.companyId, companyId)), input.sourceDb .select() .from(projects) @@ -1371,11 +1701,6 @@ async function collectMergePlan(input: { .select({ count: sql`count(*)::int` }) .from(heartbeatRuns) .where(eq(heartbeatRuns.companyId, companyId)), - input.sourceDb - .select({ count: sql`count(*)::int` }) - .from(issueDocuments) - .innerJoin(issues, eq(issueDocuments.issueId, issues.id)) - .where(eq(issues.companyId, companyId)), ]); if (!targetCompanyRow) { @@ -1392,6 +1717,12 @@ async function collectMergePlan(input: { targetIssues: targetIssuesRows, sourceComments: sourceCommentsRows, targetComments: targetCommentsRows, + sourceDocuments: sourceIssueDocumentsRows as IssueDocumentRow[], + targetDocuments: targetIssueDocumentsRows as IssueDocumentRow[], + sourceDocumentRevisions: sourceDocumentRevisionRows as DocumentRevisionRow[], + targetDocumentRevisions: targetDocumentRevisionRows as DocumentRevisionRow[], + sourceAttachments: sourceAttachmentRows as IssueAttachmentRow[], + targetAttachments: targetAttachmentRows as IssueAttachmentRow[], targetAgents: targetAgentsRows, targetProjects: targetProjectsRows, targetProjectWorkspaces: targetProjectWorkspaceRows, @@ -1404,7 +1735,6 @@ async function collectMergePlan(input: { sourceProjects: sourceProjectsRows, targetProjects: targetProjectsRows, unsupportedRunCount: runCountRows[0]?.count ?? 0, - unsupportedDocumentCount: documentCountRows[0]?.count ?? 0, }; } @@ -1575,6 +1905,8 @@ async function promptForSourceEndpoint(excludeWorktreePath?: string): Promise>["plan"]; @@ -1608,6 +1940,7 @@ async function applyMergePlan(input: { } const insertedIssueIdentifiers = new Map(); + let insertedIssues = 0; for (const issue of issueInserts) { const issueNumber = nextIssueNumber; nextIssueNumber += 1; @@ -1647,6 +1980,7 @@ async function applyMergePlan(input: { createdAt: issue.source.createdAt, updatedAt: issue.source.updatedAt, }); + insertedIssues += 1; } const commentCandidates = input.plan.commentPlans.filter( @@ -1663,6 +1997,7 @@ async function applyMergePlan(input: { ) : new Set(); + let insertedComments = 0; for (const comment of commentCandidates) { if (existingCommentIds.has(comment.source.id)) continue; const parentExists = await tx @@ -1681,11 +2016,199 @@ async function applyMergePlan(input: { createdAt: comment.source.createdAt, updatedAt: comment.source.updatedAt, }); + insertedComments += 1; + } + + const documentCandidates = input.plan.documentPlans.filter( + (plan): plan is PlannedIssueDocumentInsert | PlannedIssueDocumentMerge => + plan.action === "insert" || plan.action === "merge_existing", + ); + let insertedDocuments = 0; + let mergedDocuments = 0; + let insertedDocumentRevisions = 0; + for (const documentPlan of documentCandidates) { + const parentExists = await tx + .select({ id: issues.id }) + .from(issues) + .where(and(eq(issues.id, documentPlan.source.issueId), eq(issues.companyId, companyId))) + .then((rows) => rows[0] ?? null); + if (!parentExists) continue; + + const conflictingKeyDocument = await tx + .select({ documentId: issueDocuments.documentId }) + .from(issueDocuments) + .where(and(eq(issueDocuments.issueId, documentPlan.source.issueId), eq(issueDocuments.key, documentPlan.source.key))) + .then((rows) => rows[0] ?? null); + if ( + conflictingKeyDocument + && conflictingKeyDocument.documentId !== documentPlan.source.documentId + ) { + continue; + } + + const existingDocument = await tx + .select({ id: documents.id }) + .from(documents) + .where(eq(documents.id, documentPlan.source.documentId)) + .then((rows) => rows[0] ?? null); + + if (!existingDocument) { + await tx.insert(documents).values({ + id: documentPlan.source.documentId, + companyId, + title: documentPlan.source.title, + format: documentPlan.source.format, + latestBody: documentPlan.source.latestBody, + latestRevisionId: documentPlan.latestRevisionId, + latestRevisionNumber: documentPlan.latestRevisionNumber, + createdByAgentId: documentPlan.targetCreatedByAgentId, + createdByUserId: documentPlan.source.createdByUserId, + updatedByAgentId: documentPlan.targetUpdatedByAgentId, + updatedByUserId: documentPlan.source.updatedByUserId, + createdAt: documentPlan.source.documentCreatedAt, + updatedAt: documentPlan.source.documentUpdatedAt, + }); + await tx.insert(issueDocuments).values({ + id: documentPlan.source.id, + companyId, + issueId: documentPlan.source.issueId, + documentId: documentPlan.source.documentId, + key: documentPlan.source.key, + createdAt: documentPlan.source.linkCreatedAt, + updatedAt: documentPlan.source.linkUpdatedAt, + }); + insertedDocuments += 1; + } else { + const existingLink = await tx + .select({ id: issueDocuments.id }) + .from(issueDocuments) + .where(eq(issueDocuments.documentId, documentPlan.source.documentId)) + .then((rows) => rows[0] ?? null); + if (!existingLink) { + await tx.insert(issueDocuments).values({ + id: documentPlan.source.id, + companyId, + issueId: documentPlan.source.issueId, + documentId: documentPlan.source.documentId, + key: documentPlan.source.key, + createdAt: documentPlan.source.linkCreatedAt, + updatedAt: documentPlan.source.linkUpdatedAt, + }); + } else { + await tx + .update(issueDocuments) + .set({ + issueId: documentPlan.source.issueId, + key: documentPlan.source.key, + updatedAt: documentPlan.source.linkUpdatedAt, + }) + .where(eq(issueDocuments.documentId, documentPlan.source.documentId)); + } + + await tx + .update(documents) + .set({ + title: documentPlan.source.title, + format: documentPlan.source.format, + latestBody: documentPlan.source.latestBody, + latestRevisionId: documentPlan.latestRevisionId, + latestRevisionNumber: documentPlan.latestRevisionNumber, + updatedByAgentId: documentPlan.targetUpdatedByAgentId, + updatedByUserId: documentPlan.source.updatedByUserId, + updatedAt: documentPlan.source.documentUpdatedAt, + }) + .where(eq(documents.id, documentPlan.source.documentId)); + mergedDocuments += 1; + } + + const existingRevisionIds = new Set( + ( + await tx + .select({ id: documentRevisions.id }) + .from(documentRevisions) + .where(eq(documentRevisions.documentId, documentPlan.source.documentId)) + ).map((row) => row.id), + ); + for (const revisionPlan of documentPlan.revisionsToInsert) { + if (existingRevisionIds.has(revisionPlan.source.id)) continue; + await tx.insert(documentRevisions).values({ + id: revisionPlan.source.id, + companyId, + documentId: documentPlan.source.documentId, + revisionNumber: revisionPlan.targetRevisionNumber, + body: revisionPlan.source.body, + changeSummary: revisionPlan.source.changeSummary, + createdByAgentId: revisionPlan.targetCreatedByAgentId, + createdByUserId: revisionPlan.source.createdByUserId, + createdAt: revisionPlan.source.createdAt, + }); + insertedDocumentRevisions += 1; + } + } + + const attachmentCandidates = input.plan.attachmentPlans.filter( + (plan): plan is PlannedAttachmentInsert => plan.action === "insert", + ); + const existingAttachmentIds = new Set( + ( + await tx + .select({ id: issueAttachments.id }) + .from(issueAttachments) + .where(eq(issueAttachments.companyId, companyId)) + ).map((row) => row.id), + ); + let insertedAttachments = 0; + for (const attachment of attachmentCandidates) { + if (existingAttachmentIds.has(attachment.source.id)) continue; + const parentExists = await tx + .select({ id: issues.id }) + .from(issues) + .where(and(eq(issues.id, attachment.source.issueId), eq(issues.companyId, companyId))) + .then((rows) => rows[0] ?? null); + if (!parentExists) continue; + + const body = await input.sourceStorage.getObject(companyId, attachment.source.objectKey); + await input.targetStorage.putObject( + companyId, + attachment.source.objectKey, + body, + attachment.source.contentType, + ); + + await tx.insert(assets).values({ + id: attachment.source.assetId, + companyId, + provider: attachment.source.provider, + objectKey: attachment.source.objectKey, + contentType: attachment.source.contentType, + byteSize: attachment.source.byteSize, + sha256: attachment.source.sha256, + originalFilename: attachment.source.originalFilename, + createdByAgentId: attachment.targetCreatedByAgentId, + createdByUserId: attachment.source.createdByUserId, + createdAt: attachment.source.assetCreatedAt, + updatedAt: attachment.source.assetUpdatedAt, + }); + + await tx.insert(issueAttachments).values({ + id: attachment.source.id, + companyId, + issueId: attachment.source.issueId, + assetId: attachment.source.assetId, + issueCommentId: attachment.targetIssueCommentId, + createdAt: attachment.source.attachmentCreatedAt, + updatedAt: attachment.source.attachmentUpdatedAt, + }); + insertedAttachments += 1; } return { - insertedIssues: issueInserts.length, - insertedComments: commentCandidates.filter((comment) => !existingCommentIds.has(comment.source.id)).length, + insertedIssues, + insertedComments, + insertedDocuments, + mergedDocuments, + insertedDocumentRevisions, + insertedAttachments, insertedIssueIdentifiers, }; }); @@ -1716,6 +2239,8 @@ export async function worktreeMergeHistoryCommand(sourceArg: string | undefined, const scopes = parseWorktreeMergeScopes(opts.scope); const sourceHandle = await openConfiguredDb(sourceEndpoint.configPath); const targetHandle = await openConfiguredDb(targetEndpoint.configPath); + const sourceStorage = openConfiguredStorage(sourceEndpoint.configPath); + const targetStorage = openConfiguredStorage(targetEndpoint.configPath); try { const company = await resolveMergeCompany({ @@ -1750,7 +2275,6 @@ export async function worktreeMergeHistoryCommand(sourceArg: string | undefined, sourcePath: `${sourceEndpoint.label} (${sourceEndpoint.rootPath})`, targetPath: `${targetEndpoint.label} (${targetEndpoint.rootPath})`, unsupportedRunCount: collected.unsupportedRunCount, - unsupportedDocumentCount: collected.unsupportedDocumentCount, })); if (!opts.apply) { @@ -1769,13 +2293,15 @@ export async function worktreeMergeHistoryCommand(sourceArg: string | undefined, } const applied = await applyMergePlan({ + sourceStorage, + targetStorage, targetDb: targetHandle.db, company, plan: collected.plan, }); p.outro( pc.green( - `Imported ${applied.insertedIssues} issues and ${applied.insertedComments} comments into ${company.issuePrefix}.`, + `Imported ${applied.insertedIssues} issues, ${applied.insertedComments} comments, ${applied.insertedDocuments} documents (${applied.insertedDocumentRevisions} revisions, ${applied.mergedDocuments} merged), and ${applied.insertedAttachments} attachments into ${company.issuePrefix}.`, ), ); } finally {