diff --git a/cli/src/__tests__/worktree-merge-history.test.ts b/cli/src/__tests__/worktree-merge-history.test.ts new file mode 100644 index 00000000..7a4d6b8b --- /dev/null +++ b/cli/src/__tests__/worktree-merge-history.test.ts @@ -0,0 +1,392 @@ +import { describe, expect, it } from "vitest"; +import { buildWorktreeMergePlan, parseWorktreeMergeScopes } from "../commands/worktree-merge-history-lib.js"; + +function makeIssue(overrides: Record = {}) { + return { + id: "issue-1", + companyId: "company-1", + projectId: null, + projectWorkspaceId: null, + goalId: "goal-1", + parentId: null, + title: "Issue", + description: null, + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + createdByAgentId: null, + createdByUserId: "local-board", + issueNumber: 1, + identifier: "PAP-1", + requestDepth: 0, + billingCode: null, + assigneeAdapterOverrides: null, + executionWorkspaceId: null, + executionWorkspacePreference: null, + executionWorkspaceSettings: null, + startedAt: null, + completedAt: null, + cancelledAt: null, + hiddenAt: null, + createdAt: new Date("2026-03-20T00:00:00.000Z"), + updatedAt: new Date("2026-03-20T00:00:00.000Z"), + ...overrides, + } as any; +} + +function makeComment(overrides: Record = {}) { + return { + id: "comment-1", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "local-board", + body: "hello", + createdAt: new Date("2026-03-20T00:00:00.000Z"), + updatedAt: new Date("2026-03-20T00:00:00.000Z"), + ...overrides, + } 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"]); + expect(parseWorktreeMergeScopes("issues")).toEqual(["issues"]); + }); + + it("dedupes nested worktree issues by preserved source uuid", () => { + const sharedIssue = makeIssue({ id: "issue-a", identifier: "PAP-10", title: "Shared" }); + const branchOneIssue = makeIssue({ + id: "issue-b", + identifier: "PAP-22", + title: "Branch one issue", + createdAt: new Date("2026-03-20T01:00:00.000Z"), + }); + const branchTwoIssue = makeIssue({ + id: "issue-c", + identifier: "PAP-23", + title: "Branch two issue", + createdAt: new Date("2026-03-20T02:00:00.000Z"), + }); + + const plan = buildWorktreeMergePlan({ + companyId: "company-1", + companyName: "Paperclip", + issuePrefix: "PAP", + previewIssueCounterStart: 500, + scopes: ["issues", "comments"], + sourceIssues: [sharedIssue, branchOneIssue, branchTwoIssue], + targetIssues: [sharedIssue, branchOneIssue], + sourceComments: [], + targetComments: [], + targetAgents: [], + targetProjects: [], + targetProjectWorkspaces: [], + targetGoals: [{ id: "goal-1" }] as any, + }); + + expect(plan.counts.issuesToInsert).toBe(1); + expect(plan.issuePlans.filter((item) => item.action === "insert").map((item) => item.source.id)).toEqual(["issue-c"]); + expect(plan.issuePlans.find((item) => item.source.id === "issue-c" && item.action === "insert")).toMatchObject({ + previewIdentifier: "PAP-501", + }); + }); + + it("clears missing references and coerces in_progress without an assignee", () => { + const plan = buildWorktreeMergePlan({ + companyId: "company-1", + companyName: "Paperclip", + issuePrefix: "PAP", + previewIssueCounterStart: 10, + scopes: ["issues"], + sourceIssues: [ + makeIssue({ + id: "issue-x", + identifier: "PAP-99", + status: "in_progress", + assigneeAgentId: "agent-missing", + projectId: "project-missing", + projectWorkspaceId: "workspace-missing", + goalId: "goal-missing", + }), + ], + targetIssues: [], + sourceComments: [], + targetComments: [], + targetAgents: [], + targetProjects: [], + targetProjectWorkspaces: [], + targetGoals: [], + }); + + const insert = plan.issuePlans[0] as any; + expect(insert.targetStatus).toBe("todo"); + expect(insert.targetAssigneeAgentId).toBeNull(); + expect(insert.targetProjectId).toBeNull(); + expect(insert.targetProjectWorkspaceId).toBeNull(); + expect(insert.targetGoalId).toBeNull(); + expect(insert.adjustments).toEqual([ + "clear_assignee_agent", + "clear_project", + "clear_project_workspace", + "clear_goal", + "coerce_in_progress_to_todo", + ]); + }); + + it("applies an explicit project mapping override instead of clearing the project", () => { + const plan = buildWorktreeMergePlan({ + companyId: "company-1", + companyName: "Paperclip", + issuePrefix: "PAP", + previewIssueCounterStart: 10, + scopes: ["issues"], + sourceIssues: [ + makeIssue({ + id: "issue-project-map", + identifier: "PAP-77", + projectId: "source-project-1", + projectWorkspaceId: "source-workspace-1", + }), + ], + targetIssues: [], + sourceComments: [], + targetComments: [], + targetAgents: [], + targetProjects: [{ id: "target-project-1", name: "Mapped project", status: "in_progress" }] as any, + targetProjectWorkspaces: [], + targetGoals: [{ id: "goal-1" }] as any, + projectIdOverrides: { + "source-project-1": "target-project-1", + }, + }); + + const insert = plan.issuePlans[0] as any; + expect(insert.targetProjectId).toBe("target-project-1"); + expect(insert.projectResolution).toBe("mapped"); + expect(insert.mappedProjectName).toBe("Mapped project"); + expect(insert.targetProjectWorkspaceId).toBeNull(); + expect(insert.adjustments).toEqual(["clear_project_workspace"]); + }); + + it("imports comments onto shared or newly imported issues while skipping existing comments", () => { + const sharedIssue = makeIssue({ id: "issue-a", identifier: "PAP-10" }); + const newIssue = makeIssue({ + id: "issue-b", + identifier: "PAP-11", + createdAt: new Date("2026-03-20T01:00:00.000Z"), + }); + const existingComment = makeComment({ id: "comment-existing", issueId: "issue-a" }); + const sharedIssueComment = makeComment({ id: "comment-shared", issueId: "issue-a" }); + const newIssueComment = makeComment({ + id: "comment-new-issue", + issueId: "issue-b", + authorAgentId: "missing-agent", + createdAt: new Date("2026-03-20T01:05:00.000Z"), + }); + + const plan = buildWorktreeMergePlan({ + companyId: "company-1", + companyName: "Paperclip", + issuePrefix: "PAP", + previewIssueCounterStart: 10, + scopes: ["issues", "comments"], + sourceIssues: [sharedIssue, newIssue], + targetIssues: [sharedIssue], + sourceComments: [existingComment, sharedIssueComment, newIssueComment], + targetComments: [existingComment], + targetAgents: [], + targetProjects: [], + targetProjectWorkspaces: [], + targetGoals: [{ id: "goal-1" }] as any, + }); + + expect(plan.counts.commentsToInsert).toBe(2); + expect(plan.counts.commentsExisting).toBe(1); + expect(plan.commentPlans.filter((item) => item.action === "insert").map((item) => item.source.id)).toEqual([ + "comment-shared", + "comment-new-issue", + ]); + 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/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index a8333ba5..ca48b001 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { copyGitHooksToWorktreeGitDir, copySeededSecretsKey, + readSourceAttachmentBody, rebindWorkspaceCwd, resolveSourceConfigPath, resolveGitWorktreeAddArgs, @@ -195,6 +196,43 @@ describe("worktree helpers", () => { expect(formatShellExports(env)).toContain("export PAPERCLIP_INSTANCE_ID='feature-worktree-support'"); }); + it("falls back across storage roots before skipping a missing attachment object", async () => { + const missingErr = Object.assign(new Error("missing"), { code: "ENOENT" }); + const expected = Buffer.from("image-bytes"); + await expect( + readSourceAttachmentBody( + [ + { + getObject: vi.fn().mockRejectedValue(missingErr), + }, + { + getObject: vi.fn().mockResolvedValue(expected), + }, + ], + "company-1", + "company-1/issues/issue-1/missing.png", + ), + ).resolves.toEqual(expected); + }); + + it("returns null when an attachment object is missing from every lookup storage", async () => { + const missingErr = Object.assign(new Error("missing"), { code: "ENOENT" }); + await expect( + readSourceAttachmentBody( + [ + { + getObject: vi.fn().mockRejectedValue(missingErr), + }, + { + getObject: vi.fn().mockRejectedValue(Object.assign(new Error("missing"), { status: 404 })), + }, + ], + "company-1", + "company-1/issues/issue-1/missing.png", + ), + ).resolves.toBeNull(); + }); + it("generates vivid worktree colors as hex", () => { expect(generateWorktreeColor()).toMatch(/^#[0-9a-f]{6}$/); }); diff --git a/cli/src/commands/worktree-merge-history-lib.ts b/cli/src/commands/worktree-merge-history-lib.ts new file mode 100644 index 00000000..a55a22d3 --- /dev/null +++ b/cli/src/commands/worktree-merge-history-lib.ts @@ -0,0 +1,709 @@ +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; + commentPlans: Array; + documentPlans: Array; + attachmentPlans: Array; + 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; +}; + +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, + adjustment: ImportAdjustment, +): void { + 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(); + + const depthFor = (issue: IssueRow, stack = new Set()): 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; +}): 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 = { + 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 = []; + 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([ + ...input.targetIssues.map((issue) => issue.id), + ...issuePlans.filter((plan): plan is PlannedIssueInsert => plan.action === "insert").map((plan) => plan.source.id), + ]); + + const commentPlans: Array = []; + 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([ + ...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, + 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, + }; +} diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index b77317fd..285d57c0 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,15 +16,28 @@ 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 { eq } from "drizzle-orm"; +import { and, eq, inArray, sql } from "drizzle-orm"; import { applyPendingMigrations, + agents, + assets, + companies, createDb, + documentRevisions, + documents, ensurePostgresDatabase, formatDatabaseBackupResult, + goals, + heartbeatRuns, + issueAttachments, + issueComments, + issueDocuments, + issues, projectWorkspaces, + projects, runDatabaseBackup, runDatabaseRestore, } from "@paperclipai/db"; @@ -48,6 +62,18 @@ import { type WorktreeSeedMode, type WorktreeLocalPaths, } from "./worktree-lib.js"; +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"; type WorktreeInitOptions = { name?: string; @@ -73,6 +99,20 @@ type WorktreeEnvOptions = { json?: boolean; }; +type WorktreeListOptions = { + json?: boolean; +}; + +type WorktreeMergeHistoryOptions = { + from?: string; + to?: string; + company?: string; + scope?: string; + apply?: boolean; + dry?: boolean; + yes?: boolean; +}; + type EmbeddedPostgresInstance = { initialise(): Promise; start(): Promise; @@ -153,6 +193,190 @@ 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 isMissingStorageObjectError(error: unknown): boolean { + if (!error || typeof error !== "object") return false; + const candidate = error as { code?: unknown; status?: unknown; name?: unknown; message?: unknown }; + return candidate.code === "ENOENT" + || candidate.status === 404 + || candidate.name === "NoSuchKey" + || candidate.name === "NotFound" + || candidate.message === "Object not found."; +} + +export async function readSourceAttachmentBody( + sourceStorages: Array>, + companyId: string, + objectKey: string, +): Promise { + for (const sourceStorage of sourceStorages) { + try { + return await sourceStorage.getObject(companyId, objectKey); + } catch (error) { + if (isMissingStorageObjectError(error)) { + continue; + } + throw error; + } + } + return null; +} + export function resolveWorktreeMakeTargetPath(name: string): string { return path.resolve(os.homedir(), resolveWorktreeMakeName(name)); } @@ -838,6 +1062,21 @@ type GitWorktreeListEntry = { detached: boolean; }; +type MergeSourceChoice = { + worktree: string; + branch: string | null; + branchLabel: string; + hasPaperclipConfig: boolean; + isCurrent: boolean; +}; + +type ResolvedWorktreeEndpoint = { + rootPath: string; + configPath: string; + label: string; + isCurrent: boolean; +}; + function parseGitWorktreeList(cwd: string): GitWorktreeListEntry[] { const raw = execFileSync("git", ["worktree", "list", "--porcelain"], { cwd, @@ -876,6 +1115,21 @@ function parseGitWorktreeList(cwd: string): GitWorktreeListEntry[] { return entries; } +function toMergeSourceChoices(cwd: string): MergeSourceChoice[] { + const currentCwd = path.resolve(cwd); + return parseGitWorktreeList(cwd).map((entry) => { + const branchLabel = entry.branch?.replace(/^refs\/heads\//, "") ?? "(detached)"; + const worktreePath = path.resolve(entry.worktree); + return { + worktree: worktreePath, + branch: entry.branch, + branchLabel, + hasPaperclipConfig: existsSync(path.resolve(worktreePath, ".paperclip", "config.json")), + isCurrent: worktreePath === currentCwd, + }; + }); +} + function branchHasUniqueCommits(cwd: string, branchName: string): boolean { try { const output = execFileSync( @@ -1071,6 +1325,1060 @@ export async function worktreeEnvCommand(opts: WorktreeEnvOptions): Promise & { + $client?: { end?: (opts?: { timeout?: number }) => Promise }; +}; + +type OpenDbHandle = { + db: ClosableDb; + stop: () => Promise; +}; + +type ResolvedMergeCompany = { + id: string; + name: string; + issuePrefix: string; +}; + +async function closeDb(db: ClosableDb): Promise { + await db.$client?.end?.({ timeout: 5 }).catch(() => undefined); +} + +function resolveCurrentEndpoint(): ResolvedWorktreeEndpoint { + return { + rootPath: path.resolve(process.cwd()), + configPath: resolveConfigPath(), + label: "current", + isCurrent: true, + }; +} + +function resolveAttachmentLookupStorages(input: { + sourceEndpoint: ResolvedWorktreeEndpoint; + targetEndpoint: ResolvedWorktreeEndpoint; +}): ConfiguredStorage[] { + const orderedConfigPaths = [ + input.sourceEndpoint.configPath, + resolveCurrentEndpoint().configPath, + input.targetEndpoint.configPath, + ...toMergeSourceChoices(process.cwd()) + .filter((choice) => choice.hasPaperclipConfig) + .map((choice) => path.resolve(choice.worktree, ".paperclip", "config.json")), + ]; + const seen = new Set(); + const storages: ConfiguredStorage[] = []; + for (const configPath of orderedConfigPaths) { + const resolved = path.resolve(configPath); + if (seen.has(resolved) || !existsSync(resolved)) continue; + seen.add(resolved); + storages.push(openConfiguredStorage(resolved)); + } + return storages; +} + +async function openConfiguredDb(configPath: string): Promise { + const config = readConfig(configPath); + if (!config) { + throw new Error(`Config not found at ${configPath}.`); + } + const envEntries = readPaperclipEnvEntries(resolvePaperclipEnvFile(configPath)); + let embeddedHandle: EmbeddedPostgresHandle | null = null; + + try { + if (config.database.mode === "embedded-postgres") { + embeddedHandle = await ensureEmbeddedPostgres( + config.database.embeddedPostgresDataDir, + config.database.embeddedPostgresPort, + ); + } + const connectionString = resolveSourceConnectionString(config, envEntries, embeddedHandle?.port); + const db = createDb(connectionString) as ClosableDb; + return { + db, + stop: async () => { + await closeDb(db); + if (embeddedHandle?.startedByThisProcess) { + await embeddedHandle.stop(); + } + }, + }; + } catch (error) { + if (embeddedHandle?.startedByThisProcess) { + await embeddedHandle.stop().catch(() => undefined); + } + throw error; + } +} + +async function resolveMergeCompany(input: { + sourceDb: ClosableDb; + targetDb: ClosableDb; + selector?: string; +}): Promise { + const [sourceCompanies, targetCompanies] = await Promise.all([ + input.sourceDb + .select({ + id: companies.id, + name: companies.name, + issuePrefix: companies.issuePrefix, + }) + .from(companies), + input.targetDb + .select({ + id: companies.id, + name: companies.name, + issuePrefix: companies.issuePrefix, + }) + .from(companies), + ]); + + const targetById = new Map(targetCompanies.map((company) => [company.id, company])); + const shared = sourceCompanies.filter((company) => targetById.has(company.id)); + const selector = nonEmpty(input.selector); + if (selector) { + const matched = shared.find( + (company) => company.id === selector || company.issuePrefix.toLowerCase() === selector.toLowerCase(), + ); + if (!matched) { + throw new Error(`Could not resolve company "${selector}" in both source and target databases.`); + } + return matched; + } + + if (shared.length === 1) { + return shared[0]; + } + + if (shared.length === 0) { + throw new Error("Source and target databases do not share a company id. Pass --company explicitly once both sides match."); + } + + const options = shared + .map((company) => `${company.issuePrefix} (${company.name})`) + .join(", "); + throw new Error(`Multiple shared companies found. Re-run with --company . Options: ${options}`); +} + +function renderMergePlan(plan: Awaited>["plan"], extras: { + sourcePath: string; + targetPath: string; + unsupportedRunCount: number; +}): string { + const terminalWidth = Math.max(60, process.stdout.columns ?? 100); + const oneLine = (value: string) => value.replace(/\s+/g, " ").trim(); + const truncateToWidth = (value: string, maxWidth: number) => { + if (maxWidth <= 1) return ""; + if (value.length <= maxWidth) return value; + return `${value.slice(0, Math.max(0, maxWidth - 1)).trimEnd()}…`; + }; + const lines = [ + `Mode: preview`, + `Source: ${extras.sourcePath}`, + `Target: ${extras.targetPath}`, + `Company: ${plan.companyName} (${plan.issuePrefix})`, + "", + "Issues", + `- insert: ${plan.counts.issuesToInsert}`, + `- already present: ${plan.counts.issuesExisting}`, + `- shared/imported issues with drift: ${plan.counts.issueDrift}`, + ]; + + const issueInserts = plan.issuePlans.filter((item): item is PlannedIssueInsert => item.action === "insert"); + if (issueInserts.length > 0) { + lines.push(""); + lines.push("Planned issue imports"); + for (const issue of issueInserts) { + const projectNote = + issue.projectResolution === "mapped" && issue.mappedProjectName + ? ` project->${issue.mappedProjectName}` + : ""; + const adjustments = issue.adjustments.length > 0 ? ` [${issue.adjustments.join(", ")}]` : ""; + const prefix = `- ${issue.source.identifier ?? issue.source.id} -> ${issue.previewIdentifier} (${issue.targetStatus}${projectNote})`; + const title = oneLine(issue.source.title); + const suffix = `${adjustments}${title ? ` ${title}` : ""}`; + lines.push( + `${prefix}${truncateToWidth(suffix, Math.max(8, terminalWidth - prefix.length))}`, + ); + } + } + + if (plan.scopes.includes("comments")) { + lines.push(""); + lines.push("Comments"); + lines.push(`- insert: ${plan.counts.commentsToInsert}`); + lines.push(`- already present: ${plan.counts.commentsExisting}`); + 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}`); + lines.push(`- cleared projects: ${plan.adjustments.clear_project}`); + 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(""); + lines.push("Identifiers shown above are provisional preview values. `--apply` reserves fresh issue numbers at write time."); + + return lines.join("\n"); +} + +async function collectMergePlan(input: { + sourceDb: ClosableDb; + targetDb: ClosableDb; + company: ResolvedMergeCompany; + scopes: ReturnType; + projectIdOverrides?: Record; +}) { + const companyId = input.company.id; + 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, + }) + .from(companies) + .where(eq(companies.id, companyId)) + .then((rows) => rows[0] ?? null), + input.sourceDb + .select() + .from(issues) + .where(eq(issues.companyId, companyId)), + input.targetDb + .select() + .from(issues) + .where(eq(issues.companyId, companyId)), + input.scopes.includes("comments") + ? input.sourceDb + .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) + .where(eq(projects.companyId, companyId)), + input.targetDb + .select() + .from(projects) + .where(eq(projects.companyId, companyId)), + input.targetDb + .select() + .from(agents) + .where(eq(agents.companyId, companyId)), + input.targetDb + .select() + .from(projectWorkspaces) + .where(eq(projectWorkspaces.companyId, companyId)), + input.targetDb + .select() + .from(goals) + .where(eq(goals.companyId, companyId)), + input.sourceDb + .select({ count: sql`count(*)::int` }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.companyId, companyId)), + ]); + + if (!targetCompanyRow) { + throw new Error(`Target company ${companyId} was not found.`); + } + + const plan = buildWorktreeMergePlan({ + companyId, + companyName: input.company.name, + issuePrefix: input.company.issuePrefix, + previewIssueCounterStart: targetCompanyRow.issueCounter, + scopes: input.scopes, + sourceIssues: sourceIssuesRows, + 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, + targetGoals: targetGoalsRows, + projectIdOverrides: input.projectIdOverrides, + }); + + return { + plan, + sourceProjects: sourceProjectsRows, + targetProjects: targetProjectsRows, + unsupportedRunCount: runCountRows[0]?.count ?? 0, + }; +} + +async function promptForProjectMappings(input: { + plan: Awaited>["plan"]; + sourceProjects: Awaited>["sourceProjects"]; + targetProjects: Awaited>["targetProjects"]; +}): Promise> { + const missingProjectIds = [ + ...new Set( + input.plan.issuePlans + .filter((plan): plan is PlannedIssueInsert => plan.action === "insert") + .filter((plan) => !!plan.source.projectId && plan.projectResolution === "cleared") + .map((plan) => plan.source.projectId as string), + ), + ]; + if (missingProjectIds.length === 0 || input.targetProjects.length === 0) { + return {}; + } + + const sourceProjectsById = new Map(input.sourceProjects.map((project) => [project.id, project])); + const targetChoices = [...input.targetProjects] + .sort((left, right) => left.name.localeCompare(right.name)) + .map((project) => ({ + value: project.id, + label: project.name, + hint: project.status, + })); + + const mappings: Record = {}; + for (const sourceProjectId of missingProjectIds) { + const sourceProject = sourceProjectsById.get(sourceProjectId); + if (!sourceProject) continue; + const nameMatch = input.targetProjects.find( + (project) => project.name.trim().toLowerCase() === sourceProject.name.trim().toLowerCase(), + ); + const selection = await p.select({ + message: `Project "${sourceProject.name}" is missing in target. How should ${input.plan.issuePrefix} imports handle it?`, + options: [ + ...(nameMatch + ? [{ + value: nameMatch.id, + label: `Map to ${nameMatch.name}`, + hint: "Recommended: exact name match", + }] + : []), + { + value: null, + label: "Leave unset", + hint: "Keep imported issues without a project", + }, + ...targetChoices.filter((choice) => choice.value !== nameMatch?.id), + ], + initialValue: nameMatch?.id ?? null, + }); + if (p.isCancel(selection)) { + throw new Error("Project mapping cancelled."); + } + mappings[sourceProjectId] = selection; + } + + return mappings; +} + +export async function worktreeListCommand(opts: WorktreeListOptions): Promise { + const choices = toMergeSourceChoices(process.cwd()); + if (opts.json) { + console.log(JSON.stringify(choices, null, 2)); + return; + } + + for (const choice of choices) { + const flags = [ + choice.isCurrent ? "current" : null, + choice.hasPaperclipConfig ? "paperclip" : "no-paperclip-config", + ].filter((value): value is string => value !== null); + p.log.message(`${choice.branchLabel} ${choice.worktree} [${flags.join(", ")}]`); + } +} + +function resolveEndpointFromChoice(choice: MergeSourceChoice): ResolvedWorktreeEndpoint { + if (choice.isCurrent) { + return resolveCurrentEndpoint(); + } + return { + rootPath: choice.worktree, + configPath: path.resolve(choice.worktree, ".paperclip", "config.json"), + label: choice.branchLabel, + isCurrent: false, + }; +} + +function resolveWorktreeEndpointFromSelector( + selector: string, + opts?: { allowCurrent?: boolean }, +): ResolvedWorktreeEndpoint { + const trimmed = selector.trim(); + const allowCurrent = opts?.allowCurrent !== false; + if (trimmed.length === 0) { + throw new Error("Worktree selector cannot be empty."); + } + + const currentEndpoint = resolveCurrentEndpoint(); + if (allowCurrent && trimmed === "current") { + return currentEndpoint; + } + + const choices = toMergeSourceChoices(process.cwd()); + const directPath = path.resolve(trimmed); + if (existsSync(directPath)) { + if (allowCurrent && directPath === currentEndpoint.rootPath) { + return currentEndpoint; + } + const configPath = path.resolve(directPath, ".paperclip", "config.json"); + if (!existsSync(configPath)) { + throw new Error(`Resolved worktree path ${directPath} does not contain .paperclip/config.json.`); + } + return { + rootPath: directPath, + configPath, + label: path.basename(directPath), + isCurrent: false, + }; + } + + const matched = choices.find((choice) => + (allowCurrent || !choice.isCurrent) + && (choice.worktree === directPath + || path.basename(choice.worktree) === trimmed + || choice.branchLabel === trimmed), + ); + if (!matched) { + throw new Error( + `Could not resolve worktree "${selector}". Use a path, a listed worktree directory name, branch name, or "current".`, + ); + } + if (!matched.hasPaperclipConfig && !matched.isCurrent) { + throw new Error(`Resolved worktree "${selector}" does not look like a Paperclip worktree.`); + } + return resolveEndpointFromChoice(matched); +} + +async function promptForSourceEndpoint(excludeWorktreePath?: string): Promise { + const excluded = excludeWorktreePath ? path.resolve(excludeWorktreePath) : null; + const currentEndpoint = resolveCurrentEndpoint(); + const choices = toMergeSourceChoices(process.cwd()) + .filter((choice) => choice.hasPaperclipConfig || choice.isCurrent) + .filter((choice) => path.resolve(choice.worktree) !== excluded) + .map((choice) => ({ + value: choice.isCurrent ? "__current__" : choice.worktree, + label: choice.branchLabel, + hint: `${choice.worktree}${choice.isCurrent ? " (current)" : ""}`, + })); + if (choices.length === 0) { + throw new Error("No Paperclip worktrees were found. Run `paperclipai worktree:list` to inspect the repo worktrees."); + } + const selection = await p.select({ + message: "Choose the source worktree to import from", + options: choices, + }); + if (p.isCancel(selection)) { + throw new Error("Source worktree selection cancelled."); + } + if (selection === "__current__") { + return currentEndpoint; + } + return resolveWorktreeEndpointFromSelector(selection, { allowCurrent: true }); +} + +async function applyMergePlan(input: { + sourceStorages: ConfiguredStorage[]; + targetStorage: ConfiguredStorage; + targetDb: ClosableDb; + company: ResolvedMergeCompany; + plan: Awaited>["plan"]; +}) { + const companyId = input.company.id; + + return await input.targetDb.transaction(async (tx) => { + const issueCandidates = input.plan.issuePlans.filter( + (plan): plan is PlannedIssueInsert => plan.action === "insert", + ); + const issueCandidateIds = issueCandidates.map((issue) => issue.source.id); + const existingIssueIds = issueCandidateIds.length > 0 + ? new Set( + (await tx + .select({ id: issues.id }) + .from(issues) + .where(inArray(issues.id, issueCandidateIds))) + .map((row) => row.id), + ) + : new Set(); + const issueInserts = issueCandidates.filter((issue) => !existingIssueIds.has(issue.source.id)); + + let nextIssueNumber = 0; + if (issueInserts.length > 0) { + const [companyRow] = await tx + .update(companies) + .set({ issueCounter: sql`${companies.issueCounter} + ${issueInserts.length}` }) + .where(eq(companies.id, companyId)) + .returning({ issueCounter: companies.issueCounter }); + nextIssueNumber = companyRow.issueCounter - issueInserts.length + 1; + } + + const insertedIssueIdentifiers = new Map(); + let insertedIssues = 0; + for (const issue of issueInserts) { + const issueNumber = nextIssueNumber; + nextIssueNumber += 1; + const identifier = `${input.company.issuePrefix}-${issueNumber}`; + insertedIssueIdentifiers.set(issue.source.id, identifier); + await tx.insert(issues).values({ + id: issue.source.id, + companyId, + projectId: issue.targetProjectId, + projectWorkspaceId: issue.targetProjectWorkspaceId, + goalId: issue.targetGoalId, + parentId: issue.source.parentId, + title: issue.source.title, + description: issue.source.description, + status: issue.targetStatus, + priority: issue.source.priority, + assigneeAgentId: issue.targetAssigneeAgentId, + assigneeUserId: issue.source.assigneeUserId, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + createdByAgentId: issue.targetCreatedByAgentId, + createdByUserId: issue.source.createdByUserId, + issueNumber, + identifier, + requestDepth: issue.source.requestDepth, + billingCode: issue.source.billingCode, + assigneeAdapterOverrides: issue.targetAssigneeAgentId ? issue.source.assigneeAdapterOverrides : null, + executionWorkspaceId: null, + executionWorkspacePreference: null, + executionWorkspaceSettings: null, + startedAt: issue.source.startedAt, + completedAt: issue.source.completedAt, + cancelledAt: issue.source.cancelledAt, + hiddenAt: issue.source.hiddenAt, + createdAt: issue.source.createdAt, + updatedAt: issue.source.updatedAt, + }); + insertedIssues += 1; + } + + const commentCandidates = input.plan.commentPlans.filter( + (plan): plan is PlannedCommentInsert => plan.action === "insert", + ); + const commentCandidateIds = commentCandidates.map((comment) => comment.source.id); + const existingCommentIds = commentCandidateIds.length > 0 + ? new Set( + (await tx + .select({ id: issueComments.id }) + .from(issueComments) + .where(inArray(issueComments.id, commentCandidateIds))) + .map((row) => row.id), + ) + : new Set(); + + let insertedComments = 0; + for (const comment of commentCandidates) { + if (existingCommentIds.has(comment.source.id)) continue; + const parentExists = await tx + .select({ id: issues.id }) + .from(issues) + .where(and(eq(issues.id, comment.source.issueId), eq(issues.companyId, companyId))) + .then((rows) => rows[0] ?? null); + if (!parentExists) continue; + await tx.insert(issueComments).values({ + id: comment.source.id, + companyId, + issueId: comment.source.issueId, + authorAgentId: comment.targetAuthorAgentId, + authorUserId: comment.source.authorUserId, + body: comment.source.body, + 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; + let skippedMissingAttachmentObjects = 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 readSourceAttachmentBody( + input.sourceStorages, + companyId, + attachment.source.objectKey, + ); + if (!body) { + skippedMissingAttachmentObjects += 1; + continue; + } + 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, + insertedComments, + insertedDocuments, + mergedDocuments, + insertedDocumentRevisions, + insertedAttachments, + skippedMissingAttachmentObjects, + insertedIssueIdentifiers, + }; + }); +} + +export async function worktreeMergeHistoryCommand(sourceArg: string | undefined, opts: WorktreeMergeHistoryOptions): Promise { + if (opts.apply && opts.dry) { + throw new Error("Use either --apply or --dry, not both."); + } + + if (sourceArg && opts.from) { + throw new Error("Use either the positional source argument or --from, not both."); + } + + const targetEndpoint = opts.to + ? resolveWorktreeEndpointFromSelector(opts.to, { allowCurrent: true }) + : resolveCurrentEndpoint(); + const sourceEndpoint = opts.from + ? resolveWorktreeEndpointFromSelector(opts.from, { allowCurrent: true }) + : sourceArg + ? resolveWorktreeEndpointFromSelector(sourceArg, { allowCurrent: true }) + : await promptForSourceEndpoint(targetEndpoint.rootPath); + + if (path.resolve(sourceEndpoint.configPath) === path.resolve(targetEndpoint.configPath)) { + throw new Error("Source and target Paperclip configs are the same. Choose different --from/--to worktrees."); + } + + const scopes = parseWorktreeMergeScopes(opts.scope); + const sourceHandle = await openConfiguredDb(sourceEndpoint.configPath); + const targetHandle = await openConfiguredDb(targetEndpoint.configPath); + const sourceStorages = resolveAttachmentLookupStorages({ + sourceEndpoint, + targetEndpoint, + }); + const targetStorage = openConfiguredStorage(targetEndpoint.configPath); + + try { + const company = await resolveMergeCompany({ + sourceDb: sourceHandle.db, + targetDb: targetHandle.db, + selector: opts.company, + }); + let collected = await collectMergePlan({ + sourceDb: sourceHandle.db, + targetDb: targetHandle.db, + company, + scopes, + }); + if (!opts.yes) { + const projectIdOverrides = await promptForProjectMappings({ + plan: collected.plan, + sourceProjects: collected.sourceProjects, + targetProjects: collected.targetProjects, + }); + if (Object.keys(projectIdOverrides).length > 0) { + collected = await collectMergePlan({ + sourceDb: sourceHandle.db, + targetDb: targetHandle.db, + company, + scopes, + projectIdOverrides, + }); + } + } + + console.log(renderMergePlan(collected.plan, { + sourcePath: `${sourceEndpoint.label} (${sourceEndpoint.rootPath})`, + targetPath: `${targetEndpoint.label} (${targetEndpoint.rootPath})`, + unsupportedRunCount: collected.unsupportedRunCount, + })); + + if (!opts.apply) { + return; + } + + const confirmed = opts.yes + ? true + : await p.confirm({ + message: `Import ${collected.plan.counts.issuesToInsert} issues and ${collected.plan.counts.commentsToInsert} comments from ${sourceEndpoint.label} into ${targetEndpoint.label}?`, + initialValue: false, + }); + if (p.isCancel(confirmed) || !confirmed) { + p.log.warn("Import cancelled."); + return; + } + + const applied = await applyMergePlan({ + sourceStorages, + targetStorage, + targetDb: targetHandle.db, + company, + plan: collected.plan, + }); + if (applied.skippedMissingAttachmentObjects > 0) { + p.log.warn( + `Skipped ${applied.skippedMissingAttachmentObjects} attachments whose source files were missing from storage.`, + ); + } + p.outro( + 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}.`, + ), + ); + } finally { + await targetHandle.stop(); + await sourceHandle.stop(); + } +} + export function registerWorktreeCommands(program: Command): void { const worktree = program.command("worktree").description("Worktree-local Paperclip instance helpers"); @@ -1114,6 +2422,25 @@ export function registerWorktreeCommands(program: Command): void { .option("--json", "Print JSON instead of shell exports") .action(worktreeEnvCommand); + program + .command("worktree:list") + .description("List git worktrees visible from this repo and whether they look like Paperclip worktrees") + .option("--json", "Print JSON instead of text output") + .action(worktreeListCommand); + + program + .command("worktree:merge-history") + .description("Preview or import issue/comment history from another worktree into the current instance") + .argument("[source]", "Optional source worktree path, directory name, or branch name (back-compat alias for --from)") + .option("--from ", "Source worktree path, directory name, branch name, or current") + .option("--to ", "Target worktree path, directory name, branch name, or current (defaults to current)") + .option("--company ", "Shared company id or issue prefix inside the chosen source/target instances") + .option("--scope ", "Comma-separated scopes to import (issues, comments)", "issues,comments") + .option("--apply", "Apply the import after previewing the plan", false) + .option("--dry", "Preview only and do not import anything", false) + .option("--yes", "Skip the interactive confirmation prompt when applying", false) + .action(worktreeMergeHistoryCommand); + program .command("worktree:cleanup") .description("Safely remove a worktree, its branch, and its isolated instance data") diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 5b1b37d9..3715e4e6 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -65,6 +65,7 @@ export type CreateIssueLabel = z.infer; export const updateIssueSchema = createIssueSchema.partial().extend({ comment: z.string().min(1).optional(), + reopen: z.boolean().optional(), hiddenAt: z.string().datetime().nullable().optional(), }); diff --git a/server/src/__tests__/issue-comment-reopen-routes.test.ts b/server/src/__tests__/issue-comment-reopen-routes.test.ts new file mode 100644 index 00000000..f7f54c23 --- /dev/null +++ b/server/src/__tests__/issue-comment-reopen-routes.test.ts @@ -0,0 +1,143 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { issueRoutes } from "../routes/issues.js"; +import { errorHandler } from "../middleware/index.js"; + +const mockIssueService = vi.hoisted(() => ({ + getById: vi.fn(), + update: vi.fn(), + addComment: vi.fn(), + findMentionedAgents: vi.fn(), +})); + +const mockAccessService = vi.hoisted(() => ({ + canUser: vi.fn(), + hasPermission: vi.fn(), +})); + +const mockHeartbeatService = vi.hoisted(() => ({ + wakeup: vi.fn(async () => undefined), + reportRunActivity: vi.fn(async () => undefined), +})); + +const mockAgentService = vi.hoisted(() => ({ + getById: vi.fn(), +})); + +const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); + +vi.mock("../services/index.js", () => ({ + accessService: () => mockAccessService, + agentService: () => mockAgentService, + documentService: () => ({}), + executionWorkspaceService: () => ({}), + goalService: () => ({}), + heartbeatService: () => mockHeartbeatService, + issueApprovalService: () => ({}), + issueService: () => mockIssueService, + logActivity: mockLogActivity, + projectService: () => ({}), + workProductService: () => ({}), +})); + +function createApp() { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + }; + next(); + }); + app.use("/api", issueRoutes({} as any, {} as any)); + app.use(errorHandler); + return app; +} + +function makeIssue(status: "todo" | "done") { + return { + id: "11111111-1111-4111-8111-111111111111", + companyId: "company-1", + status, + assigneeAgentId: "22222222-2222-4222-8222-222222222222", + assigneeUserId: null, + createdByUserId: "local-board", + identifier: "PAP-580", + title: "Comment reopen default", + }; +} + +describe("issue comment reopen routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIssueService.addComment.mockResolvedValue({ + id: "comment-1", + issueId: "11111111-1111-4111-8111-111111111111", + companyId: "company-1", + body: "hello", + createdAt: new Date(), + updatedAt: new Date(), + authorAgentId: null, + authorUserId: "local-board", + }); + mockIssueService.findMentionedAgents.mockResolvedValue([]); + }); + + it("treats reopen=true as a no-op when the issue is already open", async () => { + mockIssueService.getById.mockResolvedValue(makeIssue("todo")); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...makeIssue("todo"), + ...patch, + })); + + const res = await request(createApp()) + .patch("/api/issues/11111111-1111-4111-8111-111111111111") + .send({ comment: "hello", reopen: true, assigneeAgentId: "33333333-3333-4333-8333-333333333333" }); + + expect(res.status).toBe(200); + expect(mockIssueService.update).toHaveBeenCalledWith("11111111-1111-4111-8111-111111111111", { + assigneeAgentId: "33333333-3333-4333-8333-333333333333", + }); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "issue.updated", + details: expect.not.objectContaining({ reopened: true }), + }), + ); + }); + + it("reopens closed issues via the PATCH comment path", async () => { + mockIssueService.getById.mockResolvedValue(makeIssue("done")); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...makeIssue("done"), + ...patch, + })); + + const res = await request(createApp()) + .patch("/api/issues/11111111-1111-4111-8111-111111111111") + .send({ comment: "hello", reopen: true, assigneeAgentId: "33333333-3333-4333-8333-333333333333" }); + + expect(res.status).toBe(200); + expect(mockIssueService.update).toHaveBeenCalledWith("11111111-1111-4111-8111-111111111111", { + assigneeAgentId: "33333333-3333-4333-8333-333333333333", + status: "todo", + }); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "issue.updated", + details: expect.objectContaining({ + reopened: true, + reopenedFrom: "done", + status: "todo", + }), + }), + ); + }); +}); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 0e19a871..43eebe66 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -824,10 +824,14 @@ export function issueRoutes(db: Db, storage: StorageService) { if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return; const actor = getActorInfo(req); - const { comment: commentBody, hiddenAt: hiddenAtRaw, ...updateFields } = req.body; + const isClosed = existing.status === "done" || existing.status === "cancelled"; + const { comment: commentBody, reopen: reopenRequested, hiddenAt: hiddenAtRaw, ...updateFields } = req.body; if (hiddenAtRaw !== undefined) { updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null; } + if (commentBody && reopenRequested === true && isClosed && updateFields.status === undefined) { + updateFields.status = "todo"; + } let issue; try { issue = await svc.update(id, updateFields); @@ -875,6 +879,13 @@ export function issueRoutes(db: Db, storage: StorageService) { } const hasFieldChanges = Object.keys(previous).length > 0; + const reopened = + commentBody && + reopenRequested === true && + isClosed && + previous.status !== undefined && + issue.status === "todo"; + const reopenFromStatus = reopened ? existing.status : null; await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, @@ -888,6 +899,7 @@ export function issueRoutes(db: Db, storage: StorageService) { ...updateFields, identifier: issue.identifier, ...(commentBody ? { source: "comment" } : {}), + ...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus } : {}), _previous: hasFieldChanges ? previous : undefined, }, }); @@ -913,6 +925,7 @@ export function issueRoutes(db: Db, storage: StorageService) { bodySnippet: comment.body.slice(0, 120), identifier: issue.identifier, issueTitle: issue.title, + ...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}), ...(hasFieldChanges ? { updated: true } : {}), }, }); diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index 0e97f31a..151e1064 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -50,7 +50,6 @@ interface CommentThreadProps { mentions?: MentionOption[]; } -const CLOSED_STATUSES = new Set(["done", "cancelled"]); const DRAFT_DEBOUNCE_MS = 800; function loadDraft(draftKey: string): string { @@ -261,7 +260,6 @@ export function CommentThread({ companyId, projectId, onAdd, - issueStatus, agentMap, imageUploadHandler, onAttachImage, @@ -286,8 +284,6 @@ export function CommentThread({ const location = useLocation(); const hasScrolledRef = useRef(false); - const isClosed = issueStatus ? CLOSED_STATUSES.has(issueStatus) : false; - const timeline = useMemo(() => { const commentItems: TimelineItem[] = comments.map((comment) => ({ kind: "comment", @@ -369,10 +365,10 @@ export function CommentThread({ setSubmitting(true); try { - await onAdd(trimmed, isClosed && reopen ? true : undefined, reassignment ?? undefined); + await onAdd(trimmed, reopen ? true : undefined, reassignment ?? undefined); setBody(""); if (draftKey) clearDraft(draftKey); - setReopen(false); + setReopen(true); setReassignTarget(effectiveSuggestedAssigneeValue); } finally { setSubmitting(false); @@ -439,17 +435,15 @@ export function CommentThread({ )} - {isClosed && ( - - )} + {enableReassign && reassignOptions.length > 0 && (