Import worktree documents and attachments

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-03-20 15:59:41 -05:00
parent be911754c5
commit 73ada45037
3 changed files with 1083 additions and 20 deletions
+545 -19
View File
@@ -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 {