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;
|
} 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", () => {
|
describe("worktree merge history planner", () => {
|
||||||
it("parses default scopes", () => {
|
it("parses default scopes", () => {
|
||||||
expect(parseWorktreeMergeScopes(undefined)).toEqual(["issues", "comments"]);
|
expect(parseWorktreeMergeScopes(undefined)).toEqual(["issues", "comments"]);
|
||||||
@@ -214,4 +276,117 @@ describe("worktree merge history planner", () => {
|
|||||||
]);
|
]);
|
||||||
expect(plan.adjustments.clear_author_agent).toBe(1);
|
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 {
|
import {
|
||||||
agents,
|
agents,
|
||||||
|
assets,
|
||||||
|
documentRevisions,
|
||||||
goals,
|
goals,
|
||||||
|
issueAttachments,
|
||||||
issueComments,
|
issueComments,
|
||||||
|
issueDocuments,
|
||||||
issues,
|
issues,
|
||||||
projects,
|
projects,
|
||||||
projectWorkspaces,
|
projectWorkspaces,
|
||||||
@@ -13,6 +17,10 @@ type AgentRow = typeof agents.$inferSelect;
|
|||||||
type ProjectRow = typeof projects.$inferSelect;
|
type ProjectRow = typeof projects.$inferSelect;
|
||||||
type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect;
|
type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect;
|
||||||
type GoalRow = typeof goals.$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 const WORKTREE_MERGE_SCOPES = ["issues", "comments"] as const;
|
||||||
export type WorktreeMergeScope = (typeof WORKTREE_MERGE_SCOPES)[number];
|
export type WorktreeMergeScope = (typeof WORKTREE_MERGE_SCOPES)[number];
|
||||||
@@ -23,7 +31,10 @@ export type ImportAdjustment =
|
|||||||
| "clear_project_workspace"
|
| "clear_project_workspace"
|
||||||
| "clear_goal"
|
| "clear_goal"
|
||||||
| "clear_author_agent"
|
| "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 IssueMergeAction = "skip_existing" | "insert";
|
||||||
export type CommentMergeAction = "skip_existing" | "skip_missing_parent" | "insert";
|
export type CommentMergeAction = "skip_existing" | "skip_missing_parent" | "insert";
|
||||||
@@ -62,6 +73,106 @@ export type PlannedCommentSkip = {
|
|||||||
action: "skip_existing" | "skip_missing_parent";
|
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 = {
|
export type WorktreeMergePlan = {
|
||||||
companyId: string;
|
companyId: string;
|
||||||
companyName: string;
|
companyName: string;
|
||||||
@@ -70,6 +181,8 @@ export type WorktreeMergePlan = {
|
|||||||
scopes: WorktreeMergeScope[];
|
scopes: WorktreeMergeScope[];
|
||||||
issuePlans: Array<PlannedIssueInsert | PlannedIssueSkip>;
|
issuePlans: Array<PlannedIssueInsert | PlannedIssueSkip>;
|
||||||
commentPlans: Array<PlannedCommentInsert | PlannedCommentSkip>;
|
commentPlans: Array<PlannedCommentInsert | PlannedCommentSkip>;
|
||||||
|
documentPlans: Array<PlannedIssueDocumentInsert | PlannedIssueDocumentMerge | PlannedIssueDocumentSkip>;
|
||||||
|
attachmentPlans: Array<PlannedAttachmentInsert | PlannedAttachmentSkip>;
|
||||||
counts: {
|
counts: {
|
||||||
issuesToInsert: number;
|
issuesToInsert: number;
|
||||||
issuesExisting: number;
|
issuesExisting: number;
|
||||||
@@ -77,6 +190,15 @@ export type WorktreeMergePlan = {
|
|||||||
commentsToInsert: number;
|
commentsToInsert: number;
|
||||||
commentsExisting: number;
|
commentsExisting: number;
|
||||||
commentsMissingParent: 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>;
|
adjustments: Record<ImportAdjustment, number>;
|
||||||
};
|
};
|
||||||
@@ -103,6 +225,52 @@ function incrementAdjustment(
|
|||||||
counts[adjustment] += 1;
|
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[] {
|
function sortIssuesForImport(sourceIssues: IssueRow[]): IssueRow[] {
|
||||||
const byId = new Map(sourceIssues.map((issue) => [issue.id, issue]));
|
const byId = new Map(sourceIssues.map((issue) => [issue.id, issue]));
|
||||||
const memoDepth = new Map<string, number>();
|
const memoDepth = new Map<string, number>();
|
||||||
@@ -170,6 +338,12 @@ export function buildWorktreeMergePlan(input: {
|
|||||||
targetIssues: IssueRow[];
|
targetIssues: IssueRow[];
|
||||||
sourceComments: CommentRow[];
|
sourceComments: CommentRow[];
|
||||||
targetComments: CommentRow[];
|
targetComments: CommentRow[];
|
||||||
|
sourceDocuments?: IssueDocumentRow[];
|
||||||
|
targetDocuments?: IssueDocumentRow[];
|
||||||
|
sourceDocumentRevisions?: DocumentRevisionRow[];
|
||||||
|
targetDocumentRevisions?: DocumentRevisionRow[];
|
||||||
|
sourceAttachments?: IssueAttachmentRow[];
|
||||||
|
targetAttachments?: IssueAttachmentRow[];
|
||||||
targetAgents: AgentRow[];
|
targetAgents: AgentRow[];
|
||||||
targetProjects: ProjectRow[];
|
targetProjects: ProjectRow[];
|
||||||
targetProjectWorkspaces: ProjectWorkspaceRow[];
|
targetProjectWorkspaces: ProjectWorkspaceRow[];
|
||||||
@@ -192,6 +366,9 @@ export function buildWorktreeMergePlan(input: {
|
|||||||
clear_goal: 0,
|
clear_goal: 0,
|
||||||
clear_author_agent: 0,
|
clear_author_agent: 0,
|
||||||
coerce_in_progress_to_todo: 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> = [];
|
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 = {
|
const counts = {
|
||||||
issuesToInsert: issuePlans.filter((plan) => plan.action === "insert").length,
|
issuesToInsert: issuePlans.filter((plan) => plan.action === "insert").length,
|
||||||
issuesExisting: issuePlans.filter((plan) => plan.action === "skip_existing").length,
|
issuesExisting: issuePlans.filter((plan) => plan.action === "skip_existing").length,
|
||||||
@@ -331,6 +678,19 @@ export function buildWorktreeMergePlan(input: {
|
|||||||
commentsToInsert: commentPlans.filter((plan) => plan.action === "insert").length,
|
commentsToInsert: commentPlans.filter((plan) => plan.action === "insert").length,
|
||||||
commentsExisting: commentPlans.filter((plan) => plan.action === "skip_existing").length,
|
commentsExisting: commentPlans.filter((plan) => plan.action === "skip_existing").length,
|
||||||
commentsMissingParent: commentPlans.filter((plan) => plan.action === "skip_missing_parent").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 {
|
return {
|
||||||
@@ -341,6 +701,8 @@ export function buildWorktreeMergePlan(input: {
|
|||||||
scopes: input.scopes,
|
scopes: input.scopes,
|
||||||
issuePlans,
|
issuePlans,
|
||||||
commentPlans,
|
commentPlans,
|
||||||
|
documentPlans,
|
||||||
|
attachmentPlans,
|
||||||
counts,
|
counts,
|
||||||
adjustments: adjustmentCounts,
|
adjustments: adjustmentCounts,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
copyFileSync,
|
copyFileSync,
|
||||||
existsSync,
|
existsSync,
|
||||||
mkdirSync,
|
mkdirSync,
|
||||||
|
promises as fsPromises,
|
||||||
readdirSync,
|
readdirSync,
|
||||||
readFileSync,
|
readFileSync,
|
||||||
readlinkSync,
|
readlinkSync,
|
||||||
@@ -15,18 +16,23 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { createServer } from "node:net";
|
import { createServer } from "node:net";
|
||||||
|
import { Readable } from "node:stream";
|
||||||
import * as p from "@clack/prompts";
|
import * as p from "@clack/prompts";
|
||||||
import pc from "picocolors";
|
import pc from "picocolors";
|
||||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
applyPendingMigrations,
|
applyPendingMigrations,
|
||||||
agents,
|
agents,
|
||||||
|
assets,
|
||||||
companies,
|
companies,
|
||||||
createDb,
|
createDb,
|
||||||
|
documentRevisions,
|
||||||
|
documents,
|
||||||
ensurePostgresDatabase,
|
ensurePostgresDatabase,
|
||||||
formatDatabaseBackupResult,
|
formatDatabaseBackupResult,
|
||||||
goals,
|
goals,
|
||||||
heartbeatRuns,
|
heartbeatRuns,
|
||||||
|
issueAttachments,
|
||||||
issueComments,
|
issueComments,
|
||||||
issueDocuments,
|
issueDocuments,
|
||||||
issues,
|
issues,
|
||||||
@@ -59,7 +65,13 @@ import {
|
|||||||
import {
|
import {
|
||||||
buildWorktreeMergePlan,
|
buildWorktreeMergePlan,
|
||||||
parseWorktreeMergeScopes,
|
parseWorktreeMergeScopes,
|
||||||
|
type IssueAttachmentRow,
|
||||||
|
type IssueDocumentRow,
|
||||||
|
type DocumentRevisionRow,
|
||||||
|
type PlannedAttachmentInsert,
|
||||||
type PlannedCommentInsert,
|
type PlannedCommentInsert,
|
||||||
|
type PlannedIssueDocumentInsert,
|
||||||
|
type PlannedIssueDocumentMerge,
|
||||||
type PlannedIssueInsert,
|
type PlannedIssueInsert,
|
||||||
} from "./worktree-merge-history-lib.js";
|
} 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;
|
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 {
|
export function resolveWorktreeMakeTargetPath(name: string): string {
|
||||||
return path.resolve(os.homedir(), resolveWorktreeMakeName(name));
|
return path.resolve(os.homedir(), resolveWorktreeMakeName(name));
|
||||||
}
|
}
|
||||||
@@ -1244,7 +1412,6 @@ function renderMergePlan(plan: Awaited<ReturnType<typeof collectMergePlan>>["pla
|
|||||||
sourcePath: string;
|
sourcePath: string;
|
||||||
targetPath: string;
|
targetPath: string;
|
||||||
unsupportedRunCount: number;
|
unsupportedRunCount: number;
|
||||||
unsupportedDocumentCount: number;
|
|
||||||
}): string {
|
}): string {
|
||||||
const terminalWidth = Math.max(60, process.stdout.columns ?? 100);
|
const terminalWidth = Math.max(60, process.stdout.columns ?? 100);
|
||||||
const oneLine = (value: string) => value.replace(/\s+/g, " ").trim();
|
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(`- 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("");
|
||||||
lines.push("Adjustments");
|
lines.push("Adjustments");
|
||||||
lines.push(`- cleared assignee agents: ${plan.adjustments.clear_assignee_agent}`);
|
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 project workspaces: ${plan.adjustments.clear_project_workspace}`);
|
||||||
lines.push(`- cleared goals: ${plan.adjustments.clear_goal}`);
|
lines.push(`- cleared goals: ${plan.adjustments.clear_goal}`);
|
||||||
lines.push(`- cleared comment author agents: ${plan.adjustments.clear_author_agent}`);
|
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(`- coerced in_progress to todo: ${plan.adjustments.coerce_in_progress_to_todo}`);
|
||||||
|
|
||||||
lines.push("");
|
lines.push("");
|
||||||
lines.push("Not imported in this phase");
|
lines.push("Not imported in this phase");
|
||||||
lines.push(`- heartbeat runs: ${extras.unsupportedRunCount}`);
|
lines.push(`- heartbeat runs: ${extras.unsupportedRunCount}`);
|
||||||
lines.push(`- issue documents: ${extras.unsupportedDocumentCount}`);
|
|
||||||
lines.push("");
|
lines.push("");
|
||||||
lines.push("Identifiers shown above are provisional preview values. `--apply` reserves fresh issue numbers at write time.");
|
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>;
|
projectIdOverrides?: Record<string, string | null | undefined>;
|
||||||
}) {
|
}) {
|
||||||
const companyId = input.company.id;
|
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
|
input.targetDb
|
||||||
.select({
|
.select({
|
||||||
issueCounter: companies.issueCounter,
|
issueCounter: companies.issueCounter,
|
||||||
@@ -1341,12 +1543,140 @@ async function collectMergePlan(input: {
|
|||||||
.from(issueComments)
|
.from(issueComments)
|
||||||
.where(eq(issueComments.companyId, companyId))
|
.where(eq(issueComments.companyId, companyId))
|
||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
input.scopes.includes("comments")
|
input.targetDb
|
||||||
? input.targetDb
|
.select()
|
||||||
.select()
|
.from(issueComments)
|
||||||
.from(issueComments)
|
.where(eq(issueComments.companyId, companyId)),
|
||||||
.where(eq(issueComments.companyId, companyId))
|
input.sourceDb
|
||||||
: Promise.resolve([]),
|
.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
|
input.sourceDb
|
||||||
.select()
|
.select()
|
||||||
.from(projects)
|
.from(projects)
|
||||||
@@ -1371,11 +1701,6 @@ async function collectMergePlan(input: {
|
|||||||
.select({ count: sql<number>`count(*)::int` })
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
.from(heartbeatRuns)
|
.from(heartbeatRuns)
|
||||||
.where(eq(heartbeatRuns.companyId, companyId)),
|
.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) {
|
if (!targetCompanyRow) {
|
||||||
@@ -1392,6 +1717,12 @@ async function collectMergePlan(input: {
|
|||||||
targetIssues: targetIssuesRows,
|
targetIssues: targetIssuesRows,
|
||||||
sourceComments: sourceCommentsRows,
|
sourceComments: sourceCommentsRows,
|
||||||
targetComments: targetCommentsRows,
|
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,
|
targetAgents: targetAgentsRows,
|
||||||
targetProjects: targetProjectsRows,
|
targetProjects: targetProjectsRows,
|
||||||
targetProjectWorkspaces: targetProjectWorkspaceRows,
|
targetProjectWorkspaces: targetProjectWorkspaceRows,
|
||||||
@@ -1404,7 +1735,6 @@ async function collectMergePlan(input: {
|
|||||||
sourceProjects: sourceProjectsRows,
|
sourceProjects: sourceProjectsRows,
|
||||||
targetProjects: targetProjectsRows,
|
targetProjects: targetProjectsRows,
|
||||||
unsupportedRunCount: runCountRows[0]?.count ?? 0,
|
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: {
|
async function applyMergePlan(input: {
|
||||||
|
sourceStorage: ConfiguredStorage;
|
||||||
|
targetStorage: ConfiguredStorage;
|
||||||
targetDb: ClosableDb;
|
targetDb: ClosableDb;
|
||||||
company: ResolvedMergeCompany;
|
company: ResolvedMergeCompany;
|
||||||
plan: Awaited<ReturnType<typeof collectMergePlan>>["plan"];
|
plan: Awaited<ReturnType<typeof collectMergePlan>>["plan"];
|
||||||
@@ -1608,6 +1940,7 @@ async function applyMergePlan(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const insertedIssueIdentifiers = new Map<string, string>();
|
const insertedIssueIdentifiers = new Map<string, string>();
|
||||||
|
let insertedIssues = 0;
|
||||||
for (const issue of issueInserts) {
|
for (const issue of issueInserts) {
|
||||||
const issueNumber = nextIssueNumber;
|
const issueNumber = nextIssueNumber;
|
||||||
nextIssueNumber += 1;
|
nextIssueNumber += 1;
|
||||||
@@ -1647,6 +1980,7 @@ async function applyMergePlan(input: {
|
|||||||
createdAt: issue.source.createdAt,
|
createdAt: issue.source.createdAt,
|
||||||
updatedAt: issue.source.updatedAt,
|
updatedAt: issue.source.updatedAt,
|
||||||
});
|
});
|
||||||
|
insertedIssues += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const commentCandidates = input.plan.commentPlans.filter(
|
const commentCandidates = input.plan.commentPlans.filter(
|
||||||
@@ -1663,6 +1997,7 @@ async function applyMergePlan(input: {
|
|||||||
)
|
)
|
||||||
: new Set<string>();
|
: new Set<string>();
|
||||||
|
|
||||||
|
let insertedComments = 0;
|
||||||
for (const comment of commentCandidates) {
|
for (const comment of commentCandidates) {
|
||||||
if (existingCommentIds.has(comment.source.id)) continue;
|
if (existingCommentIds.has(comment.source.id)) continue;
|
||||||
const parentExists = await tx
|
const parentExists = await tx
|
||||||
@@ -1681,11 +2016,199 @@ async function applyMergePlan(input: {
|
|||||||
createdAt: comment.source.createdAt,
|
createdAt: comment.source.createdAt,
|
||||||
updatedAt: comment.source.updatedAt,
|
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 {
|
return {
|
||||||
insertedIssues: issueInserts.length,
|
insertedIssues,
|
||||||
insertedComments: commentCandidates.filter((comment) => !existingCommentIds.has(comment.source.id)).length,
|
insertedComments,
|
||||||
|
insertedDocuments,
|
||||||
|
mergedDocuments,
|
||||||
|
insertedDocumentRevisions,
|
||||||
|
insertedAttachments,
|
||||||
insertedIssueIdentifiers,
|
insertedIssueIdentifiers,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -1716,6 +2239,8 @@ export async function worktreeMergeHistoryCommand(sourceArg: string | undefined,
|
|||||||
const scopes = parseWorktreeMergeScopes(opts.scope);
|
const scopes = parseWorktreeMergeScopes(opts.scope);
|
||||||
const sourceHandle = await openConfiguredDb(sourceEndpoint.configPath);
|
const sourceHandle = await openConfiguredDb(sourceEndpoint.configPath);
|
||||||
const targetHandle = await openConfiguredDb(targetEndpoint.configPath);
|
const targetHandle = await openConfiguredDb(targetEndpoint.configPath);
|
||||||
|
const sourceStorage = openConfiguredStorage(sourceEndpoint.configPath);
|
||||||
|
const targetStorage = openConfiguredStorage(targetEndpoint.configPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const company = await resolveMergeCompany({
|
const company = await resolveMergeCompany({
|
||||||
@@ -1750,7 +2275,6 @@ export async function worktreeMergeHistoryCommand(sourceArg: string | undefined,
|
|||||||
sourcePath: `${sourceEndpoint.label} (${sourceEndpoint.rootPath})`,
|
sourcePath: `${sourceEndpoint.label} (${sourceEndpoint.rootPath})`,
|
||||||
targetPath: `${targetEndpoint.label} (${targetEndpoint.rootPath})`,
|
targetPath: `${targetEndpoint.label} (${targetEndpoint.rootPath})`,
|
||||||
unsupportedRunCount: collected.unsupportedRunCount,
|
unsupportedRunCount: collected.unsupportedRunCount,
|
||||||
unsupportedDocumentCount: collected.unsupportedDocumentCount,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (!opts.apply) {
|
if (!opts.apply) {
|
||||||
@@ -1769,13 +2293,15 @@ export async function worktreeMergeHistoryCommand(sourceArg: string | undefined,
|
|||||||
}
|
}
|
||||||
|
|
||||||
const applied = await applyMergePlan({
|
const applied = await applyMergePlan({
|
||||||
|
sourceStorage,
|
||||||
|
targetStorage,
|
||||||
targetDb: targetHandle.db,
|
targetDb: targetHandle.db,
|
||||||
company,
|
company,
|
||||||
plan: collected.plan,
|
plan: collected.plan,
|
||||||
});
|
});
|
||||||
p.outro(
|
p.outro(
|
||||||
pc.green(
|
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 {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user