Import worktree documents and attachments
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -53,6 +53,68 @@ function makeComment(overrides: Record<string, unknown> = {}) {
|
||||
} as any;
|
||||
}
|
||||
|
||||
function makeIssueDocument(overrides: Record<string, unknown> = {}) {
|
||||
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<string, unknown> = {}) {
|
||||
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<string, unknown> = {}) {
|
||||
return {
|
||||
id: "attachment-1",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
issueCommentId: null,
|
||||
assetId: "asset-1",
|
||||
provider: "local_disk",
|
||||
objectKey: "company-1/issues/issue-1/2026/03/20/asset.png",
|
||||
contentType: "image/png",
|
||||
byteSize: 12,
|
||||
sha256: "deadbeef",
|
||||
originalFilename: "asset.png",
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "local-board",
|
||||
assetCreatedAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
assetUpdatedAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
attachmentCreatedAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
attachmentUpdatedAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
...overrides,
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("worktree merge history planner", () => {
|
||||
it("parses default scopes", () => {
|
||||
expect(parseWorktreeMergeScopes(undefined)).toEqual(["issues", "comments"]);
|
||||
@@ -214,4 +276,117 @@ describe("worktree merge history planner", () => {
|
||||
]);
|
||||
expect(plan.adjustments.clear_author_agent).toBe(1);
|
||||
});
|
||||
|
||||
it("merges document revisions onto an existing shared document and renumbers conflicts", () => {
|
||||
const sharedIssue = makeIssue({ id: "issue-a", identifier: "PAP-10" });
|
||||
const sourceDocument = makeIssueDocument({
|
||||
issueId: "issue-a",
|
||||
documentId: "document-a",
|
||||
latestBody: "# Branch plan",
|
||||
latestRevisionId: "revision-branch-2",
|
||||
latestRevisionNumber: 2,
|
||||
documentUpdatedAt: new Date("2026-03-20T02:00:00.000Z"),
|
||||
linkUpdatedAt: new Date("2026-03-20T02:00:00.000Z"),
|
||||
});
|
||||
const targetDocument = makeIssueDocument({
|
||||
issueId: "issue-a",
|
||||
documentId: "document-a",
|
||||
latestBody: "# Main plan",
|
||||
latestRevisionId: "revision-main-2",
|
||||
latestRevisionNumber: 2,
|
||||
documentUpdatedAt: new Date("2026-03-20T01:00:00.000Z"),
|
||||
linkUpdatedAt: new Date("2026-03-20T01:00:00.000Z"),
|
||||
});
|
||||
const sourceRevisionOne = makeDocumentRevision({ documentId: "document-a", id: "revision-1" });
|
||||
const sourceRevisionTwo = makeDocumentRevision({
|
||||
documentId: "document-a",
|
||||
id: "revision-branch-2",
|
||||
revisionNumber: 2,
|
||||
body: "# Branch plan",
|
||||
createdAt: new Date("2026-03-20T02:00:00.000Z"),
|
||||
});
|
||||
const targetRevisionOne = makeDocumentRevision({ documentId: "document-a", id: "revision-1" });
|
||||
const targetRevisionTwo = makeDocumentRevision({
|
||||
documentId: "document-a",
|
||||
id: "revision-main-2",
|
||||
revisionNumber: 2,
|
||||
body: "# Main plan",
|
||||
createdAt: new Date("2026-03-20T01:00:00.000Z"),
|
||||
});
|
||||
|
||||
const plan = buildWorktreeMergePlan({
|
||||
companyId: "company-1",
|
||||
companyName: "Paperclip",
|
||||
issuePrefix: "PAP",
|
||||
previewIssueCounterStart: 10,
|
||||
scopes: ["issues", "comments"],
|
||||
sourceIssues: [sharedIssue],
|
||||
targetIssues: [sharedIssue],
|
||||
sourceComments: [],
|
||||
targetComments: [],
|
||||
sourceDocuments: [sourceDocument],
|
||||
targetDocuments: [targetDocument],
|
||||
sourceDocumentRevisions: [sourceRevisionOne, sourceRevisionTwo],
|
||||
targetDocumentRevisions: [targetRevisionOne, targetRevisionTwo],
|
||||
sourceAttachments: [],
|
||||
targetAttachments: [],
|
||||
targetAgents: [],
|
||||
targetProjects: [],
|
||||
targetProjectWorkspaces: [],
|
||||
targetGoals: [{ id: "goal-1" }] as any,
|
||||
});
|
||||
|
||||
expect(plan.counts.documentsToMerge).toBe(1);
|
||||
expect(plan.counts.documentRevisionsToInsert).toBe(1);
|
||||
expect(plan.documentPlans[0]).toMatchObject({
|
||||
action: "merge_existing",
|
||||
latestRevisionId: "revision-branch-2",
|
||||
latestRevisionNumber: 3,
|
||||
});
|
||||
const mergePlan = plan.documentPlans[0] as any;
|
||||
expect(mergePlan.revisionsToInsert).toHaveLength(1);
|
||||
expect(mergePlan.revisionsToInsert[0]).toMatchObject({
|
||||
source: { id: "revision-branch-2" },
|
||||
targetRevisionNumber: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("imports attachments while clearing missing comment and author references", () => {
|
||||
const sharedIssue = makeIssue({ id: "issue-a", identifier: "PAP-10" });
|
||||
const attachment = makeAttachment({
|
||||
issueId: "issue-a",
|
||||
issueCommentId: "comment-missing",
|
||||
createdByAgentId: "agent-missing",
|
||||
});
|
||||
|
||||
const plan = buildWorktreeMergePlan({
|
||||
companyId: "company-1",
|
||||
companyName: "Paperclip",
|
||||
issuePrefix: "PAP",
|
||||
previewIssueCounterStart: 10,
|
||||
scopes: ["issues"],
|
||||
sourceIssues: [sharedIssue],
|
||||
targetIssues: [sharedIssue],
|
||||
sourceComments: [],
|
||||
targetComments: [],
|
||||
sourceDocuments: [],
|
||||
targetDocuments: [],
|
||||
sourceDocumentRevisions: [],
|
||||
targetDocumentRevisions: [],
|
||||
sourceAttachments: [attachment],
|
||||
targetAttachments: [],
|
||||
targetAgents: [],
|
||||
targetProjects: [],
|
||||
targetProjectWorkspaces: [],
|
||||
targetGoals: [{ id: "goal-1" }] as any,
|
||||
});
|
||||
|
||||
expect(plan.counts.attachmentsToInsert).toBe(1);
|
||||
expect(plan.adjustments.clear_attachment_agent).toBe(1);
|
||||
expect(plan.attachmentPlans[0]).toMatchObject({
|
||||
action: "insert",
|
||||
targetIssueCommentId: null,
|
||||
targetCreatedByAgentId: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import {
|
||||
agents,
|
||||
assets,
|
||||
documentRevisions,
|
||||
goals,
|
||||
issueAttachments,
|
||||
issueComments,
|
||||
issueDocuments,
|
||||
issues,
|
||||
projects,
|
||||
projectWorkspaces,
|
||||
@@ -13,6 +17,10 @@ type AgentRow = typeof agents.$inferSelect;
|
||||
type ProjectRow = typeof projects.$inferSelect;
|
||||
type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect;
|
||||
type GoalRow = typeof goals.$inferSelect;
|
||||
type IssueDocumentLinkRow = typeof issueDocuments.$inferSelect;
|
||||
type DocumentRevisionTableRow = typeof documentRevisions.$inferSelect;
|
||||
type IssueAttachmentTableRow = typeof issueAttachments.$inferSelect;
|
||||
type AssetRow = typeof assets.$inferSelect;
|
||||
|
||||
export const WORKTREE_MERGE_SCOPES = ["issues", "comments"] as const;
|
||||
export type WorktreeMergeScope = (typeof WORKTREE_MERGE_SCOPES)[number];
|
||||
@@ -23,7 +31,10 @@ export type ImportAdjustment =
|
||||
| "clear_project_workspace"
|
||||
| "clear_goal"
|
||||
| "clear_author_agent"
|
||||
| "coerce_in_progress_to_todo";
|
||||
| "coerce_in_progress_to_todo"
|
||||
| "clear_document_agent"
|
||||
| "clear_document_revision_agent"
|
||||
| "clear_attachment_agent";
|
||||
|
||||
export type IssueMergeAction = "skip_existing" | "insert";
|
||||
export type CommentMergeAction = "skip_existing" | "skip_missing_parent" | "insert";
|
||||
@@ -62,6 +73,106 @@ export type PlannedCommentSkip = {
|
||||
action: "skip_existing" | "skip_missing_parent";
|
||||
};
|
||||
|
||||
export type IssueDocumentRow = {
|
||||
id: IssueDocumentLinkRow["id"];
|
||||
companyId: IssueDocumentLinkRow["companyId"];
|
||||
issueId: IssueDocumentLinkRow["issueId"];
|
||||
documentId: IssueDocumentLinkRow["documentId"];
|
||||
key: IssueDocumentLinkRow["key"];
|
||||
linkCreatedAt: IssueDocumentLinkRow["createdAt"];
|
||||
linkUpdatedAt: IssueDocumentLinkRow["updatedAt"];
|
||||
title: string | null;
|
||||
format: string;
|
||||
latestBody: string;
|
||||
latestRevisionId: string | null;
|
||||
latestRevisionNumber: number;
|
||||
createdByAgentId: string | null;
|
||||
createdByUserId: string | null;
|
||||
updatedByAgentId: string | null;
|
||||
updatedByUserId: string | null;
|
||||
documentCreatedAt: Date;
|
||||
documentUpdatedAt: Date;
|
||||
};
|
||||
|
||||
export type DocumentRevisionRow = {
|
||||
id: DocumentRevisionTableRow["id"];
|
||||
companyId: DocumentRevisionTableRow["companyId"];
|
||||
documentId: DocumentRevisionTableRow["documentId"];
|
||||
revisionNumber: DocumentRevisionTableRow["revisionNumber"];
|
||||
body: DocumentRevisionTableRow["body"];
|
||||
changeSummary: DocumentRevisionTableRow["changeSummary"];
|
||||
createdByAgentId: string | null;
|
||||
createdByUserId: string | null;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
export type IssueAttachmentRow = {
|
||||
id: IssueAttachmentTableRow["id"];
|
||||
companyId: IssueAttachmentTableRow["companyId"];
|
||||
issueId: IssueAttachmentTableRow["issueId"];
|
||||
issueCommentId: IssueAttachmentTableRow["issueCommentId"];
|
||||
assetId: IssueAttachmentTableRow["assetId"];
|
||||
provider: AssetRow["provider"];
|
||||
objectKey: AssetRow["objectKey"];
|
||||
contentType: AssetRow["contentType"];
|
||||
byteSize: AssetRow["byteSize"];
|
||||
sha256: AssetRow["sha256"];
|
||||
originalFilename: AssetRow["originalFilename"];
|
||||
createdByAgentId: string | null;
|
||||
createdByUserId: string | null;
|
||||
assetCreatedAt: Date;
|
||||
assetUpdatedAt: Date;
|
||||
attachmentCreatedAt: Date;
|
||||
attachmentUpdatedAt: Date;
|
||||
};
|
||||
|
||||
export type PlannedDocumentRevisionInsert = {
|
||||
source: DocumentRevisionRow;
|
||||
targetRevisionNumber: number;
|
||||
targetCreatedByAgentId: string | null;
|
||||
adjustments: ImportAdjustment[];
|
||||
};
|
||||
|
||||
export type PlannedIssueDocumentInsert = {
|
||||
source: IssueDocumentRow;
|
||||
action: "insert";
|
||||
targetCreatedByAgentId: string | null;
|
||||
targetUpdatedByAgentId: string | null;
|
||||
latestRevisionId: string | null;
|
||||
latestRevisionNumber: number;
|
||||
revisionsToInsert: PlannedDocumentRevisionInsert[];
|
||||
adjustments: ImportAdjustment[];
|
||||
};
|
||||
|
||||
export type PlannedIssueDocumentMerge = {
|
||||
source: IssueDocumentRow;
|
||||
action: "merge_existing";
|
||||
targetCreatedByAgentId: string | null;
|
||||
targetUpdatedByAgentId: string | null;
|
||||
latestRevisionId: string | null;
|
||||
latestRevisionNumber: number;
|
||||
revisionsToInsert: PlannedDocumentRevisionInsert[];
|
||||
adjustments: ImportAdjustment[];
|
||||
};
|
||||
|
||||
export type PlannedIssueDocumentSkip = {
|
||||
source: IssueDocumentRow;
|
||||
action: "skip_existing" | "skip_missing_parent" | "skip_conflicting_key";
|
||||
};
|
||||
|
||||
export type PlannedAttachmentInsert = {
|
||||
source: IssueAttachmentRow;
|
||||
action: "insert";
|
||||
targetIssueCommentId: string | null;
|
||||
targetCreatedByAgentId: string | null;
|
||||
adjustments: ImportAdjustment[];
|
||||
};
|
||||
|
||||
export type PlannedAttachmentSkip = {
|
||||
source: IssueAttachmentRow;
|
||||
action: "skip_existing" | "skip_missing_parent";
|
||||
};
|
||||
|
||||
export type WorktreeMergePlan = {
|
||||
companyId: string;
|
||||
companyName: string;
|
||||
@@ -70,6 +181,8 @@ export type WorktreeMergePlan = {
|
||||
scopes: WorktreeMergeScope[];
|
||||
issuePlans: Array<PlannedIssueInsert | PlannedIssueSkip>;
|
||||
commentPlans: Array<PlannedCommentInsert | PlannedCommentSkip>;
|
||||
documentPlans: Array<PlannedIssueDocumentInsert | PlannedIssueDocumentMerge | PlannedIssueDocumentSkip>;
|
||||
attachmentPlans: Array<PlannedAttachmentInsert | PlannedAttachmentSkip>;
|
||||
counts: {
|
||||
issuesToInsert: number;
|
||||
issuesExisting: number;
|
||||
@@ -77,6 +190,15 @@ export type WorktreeMergePlan = {
|
||||
commentsToInsert: number;
|
||||
commentsExisting: number;
|
||||
commentsMissingParent: number;
|
||||
documentsToInsert: number;
|
||||
documentsToMerge: number;
|
||||
documentsExisting: number;
|
||||
documentsConflictingKey: number;
|
||||
documentsMissingParent: number;
|
||||
documentRevisionsToInsert: number;
|
||||
attachmentsToInsert: number;
|
||||
attachmentsExisting: number;
|
||||
attachmentsMissingParent: number;
|
||||
};
|
||||
adjustments: Record<ImportAdjustment, number>;
|
||||
};
|
||||
@@ -103,6 +225,52 @@ function incrementAdjustment(
|
||||
counts[adjustment] += 1;
|
||||
}
|
||||
|
||||
function groupBy<T>(rows: T[], keyFor: (row: T) => string): Map<string, T[]> {
|
||||
const out = new Map<string, T[]>();
|
||||
for (const row of rows) {
|
||||
const key = keyFor(row);
|
||||
const existing = out.get(key);
|
||||
if (existing) {
|
||||
existing.push(row);
|
||||
} else {
|
||||
out.set(key, [row]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function sameDate(left: Date, right: Date): boolean {
|
||||
return left.getTime() === right.getTime();
|
||||
}
|
||||
|
||||
function sortDocumentRows(rows: IssueDocumentRow[]): IssueDocumentRow[] {
|
||||
return [...rows].sort((left, right) => {
|
||||
const createdDelta = left.documentCreatedAt.getTime() - right.documentCreatedAt.getTime();
|
||||
if (createdDelta !== 0) return createdDelta;
|
||||
const linkDelta = left.linkCreatedAt.getTime() - right.linkCreatedAt.getTime();
|
||||
if (linkDelta !== 0) return linkDelta;
|
||||
return left.documentId.localeCompare(right.documentId);
|
||||
});
|
||||
}
|
||||
|
||||
function sortDocumentRevisions(rows: DocumentRevisionRow[]): DocumentRevisionRow[] {
|
||||
return [...rows].sort((left, right) => {
|
||||
const revisionDelta = left.revisionNumber - right.revisionNumber;
|
||||
if (revisionDelta !== 0) return revisionDelta;
|
||||
const createdDelta = left.createdAt.getTime() - right.createdAt.getTime();
|
||||
if (createdDelta !== 0) return createdDelta;
|
||||
return left.id.localeCompare(right.id);
|
||||
});
|
||||
}
|
||||
|
||||
function sortAttachments(rows: IssueAttachmentRow[]): IssueAttachmentRow[] {
|
||||
return [...rows].sort((left, right) => {
|
||||
const createdDelta = left.attachmentCreatedAt.getTime() - right.attachmentCreatedAt.getTime();
|
||||
if (createdDelta !== 0) return createdDelta;
|
||||
return left.id.localeCompare(right.id);
|
||||
});
|
||||
}
|
||||
|
||||
function sortIssuesForImport(sourceIssues: IssueRow[]): IssueRow[] {
|
||||
const byId = new Map(sourceIssues.map((issue) => [issue.id, issue]));
|
||||
const memoDepth = new Map<string, number>();
|
||||
@@ -170,6 +338,12 @@ export function buildWorktreeMergePlan(input: {
|
||||
targetIssues: IssueRow[];
|
||||
sourceComments: CommentRow[];
|
||||
targetComments: CommentRow[];
|
||||
sourceDocuments?: IssueDocumentRow[];
|
||||
targetDocuments?: IssueDocumentRow[];
|
||||
sourceDocumentRevisions?: DocumentRevisionRow[];
|
||||
targetDocumentRevisions?: DocumentRevisionRow[];
|
||||
sourceAttachments?: IssueAttachmentRow[];
|
||||
targetAttachments?: IssueAttachmentRow[];
|
||||
targetAgents: AgentRow[];
|
||||
targetProjects: ProjectRow[];
|
||||
targetProjectWorkspaces: ProjectWorkspaceRow[];
|
||||
@@ -192,6 +366,9 @@ export function buildWorktreeMergePlan(input: {
|
||||
clear_goal: 0,
|
||||
clear_author_agent: 0,
|
||||
coerce_in_progress_to_todo: 0,
|
||||
clear_document_agent: 0,
|
||||
clear_document_revision_agent: 0,
|
||||
clear_attachment_agent: 0,
|
||||
};
|
||||
|
||||
const issuePlans: Array<PlannedIssueInsert | PlannedIssueSkip> = [];
|
||||
@@ -324,6 +501,176 @@ export function buildWorktreeMergePlan(input: {
|
||||
}
|
||||
}
|
||||
|
||||
const sourceDocuments = input.sourceDocuments ?? [];
|
||||
const targetDocuments = input.targetDocuments ?? [];
|
||||
const sourceDocumentRevisions = input.sourceDocumentRevisions ?? [];
|
||||
const targetDocumentRevisions = input.targetDocumentRevisions ?? [];
|
||||
|
||||
const targetDocumentsById = new Map(targetDocuments.map((document) => [document.documentId, document]));
|
||||
const targetDocumentsByIssueKey = new Map(targetDocuments.map((document) => [`${document.issueId}:${document.key}`, document]));
|
||||
const sourceRevisionsByDocumentId = groupBy(sourceDocumentRevisions, (revision) => revision.documentId);
|
||||
const targetRevisionsByDocumentId = groupBy(targetDocumentRevisions, (revision) => revision.documentId);
|
||||
const commentIdsAvailableAfterImport = new Set<string>([
|
||||
...input.targetComments.map((comment) => comment.id),
|
||||
...commentPlans.filter((plan): plan is PlannedCommentInsert => plan.action === "insert").map((plan) => plan.source.id),
|
||||
]);
|
||||
|
||||
const documentPlans: Array<PlannedIssueDocumentInsert | PlannedIssueDocumentMerge | PlannedIssueDocumentSkip> = [];
|
||||
for (const document of sortDocumentRows(sourceDocuments)) {
|
||||
if (!issueIdsAvailableAfterImport.has(document.issueId)) {
|
||||
documentPlans.push({ source: document, action: "skip_missing_parent" });
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingDocument = targetDocumentsById.get(document.documentId);
|
||||
const conflictingIssueKeyDocument = targetDocumentsByIssueKey.get(`${document.issueId}:${document.key}`);
|
||||
if (!existingDocument && conflictingIssueKeyDocument && conflictingIssueKeyDocument.documentId !== document.documentId) {
|
||||
documentPlans.push({ source: document, action: "skip_conflicting_key" });
|
||||
continue;
|
||||
}
|
||||
|
||||
const adjustments: ImportAdjustment[] = [];
|
||||
const targetCreatedByAgentId =
|
||||
document.createdByAgentId && targetAgentIds.has(document.createdByAgentId) ? document.createdByAgentId : null;
|
||||
const targetUpdatedByAgentId =
|
||||
document.updatedByAgentId && targetAgentIds.has(document.updatedByAgentId) ? document.updatedByAgentId : null;
|
||||
if (
|
||||
(document.createdByAgentId && !targetCreatedByAgentId)
|
||||
|| (document.updatedByAgentId && !targetUpdatedByAgentId)
|
||||
) {
|
||||
adjustments.push("clear_document_agent");
|
||||
incrementAdjustment(adjustmentCounts, "clear_document_agent");
|
||||
}
|
||||
|
||||
const sourceRevisions = sortDocumentRevisions(sourceRevisionsByDocumentId.get(document.documentId) ?? []);
|
||||
const targetRevisions = sortDocumentRevisions(targetRevisionsByDocumentId.get(document.documentId) ?? []);
|
||||
const existingRevisionIds = new Set(targetRevisions.map((revision) => revision.id));
|
||||
const usedRevisionNumbers = new Set(targetRevisions.map((revision) => revision.revisionNumber));
|
||||
let nextRevisionNumber = targetRevisions.reduce(
|
||||
(maxValue, revision) => Math.max(maxValue, revision.revisionNumber),
|
||||
0,
|
||||
) + 1;
|
||||
|
||||
const targetRevisionNumberById = new Map<string, number>(
|
||||
targetRevisions.map((revision) => [revision.id, revision.revisionNumber]),
|
||||
);
|
||||
const revisionsToInsert: PlannedDocumentRevisionInsert[] = [];
|
||||
|
||||
for (const revision of sourceRevisions) {
|
||||
if (existingRevisionIds.has(revision.id)) continue;
|
||||
let targetRevisionNumber = revision.revisionNumber;
|
||||
if (usedRevisionNumbers.has(targetRevisionNumber)) {
|
||||
while (usedRevisionNumbers.has(nextRevisionNumber)) {
|
||||
nextRevisionNumber += 1;
|
||||
}
|
||||
targetRevisionNumber = nextRevisionNumber;
|
||||
nextRevisionNumber += 1;
|
||||
}
|
||||
usedRevisionNumbers.add(targetRevisionNumber);
|
||||
targetRevisionNumberById.set(revision.id, targetRevisionNumber);
|
||||
|
||||
const revisionAdjustments: ImportAdjustment[] = [];
|
||||
const targetCreatedByAgentId =
|
||||
revision.createdByAgentId && targetAgentIds.has(revision.createdByAgentId) ? revision.createdByAgentId : null;
|
||||
if (revision.createdByAgentId && !targetCreatedByAgentId) {
|
||||
revisionAdjustments.push("clear_document_revision_agent");
|
||||
incrementAdjustment(adjustmentCounts, "clear_document_revision_agent");
|
||||
}
|
||||
|
||||
revisionsToInsert.push({
|
||||
source: revision,
|
||||
targetRevisionNumber,
|
||||
targetCreatedByAgentId,
|
||||
adjustments: revisionAdjustments,
|
||||
});
|
||||
}
|
||||
|
||||
const latestRevisionId = document.latestRevisionId ?? existingDocument?.latestRevisionId ?? null;
|
||||
const latestRevisionNumber =
|
||||
(latestRevisionId ? targetRevisionNumberById.get(latestRevisionId) : undefined)
|
||||
?? document.latestRevisionNumber
|
||||
?? existingDocument?.latestRevisionNumber
|
||||
?? 0;
|
||||
|
||||
if (!existingDocument) {
|
||||
documentPlans.push({
|
||||
source: document,
|
||||
action: "insert",
|
||||
targetCreatedByAgentId,
|
||||
targetUpdatedByAgentId,
|
||||
latestRevisionId,
|
||||
latestRevisionNumber,
|
||||
revisionsToInsert,
|
||||
adjustments,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const documentAlreadyMatches =
|
||||
existingDocument.key === document.key
|
||||
&& existingDocument.title === document.title
|
||||
&& existingDocument.format === document.format
|
||||
&& existingDocument.latestBody === document.latestBody
|
||||
&& (existingDocument.latestRevisionId ?? null) === latestRevisionId
|
||||
&& existingDocument.latestRevisionNumber === latestRevisionNumber
|
||||
&& (existingDocument.updatedByAgentId ?? null) === targetUpdatedByAgentId
|
||||
&& (existingDocument.updatedByUserId ?? null) === (document.updatedByUserId ?? null)
|
||||
&& sameDate(existingDocument.documentUpdatedAt, document.documentUpdatedAt)
|
||||
&& sameDate(existingDocument.linkUpdatedAt, document.linkUpdatedAt)
|
||||
&& revisionsToInsert.length === 0;
|
||||
|
||||
if (documentAlreadyMatches) {
|
||||
documentPlans.push({ source: document, action: "skip_existing" });
|
||||
continue;
|
||||
}
|
||||
|
||||
documentPlans.push({
|
||||
source: document,
|
||||
action: "merge_existing",
|
||||
targetCreatedByAgentId,
|
||||
targetUpdatedByAgentId,
|
||||
latestRevisionId,
|
||||
latestRevisionNumber,
|
||||
revisionsToInsert,
|
||||
adjustments,
|
||||
});
|
||||
}
|
||||
|
||||
const sourceAttachments = input.sourceAttachments ?? [];
|
||||
const targetAttachmentIds = new Set((input.targetAttachments ?? []).map((attachment) => attachment.id));
|
||||
const attachmentPlans: Array<PlannedAttachmentInsert | PlannedAttachmentSkip> = [];
|
||||
for (const attachment of sortAttachments(sourceAttachments)) {
|
||||
if (targetAttachmentIds.has(attachment.id)) {
|
||||
attachmentPlans.push({ source: attachment, action: "skip_existing" });
|
||||
continue;
|
||||
}
|
||||
if (!issueIdsAvailableAfterImport.has(attachment.issueId)) {
|
||||
attachmentPlans.push({ source: attachment, action: "skip_missing_parent" });
|
||||
continue;
|
||||
}
|
||||
|
||||
const adjustments: ImportAdjustment[] = [];
|
||||
const targetCreatedByAgentId =
|
||||
attachment.createdByAgentId && targetAgentIds.has(attachment.createdByAgentId)
|
||||
? attachment.createdByAgentId
|
||||
: null;
|
||||
if (attachment.createdByAgentId && !targetCreatedByAgentId) {
|
||||
adjustments.push("clear_attachment_agent");
|
||||
incrementAdjustment(adjustmentCounts, "clear_attachment_agent");
|
||||
}
|
||||
|
||||
attachmentPlans.push({
|
||||
source: attachment,
|
||||
action: "insert",
|
||||
targetIssueCommentId:
|
||||
attachment.issueCommentId && commentIdsAvailableAfterImport.has(attachment.issueCommentId)
|
||||
? attachment.issueCommentId
|
||||
: null,
|
||||
targetCreatedByAgentId,
|
||||
adjustments,
|
||||
});
|
||||
}
|
||||
|
||||
const counts = {
|
||||
issuesToInsert: issuePlans.filter((plan) => plan.action === "insert").length,
|
||||
issuesExisting: issuePlans.filter((plan) => plan.action === "skip_existing").length,
|
||||
@@ -331,6 +678,19 @@ export function buildWorktreeMergePlan(input: {
|
||||
commentsToInsert: commentPlans.filter((plan) => plan.action === "insert").length,
|
||||
commentsExisting: commentPlans.filter((plan) => plan.action === "skip_existing").length,
|
||||
commentsMissingParent: commentPlans.filter((plan) => plan.action === "skip_missing_parent").length,
|
||||
documentsToInsert: documentPlans.filter((plan) => plan.action === "insert").length,
|
||||
documentsToMerge: documentPlans.filter((plan) => plan.action === "merge_existing").length,
|
||||
documentsExisting: documentPlans.filter((plan) => plan.action === "skip_existing").length,
|
||||
documentsConflictingKey: documentPlans.filter((plan) => plan.action === "skip_conflicting_key").length,
|
||||
documentsMissingParent: documentPlans.filter((plan) => plan.action === "skip_missing_parent").length,
|
||||
documentRevisionsToInsert: documentPlans.reduce(
|
||||
(sum, plan) =>
|
||||
sum + (plan.action === "insert" || plan.action === "merge_existing" ? plan.revisionsToInsert.length : 0),
|
||||
0,
|
||||
),
|
||||
attachmentsToInsert: attachmentPlans.filter((plan) => plan.action === "insert").length,
|
||||
attachmentsExisting: attachmentPlans.filter((plan) => plan.action === "skip_existing").length,
|
||||
attachmentsMissingParent: attachmentPlans.filter((plan) => plan.action === "skip_missing_parent").length,
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -341,6 +701,8 @@ export function buildWorktreeMergePlan(input: {
|
||||
scopes: input.scopes,
|
||||
issuePlans,
|
||||
commentPlans,
|
||||
documentPlans,
|
||||
attachmentPlans,
|
||||
counts,
|
||||
adjustments: adjustmentCounts,
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
promises as fsPromises,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
readlinkSync,
|
||||
@@ -15,18 +16,23 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createServer } from "node:net";
|
||||
import { Readable } from "node:stream";
|
||||
import * as p from "@clack/prompts";
|
||||
import pc from "picocolors";
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import {
|
||||
applyPendingMigrations,
|
||||
agents,
|
||||
assets,
|
||||
companies,
|
||||
createDb,
|
||||
documentRevisions,
|
||||
documents,
|
||||
ensurePostgresDatabase,
|
||||
formatDatabaseBackupResult,
|
||||
goals,
|
||||
heartbeatRuns,
|
||||
issueAttachments,
|
||||
issueComments,
|
||||
issueDocuments,
|
||||
issues,
|
||||
@@ -59,7 +65,13 @@ import {
|
||||
import {
|
||||
buildWorktreeMergePlan,
|
||||
parseWorktreeMergeScopes,
|
||||
type IssueAttachmentRow,
|
||||
type IssueDocumentRow,
|
||||
type DocumentRevisionRow,
|
||||
type PlannedAttachmentInsert,
|
||||
type PlannedCommentInsert,
|
||||
type PlannedIssueDocumentInsert,
|
||||
type PlannedIssueDocumentMerge,
|
||||
type PlannedIssueInsert,
|
||||
} from "./worktree-merge-history-lib.js";
|
||||
|
||||
@@ -181,6 +193,162 @@ function resolveWorktreeStartPoint(explicit?: string): string | undefined {
|
||||
return explicit ?? nonEmpty(process.env.PAPERCLIP_WORKTREE_START_POINT) ?? undefined;
|
||||
}
|
||||
|
||||
type ConfiguredStorage = {
|
||||
getObject(companyId: string, objectKey: string): Promise<Buffer>;
|
||||
putObject(companyId: string, objectKey: string, body: Buffer, contentType: string): Promise<void>;
|
||||
};
|
||||
|
||||
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<Buffer> {
|
||||
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<Uint8Array>;
|
||||
arrayBuffer?: () => Promise<ArrayBuffer>;
|
||||
};
|
||||
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<any>;
|
||||
|
||||
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<any> | 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<Buffer> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
export function resolveWorktreeMakeTargetPath(name: string): string {
|
||||
return path.resolve(os.homedir(), resolveWorktreeMakeName(name));
|
||||
}
|
||||
@@ -1244,7 +1412,6 @@ function renderMergePlan(plan: Awaited<ReturnType<typeof collectMergePlan>>["pla
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
unsupportedRunCount: number;
|
||||
unsupportedDocumentCount: number;
|
||||
}): string {
|
||||
const terminalWidth = Math.max(60, process.stdout.columns ?? 100);
|
||||
const oneLine = (value: string) => value.replace(/\s+/g, " ").trim();
|
||||
@@ -1292,6 +1459,21 @@ function renderMergePlan(plan: Awaited<ReturnType<typeof collectMergePlan>>["pla
|
||||
lines.push(`- skipped (missing parent): ${plan.counts.commentsMissingParent}`);
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push("Documents");
|
||||
lines.push(`- insert: ${plan.counts.documentsToInsert}`);
|
||||
lines.push(`- merge existing: ${plan.counts.documentsToMerge}`);
|
||||
lines.push(`- already present: ${plan.counts.documentsExisting}`);
|
||||
lines.push(`- skipped (conflicting key): ${plan.counts.documentsConflictingKey}`);
|
||||
lines.push(`- skipped (missing parent): ${plan.counts.documentsMissingParent}`);
|
||||
lines.push(`- revisions insert: ${plan.counts.documentRevisionsToInsert}`);
|
||||
|
||||
lines.push("");
|
||||
lines.push("Attachments");
|
||||
lines.push(`- insert: ${plan.counts.attachmentsToInsert}`);
|
||||
lines.push(`- already present: ${plan.counts.attachmentsExisting}`);
|
||||
lines.push(`- skipped (missing parent): ${plan.counts.attachmentsMissingParent}`);
|
||||
|
||||
lines.push("");
|
||||
lines.push("Adjustments");
|
||||
lines.push(`- cleared assignee agents: ${plan.adjustments.clear_assignee_agent}`);
|
||||
@@ -1299,12 +1481,14 @@ function renderMergePlan(plan: Awaited<ReturnType<typeof collectMergePlan>>["pla
|
||||
lines.push(`- cleared project workspaces: ${plan.adjustments.clear_project_workspace}`);
|
||||
lines.push(`- cleared goals: ${plan.adjustments.clear_goal}`);
|
||||
lines.push(`- cleared comment author agents: ${plan.adjustments.clear_author_agent}`);
|
||||
lines.push(`- cleared document agents: ${plan.adjustments.clear_document_agent}`);
|
||||
lines.push(`- cleared document revision agents: ${plan.adjustments.clear_document_revision_agent}`);
|
||||
lines.push(`- cleared attachment author agents: ${plan.adjustments.clear_attachment_agent}`);
|
||||
lines.push(`- coerced in_progress to todo: ${plan.adjustments.coerce_in_progress_to_todo}`);
|
||||
|
||||
lines.push("");
|
||||
lines.push("Not imported in this phase");
|
||||
lines.push(`- heartbeat runs: ${extras.unsupportedRunCount}`);
|
||||
lines.push(`- issue documents: ${extras.unsupportedDocumentCount}`);
|
||||
lines.push("");
|
||||
lines.push("Identifiers shown above are provisional preview values. `--apply` reserves fresh issue numbers at write time.");
|
||||
|
||||
@@ -1319,7 +1503,25 @@ async function collectMergePlan(input: {
|
||||
projectIdOverrides?: Record<string, string | null | undefined>;
|
||||
}) {
|
||||
const companyId = input.company.id;
|
||||
const [targetCompanyRow, sourceIssuesRows, targetIssuesRows, sourceCommentsRows, targetCommentsRows, sourceProjectsRows, targetProjectsRows, targetAgentsRows, targetProjectWorkspaceRows, targetGoalsRows, runCountRows, documentCountRows] = await Promise.all([
|
||||
const [
|
||||
targetCompanyRow,
|
||||
sourceIssuesRows,
|
||||
targetIssuesRows,
|
||||
sourceCommentsRows,
|
||||
targetCommentsRows,
|
||||
sourceIssueDocumentsRows,
|
||||
targetIssueDocumentsRows,
|
||||
sourceDocumentRevisionRows,
|
||||
targetDocumentRevisionRows,
|
||||
sourceAttachmentRows,
|
||||
targetAttachmentRows,
|
||||
sourceProjectsRows,
|
||||
targetProjectsRows,
|
||||
targetAgentsRows,
|
||||
targetProjectWorkspaceRows,
|
||||
targetGoalsRows,
|
||||
runCountRows,
|
||||
] = await Promise.all([
|
||||
input.targetDb
|
||||
.select({
|
||||
issueCounter: companies.issueCounter,
|
||||
@@ -1341,12 +1543,140 @@ async function collectMergePlan(input: {
|
||||
.from(issueComments)
|
||||
.where(eq(issueComments.companyId, companyId))
|
||||
: Promise.resolve([]),
|
||||
input.scopes.includes("comments")
|
||||
? input.targetDb
|
||||
.select()
|
||||
.from(issueComments)
|
||||
.where(eq(issueComments.companyId, companyId))
|
||||
: Promise.resolve([]),
|
||||
input.targetDb
|
||||
.select()
|
||||
.from(issueComments)
|
||||
.where(eq(issueComments.companyId, companyId)),
|
||||
input.sourceDb
|
||||
.select({
|
||||
id: issueDocuments.id,
|
||||
companyId: issueDocuments.companyId,
|
||||
issueId: issueDocuments.issueId,
|
||||
documentId: issueDocuments.documentId,
|
||||
key: issueDocuments.key,
|
||||
linkCreatedAt: issueDocuments.createdAt,
|
||||
linkUpdatedAt: issueDocuments.updatedAt,
|
||||
title: documents.title,
|
||||
format: documents.format,
|
||||
latestBody: documents.latestBody,
|
||||
latestRevisionId: documents.latestRevisionId,
|
||||
latestRevisionNumber: documents.latestRevisionNumber,
|
||||
createdByAgentId: documents.createdByAgentId,
|
||||
createdByUserId: documents.createdByUserId,
|
||||
updatedByAgentId: documents.updatedByAgentId,
|
||||
updatedByUserId: documents.updatedByUserId,
|
||||
documentCreatedAt: documents.createdAt,
|
||||
documentUpdatedAt: documents.updatedAt,
|
||||
})
|
||||
.from(issueDocuments)
|
||||
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
||||
.innerJoin(issues, eq(issueDocuments.issueId, issues.id))
|
||||
.where(eq(issues.companyId, companyId)),
|
||||
input.targetDb
|
||||
.select({
|
||||
id: issueDocuments.id,
|
||||
companyId: issueDocuments.companyId,
|
||||
issueId: issueDocuments.issueId,
|
||||
documentId: issueDocuments.documentId,
|
||||
key: issueDocuments.key,
|
||||
linkCreatedAt: issueDocuments.createdAt,
|
||||
linkUpdatedAt: issueDocuments.updatedAt,
|
||||
title: documents.title,
|
||||
format: documents.format,
|
||||
latestBody: documents.latestBody,
|
||||
latestRevisionId: documents.latestRevisionId,
|
||||
latestRevisionNumber: documents.latestRevisionNumber,
|
||||
createdByAgentId: documents.createdByAgentId,
|
||||
createdByUserId: documents.createdByUserId,
|
||||
updatedByAgentId: documents.updatedByAgentId,
|
||||
updatedByUserId: documents.updatedByUserId,
|
||||
documentCreatedAt: documents.createdAt,
|
||||
documentUpdatedAt: documents.updatedAt,
|
||||
})
|
||||
.from(issueDocuments)
|
||||
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
||||
.innerJoin(issues, eq(issueDocuments.issueId, issues.id))
|
||||
.where(eq(issues.companyId, companyId)),
|
||||
input.sourceDb
|
||||
.select({
|
||||
id: documentRevisions.id,
|
||||
companyId: documentRevisions.companyId,
|
||||
documentId: documentRevisions.documentId,
|
||||
revisionNumber: documentRevisions.revisionNumber,
|
||||
body: documentRevisions.body,
|
||||
changeSummary: documentRevisions.changeSummary,
|
||||
createdByAgentId: documentRevisions.createdByAgentId,
|
||||
createdByUserId: documentRevisions.createdByUserId,
|
||||
createdAt: documentRevisions.createdAt,
|
||||
})
|
||||
.from(documentRevisions)
|
||||
.innerJoin(issueDocuments, eq(documentRevisions.documentId, issueDocuments.documentId))
|
||||
.innerJoin(issues, eq(issueDocuments.issueId, issues.id))
|
||||
.where(eq(issues.companyId, companyId)),
|
||||
input.targetDb
|
||||
.select({
|
||||
id: documentRevisions.id,
|
||||
companyId: documentRevisions.companyId,
|
||||
documentId: documentRevisions.documentId,
|
||||
revisionNumber: documentRevisions.revisionNumber,
|
||||
body: documentRevisions.body,
|
||||
changeSummary: documentRevisions.changeSummary,
|
||||
createdByAgentId: documentRevisions.createdByAgentId,
|
||||
createdByUserId: documentRevisions.createdByUserId,
|
||||
createdAt: documentRevisions.createdAt,
|
||||
})
|
||||
.from(documentRevisions)
|
||||
.innerJoin(issueDocuments, eq(documentRevisions.documentId, issueDocuments.documentId))
|
||||
.innerJoin(issues, eq(issueDocuments.issueId, issues.id))
|
||||
.where(eq(issues.companyId, companyId)),
|
||||
input.sourceDb
|
||||
.select({
|
||||
id: issueAttachments.id,
|
||||
companyId: issueAttachments.companyId,
|
||||
issueId: issueAttachments.issueId,
|
||||
issueCommentId: issueAttachments.issueCommentId,
|
||||
assetId: issueAttachments.assetId,
|
||||
provider: assets.provider,
|
||||
objectKey: assets.objectKey,
|
||||
contentType: assets.contentType,
|
||||
byteSize: assets.byteSize,
|
||||
sha256: assets.sha256,
|
||||
originalFilename: assets.originalFilename,
|
||||
createdByAgentId: assets.createdByAgentId,
|
||||
createdByUserId: assets.createdByUserId,
|
||||
assetCreatedAt: assets.createdAt,
|
||||
assetUpdatedAt: assets.updatedAt,
|
||||
attachmentCreatedAt: issueAttachments.createdAt,
|
||||
attachmentUpdatedAt: issueAttachments.updatedAt,
|
||||
})
|
||||
.from(issueAttachments)
|
||||
.innerJoin(assets, eq(issueAttachments.assetId, assets.id))
|
||||
.innerJoin(issues, eq(issueAttachments.issueId, issues.id))
|
||||
.where(eq(issues.companyId, companyId)),
|
||||
input.targetDb
|
||||
.select({
|
||||
id: issueAttachments.id,
|
||||
companyId: issueAttachments.companyId,
|
||||
issueId: issueAttachments.issueId,
|
||||
issueCommentId: issueAttachments.issueCommentId,
|
||||
assetId: issueAttachments.assetId,
|
||||
provider: assets.provider,
|
||||
objectKey: assets.objectKey,
|
||||
contentType: assets.contentType,
|
||||
byteSize: assets.byteSize,
|
||||
sha256: assets.sha256,
|
||||
originalFilename: assets.originalFilename,
|
||||
createdByAgentId: assets.createdByAgentId,
|
||||
createdByUserId: assets.createdByUserId,
|
||||
assetCreatedAt: assets.createdAt,
|
||||
assetUpdatedAt: assets.updatedAt,
|
||||
attachmentCreatedAt: issueAttachments.createdAt,
|
||||
attachmentUpdatedAt: issueAttachments.updatedAt,
|
||||
})
|
||||
.from(issueAttachments)
|
||||
.innerJoin(assets, eq(issueAttachments.assetId, assets.id))
|
||||
.innerJoin(issues, eq(issueAttachments.issueId, issues.id))
|
||||
.where(eq(issues.companyId, companyId)),
|
||||
input.sourceDb
|
||||
.select()
|
||||
.from(projects)
|
||||
@@ -1371,11 +1701,6 @@ async function collectMergePlan(input: {
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.companyId, companyId)),
|
||||
input.sourceDb
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(issueDocuments)
|
||||
.innerJoin(issues, eq(issueDocuments.issueId, issues.id))
|
||||
.where(eq(issues.companyId, companyId)),
|
||||
]);
|
||||
|
||||
if (!targetCompanyRow) {
|
||||
@@ -1392,6 +1717,12 @@ async function collectMergePlan(input: {
|
||||
targetIssues: targetIssuesRows,
|
||||
sourceComments: sourceCommentsRows,
|
||||
targetComments: targetCommentsRows,
|
||||
sourceDocuments: sourceIssueDocumentsRows as IssueDocumentRow[],
|
||||
targetDocuments: targetIssueDocumentsRows as IssueDocumentRow[],
|
||||
sourceDocumentRevisions: sourceDocumentRevisionRows as DocumentRevisionRow[],
|
||||
targetDocumentRevisions: targetDocumentRevisionRows as DocumentRevisionRow[],
|
||||
sourceAttachments: sourceAttachmentRows as IssueAttachmentRow[],
|
||||
targetAttachments: targetAttachmentRows as IssueAttachmentRow[],
|
||||
targetAgents: targetAgentsRows,
|
||||
targetProjects: targetProjectsRows,
|
||||
targetProjectWorkspaces: targetProjectWorkspaceRows,
|
||||
@@ -1404,7 +1735,6 @@ async function collectMergePlan(input: {
|
||||
sourceProjects: sourceProjectsRows,
|
||||
targetProjects: targetProjectsRows,
|
||||
unsupportedRunCount: runCountRows[0]?.count ?? 0,
|
||||
unsupportedDocumentCount: documentCountRows[0]?.count ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1575,6 +1905,8 @@ async function promptForSourceEndpoint(excludeWorktreePath?: string): Promise<Re
|
||||
}
|
||||
|
||||
async function applyMergePlan(input: {
|
||||
sourceStorage: ConfiguredStorage;
|
||||
targetStorage: ConfiguredStorage;
|
||||
targetDb: ClosableDb;
|
||||
company: ResolvedMergeCompany;
|
||||
plan: Awaited<ReturnType<typeof collectMergePlan>>["plan"];
|
||||
@@ -1608,6 +1940,7 @@ async function applyMergePlan(input: {
|
||||
}
|
||||
|
||||
const insertedIssueIdentifiers = new Map<string, string>();
|
||||
let insertedIssues = 0;
|
||||
for (const issue of issueInserts) {
|
||||
const issueNumber = nextIssueNumber;
|
||||
nextIssueNumber += 1;
|
||||
@@ -1647,6 +1980,7 @@ async function applyMergePlan(input: {
|
||||
createdAt: issue.source.createdAt,
|
||||
updatedAt: issue.source.updatedAt,
|
||||
});
|
||||
insertedIssues += 1;
|
||||
}
|
||||
|
||||
const commentCandidates = input.plan.commentPlans.filter(
|
||||
@@ -1663,6 +1997,7 @@ async function applyMergePlan(input: {
|
||||
)
|
||||
: new Set<string>();
|
||||
|
||||
let insertedComments = 0;
|
||||
for (const comment of commentCandidates) {
|
||||
if (existingCommentIds.has(comment.source.id)) continue;
|
||||
const parentExists = await tx
|
||||
@@ -1681,11 +2016,199 @@ async function applyMergePlan(input: {
|
||||
createdAt: comment.source.createdAt,
|
||||
updatedAt: comment.source.updatedAt,
|
||||
});
|
||||
insertedComments += 1;
|
||||
}
|
||||
|
||||
const documentCandidates = input.plan.documentPlans.filter(
|
||||
(plan): plan is PlannedIssueDocumentInsert | PlannedIssueDocumentMerge =>
|
||||
plan.action === "insert" || plan.action === "merge_existing",
|
||||
);
|
||||
let insertedDocuments = 0;
|
||||
let mergedDocuments = 0;
|
||||
let insertedDocumentRevisions = 0;
|
||||
for (const documentPlan of documentCandidates) {
|
||||
const parentExists = await tx
|
||||
.select({ id: issues.id })
|
||||
.from(issues)
|
||||
.where(and(eq(issues.id, documentPlan.source.issueId), eq(issues.companyId, companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!parentExists) continue;
|
||||
|
||||
const conflictingKeyDocument = await tx
|
||||
.select({ documentId: issueDocuments.documentId })
|
||||
.from(issueDocuments)
|
||||
.where(and(eq(issueDocuments.issueId, documentPlan.source.issueId), eq(issueDocuments.key, documentPlan.source.key)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (
|
||||
conflictingKeyDocument
|
||||
&& conflictingKeyDocument.documentId !== documentPlan.source.documentId
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingDocument = await tx
|
||||
.select({ id: documents.id })
|
||||
.from(documents)
|
||||
.where(eq(documents.id, documentPlan.source.documentId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!existingDocument) {
|
||||
await tx.insert(documents).values({
|
||||
id: documentPlan.source.documentId,
|
||||
companyId,
|
||||
title: documentPlan.source.title,
|
||||
format: documentPlan.source.format,
|
||||
latestBody: documentPlan.source.latestBody,
|
||||
latestRevisionId: documentPlan.latestRevisionId,
|
||||
latestRevisionNumber: documentPlan.latestRevisionNumber,
|
||||
createdByAgentId: documentPlan.targetCreatedByAgentId,
|
||||
createdByUserId: documentPlan.source.createdByUserId,
|
||||
updatedByAgentId: documentPlan.targetUpdatedByAgentId,
|
||||
updatedByUserId: documentPlan.source.updatedByUserId,
|
||||
createdAt: documentPlan.source.documentCreatedAt,
|
||||
updatedAt: documentPlan.source.documentUpdatedAt,
|
||||
});
|
||||
await tx.insert(issueDocuments).values({
|
||||
id: documentPlan.source.id,
|
||||
companyId,
|
||||
issueId: documentPlan.source.issueId,
|
||||
documentId: documentPlan.source.documentId,
|
||||
key: documentPlan.source.key,
|
||||
createdAt: documentPlan.source.linkCreatedAt,
|
||||
updatedAt: documentPlan.source.linkUpdatedAt,
|
||||
});
|
||||
insertedDocuments += 1;
|
||||
} else {
|
||||
const existingLink = await tx
|
||||
.select({ id: issueDocuments.id })
|
||||
.from(issueDocuments)
|
||||
.where(eq(issueDocuments.documentId, documentPlan.source.documentId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existingLink) {
|
||||
await tx.insert(issueDocuments).values({
|
||||
id: documentPlan.source.id,
|
||||
companyId,
|
||||
issueId: documentPlan.source.issueId,
|
||||
documentId: documentPlan.source.documentId,
|
||||
key: documentPlan.source.key,
|
||||
createdAt: documentPlan.source.linkCreatedAt,
|
||||
updatedAt: documentPlan.source.linkUpdatedAt,
|
||||
});
|
||||
} else {
|
||||
await tx
|
||||
.update(issueDocuments)
|
||||
.set({
|
||||
issueId: documentPlan.source.issueId,
|
||||
key: documentPlan.source.key,
|
||||
updatedAt: documentPlan.source.linkUpdatedAt,
|
||||
})
|
||||
.where(eq(issueDocuments.documentId, documentPlan.source.documentId));
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(documents)
|
||||
.set({
|
||||
title: documentPlan.source.title,
|
||||
format: documentPlan.source.format,
|
||||
latestBody: documentPlan.source.latestBody,
|
||||
latestRevisionId: documentPlan.latestRevisionId,
|
||||
latestRevisionNumber: documentPlan.latestRevisionNumber,
|
||||
updatedByAgentId: documentPlan.targetUpdatedByAgentId,
|
||||
updatedByUserId: documentPlan.source.updatedByUserId,
|
||||
updatedAt: documentPlan.source.documentUpdatedAt,
|
||||
})
|
||||
.where(eq(documents.id, documentPlan.source.documentId));
|
||||
mergedDocuments += 1;
|
||||
}
|
||||
|
||||
const existingRevisionIds = new Set(
|
||||
(
|
||||
await tx
|
||||
.select({ id: documentRevisions.id })
|
||||
.from(documentRevisions)
|
||||
.where(eq(documentRevisions.documentId, documentPlan.source.documentId))
|
||||
).map((row) => row.id),
|
||||
);
|
||||
for (const revisionPlan of documentPlan.revisionsToInsert) {
|
||||
if (existingRevisionIds.has(revisionPlan.source.id)) continue;
|
||||
await tx.insert(documentRevisions).values({
|
||||
id: revisionPlan.source.id,
|
||||
companyId,
|
||||
documentId: documentPlan.source.documentId,
|
||||
revisionNumber: revisionPlan.targetRevisionNumber,
|
||||
body: revisionPlan.source.body,
|
||||
changeSummary: revisionPlan.source.changeSummary,
|
||||
createdByAgentId: revisionPlan.targetCreatedByAgentId,
|
||||
createdByUserId: revisionPlan.source.createdByUserId,
|
||||
createdAt: revisionPlan.source.createdAt,
|
||||
});
|
||||
insertedDocumentRevisions += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const attachmentCandidates = input.plan.attachmentPlans.filter(
|
||||
(plan): plan is PlannedAttachmentInsert => plan.action === "insert",
|
||||
);
|
||||
const existingAttachmentIds = new Set(
|
||||
(
|
||||
await tx
|
||||
.select({ id: issueAttachments.id })
|
||||
.from(issueAttachments)
|
||||
.where(eq(issueAttachments.companyId, companyId))
|
||||
).map((row) => row.id),
|
||||
);
|
||||
let insertedAttachments = 0;
|
||||
for (const attachment of attachmentCandidates) {
|
||||
if (existingAttachmentIds.has(attachment.source.id)) continue;
|
||||
const parentExists = await tx
|
||||
.select({ id: issues.id })
|
||||
.from(issues)
|
||||
.where(and(eq(issues.id, attachment.source.issueId), eq(issues.companyId, companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!parentExists) continue;
|
||||
|
||||
const body = await input.sourceStorage.getObject(companyId, attachment.source.objectKey);
|
||||
await input.targetStorage.putObject(
|
||||
companyId,
|
||||
attachment.source.objectKey,
|
||||
body,
|
||||
attachment.source.contentType,
|
||||
);
|
||||
|
||||
await tx.insert(assets).values({
|
||||
id: attachment.source.assetId,
|
||||
companyId,
|
||||
provider: attachment.source.provider,
|
||||
objectKey: attachment.source.objectKey,
|
||||
contentType: attachment.source.contentType,
|
||||
byteSize: attachment.source.byteSize,
|
||||
sha256: attachment.source.sha256,
|
||||
originalFilename: attachment.source.originalFilename,
|
||||
createdByAgentId: attachment.targetCreatedByAgentId,
|
||||
createdByUserId: attachment.source.createdByUserId,
|
||||
createdAt: attachment.source.assetCreatedAt,
|
||||
updatedAt: attachment.source.assetUpdatedAt,
|
||||
});
|
||||
|
||||
await tx.insert(issueAttachments).values({
|
||||
id: attachment.source.id,
|
||||
companyId,
|
||||
issueId: attachment.source.issueId,
|
||||
assetId: attachment.source.assetId,
|
||||
issueCommentId: attachment.targetIssueCommentId,
|
||||
createdAt: attachment.source.attachmentCreatedAt,
|
||||
updatedAt: attachment.source.attachmentUpdatedAt,
|
||||
});
|
||||
insertedAttachments += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
insertedIssues: issueInserts.length,
|
||||
insertedComments: commentCandidates.filter((comment) => !existingCommentIds.has(comment.source.id)).length,
|
||||
insertedIssues,
|
||||
insertedComments,
|
||||
insertedDocuments,
|
||||
mergedDocuments,
|
||||
insertedDocumentRevisions,
|
||||
insertedAttachments,
|
||||
insertedIssueIdentifiers,
|
||||
};
|
||||
});
|
||||
@@ -1716,6 +2239,8 @@ export async function worktreeMergeHistoryCommand(sourceArg: string | undefined,
|
||||
const scopes = parseWorktreeMergeScopes(opts.scope);
|
||||
const sourceHandle = await openConfiguredDb(sourceEndpoint.configPath);
|
||||
const targetHandle = await openConfiguredDb(targetEndpoint.configPath);
|
||||
const sourceStorage = openConfiguredStorage(sourceEndpoint.configPath);
|
||||
const targetStorage = openConfiguredStorage(targetEndpoint.configPath);
|
||||
|
||||
try {
|
||||
const company = await resolveMergeCompany({
|
||||
@@ -1750,7 +2275,6 @@ export async function worktreeMergeHistoryCommand(sourceArg: string | undefined,
|
||||
sourcePath: `${sourceEndpoint.label} (${sourceEndpoint.rootPath})`,
|
||||
targetPath: `${targetEndpoint.label} (${targetEndpoint.rootPath})`,
|
||||
unsupportedRunCount: collected.unsupportedRunCount,
|
||||
unsupportedDocumentCount: collected.unsupportedDocumentCount,
|
||||
}));
|
||||
|
||||
if (!opts.apply) {
|
||||
@@ -1769,13 +2293,15 @@ export async function worktreeMergeHistoryCommand(sourceArg: string | undefined,
|
||||
}
|
||||
|
||||
const applied = await applyMergePlan({
|
||||
sourceStorage,
|
||||
targetStorage,
|
||||
targetDb: targetHandle.db,
|
||||
company,
|
||||
plan: collected.plan,
|
||||
});
|
||||
p.outro(
|
||||
pc.green(
|
||||
`Imported ${applied.insertedIssues} issues and ${applied.insertedComments} comments into ${company.issuePrefix}.`,
|
||||
`Imported ${applied.insertedIssues} issues, ${applied.insertedComments} comments, ${applied.insertedDocuments} documents (${applied.insertedDocumentRevisions} revisions, ${applied.mergedDocuments} merged), and ${applied.insertedAttachments} attachments into ${company.issuePrefix}.`,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user