forked from farhoodlabs/paperclip
Import worktree documents and attachments
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
+545
-19
@@ -3,6 +3,7 @@ import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
promises as fsPromises,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
readlinkSync,
|
||||
@@ -15,18 +16,23 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createServer } from "node:net";
|
||||
import { Readable } from "node:stream";
|
||||
import * as p from "@clack/prompts";
|
||||
import pc from "picocolors";
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import {
|
||||
applyPendingMigrations,
|
||||
agents,
|
||||
assets,
|
||||
companies,
|
||||
createDb,
|
||||
documentRevisions,
|
||||
documents,
|
||||
ensurePostgresDatabase,
|
||||
formatDatabaseBackupResult,
|
||||
goals,
|
||||
heartbeatRuns,
|
||||
issueAttachments,
|
||||
issueComments,
|
||||
issueDocuments,
|
||||
issues,
|
||||
@@ -59,7 +65,13 @@ import {
|
||||
import {
|
||||
buildWorktreeMergePlan,
|
||||
parseWorktreeMergeScopes,
|
||||
type IssueAttachmentRow,
|
||||
type IssueDocumentRow,
|
||||
type DocumentRevisionRow,
|
||||
type PlannedAttachmentInsert,
|
||||
type PlannedCommentInsert,
|
||||
type PlannedIssueDocumentInsert,
|
||||
type PlannedIssueDocumentMerge,
|
||||
type PlannedIssueInsert,
|
||||
} from "./worktree-merge-history-lib.js";
|
||||
|
||||
@@ -181,6 +193,162 @@ function resolveWorktreeStartPoint(explicit?: string): string | undefined {
|
||||
return explicit ?? nonEmpty(process.env.PAPERCLIP_WORKTREE_START_POINT) ?? undefined;
|
||||
}
|
||||
|
||||
type ConfiguredStorage = {
|
||||
getObject(companyId: string, objectKey: string): Promise<Buffer>;
|
||||
putObject(companyId: string, objectKey: string, body: Buffer, contentType: string): Promise<void>;
|
||||
};
|
||||
|
||||
function assertStorageCompanyPrefix(companyId: string, objectKey: string): void {
|
||||
if (!objectKey.startsWith(`${companyId}/`) || objectKey.includes("..")) {
|
||||
throw new Error(`Invalid object key for company ${companyId}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeStorageObjectKey(objectKey: string): string {
|
||||
const normalized = objectKey.replace(/\\/g, "/").trim();
|
||||
if (!normalized || normalized.startsWith("/")) {
|
||||
throw new Error("Invalid object key.");
|
||||
}
|
||||
const parts = normalized.split("/").filter((part) => part.length > 0);
|
||||
if (parts.length === 0 || parts.some((part) => part === "." || part === "..")) {
|
||||
throw new Error("Invalid object key.");
|
||||
}
|
||||
return parts.join("/");
|
||||
}
|
||||
|
||||
function resolveLocalStoragePath(baseDir: string, objectKey: string): string {
|
||||
const resolved = path.resolve(baseDir, normalizeStorageObjectKey(objectKey));
|
||||
const root = path.resolve(baseDir);
|
||||
if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) {
|
||||
throw new Error("Invalid object key path.");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
async function s3BodyToBuffer(body: unknown): Promise<Buffer> {
|
||||
if (!body) {
|
||||
throw new Error("Object not found.");
|
||||
}
|
||||
if (Buffer.isBuffer(body)) {
|
||||
return body;
|
||||
}
|
||||
if (body instanceof Readable) {
|
||||
return await streamToBuffer(body);
|
||||
}
|
||||
|
||||
const candidate = body as {
|
||||
transformToWebStream?: () => ReadableStream<Uint8Array>;
|
||||
arrayBuffer?: () => Promise<ArrayBuffer>;
|
||||
};
|
||||
if (typeof candidate.transformToWebStream === "function") {
|
||||
const webStream = candidate.transformToWebStream();
|
||||
const reader = webStream.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) chunks.push(value);
|
||||
}
|
||||
return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)));
|
||||
}
|
||||
if (typeof candidate.arrayBuffer === "function") {
|
||||
return Buffer.from(await candidate.arrayBuffer());
|
||||
}
|
||||
|
||||
throw new Error("Unsupported storage response body.");
|
||||
}
|
||||
|
||||
function normalizeS3Prefix(prefix: string | undefined): string {
|
||||
if (!prefix) return "";
|
||||
return prefix.trim().replace(/^\/+/, "").replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function buildS3ObjectKey(prefix: string, objectKey: string): string {
|
||||
return prefix ? `${prefix}/${objectKey}` : objectKey;
|
||||
}
|
||||
|
||||
const dynamicImport = new Function("specifier", "return import(specifier);") as (specifier: string) => Promise<any>;
|
||||
|
||||
function createConfiguredStorageFromPaperclipConfig(config: PaperclipConfig): ConfiguredStorage {
|
||||
if (config.storage.provider === "local_disk") {
|
||||
const baseDir = expandHomePrefix(config.storage.localDisk.baseDir);
|
||||
return {
|
||||
async getObject(companyId: string, objectKey: string) {
|
||||
assertStorageCompanyPrefix(companyId, objectKey);
|
||||
return await fsPromises.readFile(resolveLocalStoragePath(baseDir, objectKey));
|
||||
},
|
||||
async putObject(companyId: string, objectKey: string, body: Buffer) {
|
||||
assertStorageCompanyPrefix(companyId, objectKey);
|
||||
const filePath = resolveLocalStoragePath(baseDir, objectKey);
|
||||
await fsPromises.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fsPromises.writeFile(filePath, body);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const prefix = normalizeS3Prefix(config.storage.s3.prefix);
|
||||
let s3ClientPromise: Promise<any> | null = null;
|
||||
async function getS3Client() {
|
||||
if (!s3ClientPromise) {
|
||||
s3ClientPromise = (async () => {
|
||||
const sdk = await dynamicImport("@aws-sdk/client-s3");
|
||||
return {
|
||||
sdk,
|
||||
client: new sdk.S3Client({
|
||||
region: config.storage.s3.region,
|
||||
endpoint: config.storage.s3.endpoint,
|
||||
forcePathStyle: config.storage.s3.forcePathStyle,
|
||||
}),
|
||||
};
|
||||
})();
|
||||
}
|
||||
return await s3ClientPromise;
|
||||
}
|
||||
const bucket = config.storage.s3.bucket;
|
||||
return {
|
||||
async getObject(companyId: string, objectKey: string) {
|
||||
assertStorageCompanyPrefix(companyId, objectKey);
|
||||
const { sdk, client } = await getS3Client();
|
||||
const response = await client.send(
|
||||
new sdk.GetObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: buildS3ObjectKey(prefix, objectKey),
|
||||
}),
|
||||
);
|
||||
return await s3BodyToBuffer(response.Body);
|
||||
},
|
||||
async putObject(companyId: string, objectKey: string, body: Buffer, contentType: string) {
|
||||
assertStorageCompanyPrefix(companyId, objectKey);
|
||||
const { sdk, client } = await getS3Client();
|
||||
await client.send(
|
||||
new sdk.PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: buildS3ObjectKey(prefix, objectKey),
|
||||
Body: body,
|
||||
ContentType: contentType,
|
||||
ContentLength: body.length,
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function openConfiguredStorage(configPath: string): ConfiguredStorage {
|
||||
const config = readConfig(configPath);
|
||||
if (!config) {
|
||||
throw new Error(`Config not found at ${configPath}.`);
|
||||
}
|
||||
return createConfiguredStorageFromPaperclipConfig(config);
|
||||
}
|
||||
|
||||
async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
export function resolveWorktreeMakeTargetPath(name: string): string {
|
||||
return path.resolve(os.homedir(), resolveWorktreeMakeName(name));
|
||||
}
|
||||
@@ -1244,7 +1412,6 @@ function renderMergePlan(plan: Awaited<ReturnType<typeof collectMergePlan>>["pla
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
unsupportedRunCount: number;
|
||||
unsupportedDocumentCount: number;
|
||||
}): string {
|
||||
const terminalWidth = Math.max(60, process.stdout.columns ?? 100);
|
||||
const oneLine = (value: string) => value.replace(/\s+/g, " ").trim();
|
||||
@@ -1292,6 +1459,21 @@ function renderMergePlan(plan: Awaited<ReturnType<typeof collectMergePlan>>["pla
|
||||
lines.push(`- skipped (missing parent): ${plan.counts.commentsMissingParent}`);
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push("Documents");
|
||||
lines.push(`- insert: ${plan.counts.documentsToInsert}`);
|
||||
lines.push(`- merge existing: ${plan.counts.documentsToMerge}`);
|
||||
lines.push(`- already present: ${plan.counts.documentsExisting}`);
|
||||
lines.push(`- skipped (conflicting key): ${plan.counts.documentsConflictingKey}`);
|
||||
lines.push(`- skipped (missing parent): ${plan.counts.documentsMissingParent}`);
|
||||
lines.push(`- revisions insert: ${plan.counts.documentRevisionsToInsert}`);
|
||||
|
||||
lines.push("");
|
||||
lines.push("Attachments");
|
||||
lines.push(`- insert: ${plan.counts.attachmentsToInsert}`);
|
||||
lines.push(`- already present: ${plan.counts.attachmentsExisting}`);
|
||||
lines.push(`- skipped (missing parent): ${plan.counts.attachmentsMissingParent}`);
|
||||
|
||||
lines.push("");
|
||||
lines.push("Adjustments");
|
||||
lines.push(`- cleared assignee agents: ${plan.adjustments.clear_assignee_agent}`);
|
||||
@@ -1299,12 +1481,14 @@ function renderMergePlan(plan: Awaited<ReturnType<typeof collectMergePlan>>["pla
|
||||
lines.push(`- cleared project workspaces: ${plan.adjustments.clear_project_workspace}`);
|
||||
lines.push(`- cleared goals: ${plan.adjustments.clear_goal}`);
|
||||
lines.push(`- cleared comment author agents: ${plan.adjustments.clear_author_agent}`);
|
||||
lines.push(`- cleared document agents: ${plan.adjustments.clear_document_agent}`);
|
||||
lines.push(`- cleared document revision agents: ${plan.adjustments.clear_document_revision_agent}`);
|
||||
lines.push(`- cleared attachment author agents: ${plan.adjustments.clear_attachment_agent}`);
|
||||
lines.push(`- coerced in_progress to todo: ${plan.adjustments.coerce_in_progress_to_todo}`);
|
||||
|
||||
lines.push("");
|
||||
lines.push("Not imported in this phase");
|
||||
lines.push(`- heartbeat runs: ${extras.unsupportedRunCount}`);
|
||||
lines.push(`- issue documents: ${extras.unsupportedDocumentCount}`);
|
||||
lines.push("");
|
||||
lines.push("Identifiers shown above are provisional preview values. `--apply` reserves fresh issue numbers at write time.");
|
||||
|
||||
@@ -1319,7 +1503,25 @@ async function collectMergePlan(input: {
|
||||
projectIdOverrides?: Record<string, string | null | undefined>;
|
||||
}) {
|
||||
const companyId = input.company.id;
|
||||
const [targetCompanyRow, sourceIssuesRows, targetIssuesRows, sourceCommentsRows, targetCommentsRows, sourceProjectsRows, targetProjectsRows, targetAgentsRows, targetProjectWorkspaceRows, targetGoalsRows, runCountRows, documentCountRows] = await Promise.all([
|
||||
const [
|
||||
targetCompanyRow,
|
||||
sourceIssuesRows,
|
||||
targetIssuesRows,
|
||||
sourceCommentsRows,
|
||||
targetCommentsRows,
|
||||
sourceIssueDocumentsRows,
|
||||
targetIssueDocumentsRows,
|
||||
sourceDocumentRevisionRows,
|
||||
targetDocumentRevisionRows,
|
||||
sourceAttachmentRows,
|
||||
targetAttachmentRows,
|
||||
sourceProjectsRows,
|
||||
targetProjectsRows,
|
||||
targetAgentsRows,
|
||||
targetProjectWorkspaceRows,
|
||||
targetGoalsRows,
|
||||
runCountRows,
|
||||
] = await Promise.all([
|
||||
input.targetDb
|
||||
.select({
|
||||
issueCounter: companies.issueCounter,
|
||||
@@ -1341,12 +1543,140 @@ async function collectMergePlan(input: {
|
||||
.from(issueComments)
|
||||
.where(eq(issueComments.companyId, companyId))
|
||||
: Promise.resolve([]),
|
||||
input.scopes.includes("comments")
|
||||
? input.targetDb
|
||||
.select()
|
||||
.from(issueComments)
|
||||
.where(eq(issueComments.companyId, companyId))
|
||||
: Promise.resolve([]),
|
||||
input.targetDb
|
||||
.select()
|
||||
.from(issueComments)
|
||||
.where(eq(issueComments.companyId, companyId)),
|
||||
input.sourceDb
|
||||
.select({
|
||||
id: issueDocuments.id,
|
||||
companyId: issueDocuments.companyId,
|
||||
issueId: issueDocuments.issueId,
|
||||
documentId: issueDocuments.documentId,
|
||||
key: issueDocuments.key,
|
||||
linkCreatedAt: issueDocuments.createdAt,
|
||||
linkUpdatedAt: issueDocuments.updatedAt,
|
||||
title: documents.title,
|
||||
format: documents.format,
|
||||
latestBody: documents.latestBody,
|
||||
latestRevisionId: documents.latestRevisionId,
|
||||
latestRevisionNumber: documents.latestRevisionNumber,
|
||||
createdByAgentId: documents.createdByAgentId,
|
||||
createdByUserId: documents.createdByUserId,
|
||||
updatedByAgentId: documents.updatedByAgentId,
|
||||
updatedByUserId: documents.updatedByUserId,
|
||||
documentCreatedAt: documents.createdAt,
|
||||
documentUpdatedAt: documents.updatedAt,
|
||||
})
|
||||
.from(issueDocuments)
|
||||
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
||||
.innerJoin(issues, eq(issueDocuments.issueId, issues.id))
|
||||
.where(eq(issues.companyId, companyId)),
|
||||
input.targetDb
|
||||
.select({
|
||||
id: issueDocuments.id,
|
||||
companyId: issueDocuments.companyId,
|
||||
issueId: issueDocuments.issueId,
|
||||
documentId: issueDocuments.documentId,
|
||||
key: issueDocuments.key,
|
||||
linkCreatedAt: issueDocuments.createdAt,
|
||||
linkUpdatedAt: issueDocuments.updatedAt,
|
||||
title: documents.title,
|
||||
format: documents.format,
|
||||
latestBody: documents.latestBody,
|
||||
latestRevisionId: documents.latestRevisionId,
|
||||
latestRevisionNumber: documents.latestRevisionNumber,
|
||||
createdByAgentId: documents.createdByAgentId,
|
||||
createdByUserId: documents.createdByUserId,
|
||||
updatedByAgentId: documents.updatedByAgentId,
|
||||
updatedByUserId: documents.updatedByUserId,
|
||||
documentCreatedAt: documents.createdAt,
|
||||
documentUpdatedAt: documents.updatedAt,
|
||||
})
|
||||
.from(issueDocuments)
|
||||
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
||||
.innerJoin(issues, eq(issueDocuments.issueId, issues.id))
|
||||
.where(eq(issues.companyId, companyId)),
|
||||
input.sourceDb
|
||||
.select({
|
||||
id: documentRevisions.id,
|
||||
companyId: documentRevisions.companyId,
|
||||
documentId: documentRevisions.documentId,
|
||||
revisionNumber: documentRevisions.revisionNumber,
|
||||
body: documentRevisions.body,
|
||||
changeSummary: documentRevisions.changeSummary,
|
||||
createdByAgentId: documentRevisions.createdByAgentId,
|
||||
createdByUserId: documentRevisions.createdByUserId,
|
||||
createdAt: documentRevisions.createdAt,
|
||||
})
|
||||
.from(documentRevisions)
|
||||
.innerJoin(issueDocuments, eq(documentRevisions.documentId, issueDocuments.documentId))
|
||||
.innerJoin(issues, eq(issueDocuments.issueId, issues.id))
|
||||
.where(eq(issues.companyId, companyId)),
|
||||
input.targetDb
|
||||
.select({
|
||||
id: documentRevisions.id,
|
||||
companyId: documentRevisions.companyId,
|
||||
documentId: documentRevisions.documentId,
|
||||
revisionNumber: documentRevisions.revisionNumber,
|
||||
body: documentRevisions.body,
|
||||
changeSummary: documentRevisions.changeSummary,
|
||||
createdByAgentId: documentRevisions.createdByAgentId,
|
||||
createdByUserId: documentRevisions.createdByUserId,
|
||||
createdAt: documentRevisions.createdAt,
|
||||
})
|
||||
.from(documentRevisions)
|
||||
.innerJoin(issueDocuments, eq(documentRevisions.documentId, issueDocuments.documentId))
|
||||
.innerJoin(issues, eq(issueDocuments.issueId, issues.id))
|
||||
.where(eq(issues.companyId, companyId)),
|
||||
input.sourceDb
|
||||
.select({
|
||||
id: issueAttachments.id,
|
||||
companyId: issueAttachments.companyId,
|
||||
issueId: issueAttachments.issueId,
|
||||
issueCommentId: issueAttachments.issueCommentId,
|
||||
assetId: issueAttachments.assetId,
|
||||
provider: assets.provider,
|
||||
objectKey: assets.objectKey,
|
||||
contentType: assets.contentType,
|
||||
byteSize: assets.byteSize,
|
||||
sha256: assets.sha256,
|
||||
originalFilename: assets.originalFilename,
|
||||
createdByAgentId: assets.createdByAgentId,
|
||||
createdByUserId: assets.createdByUserId,
|
||||
assetCreatedAt: assets.createdAt,
|
||||
assetUpdatedAt: assets.updatedAt,
|
||||
attachmentCreatedAt: issueAttachments.createdAt,
|
||||
attachmentUpdatedAt: issueAttachments.updatedAt,
|
||||
})
|
||||
.from(issueAttachments)
|
||||
.innerJoin(assets, eq(issueAttachments.assetId, assets.id))
|
||||
.innerJoin(issues, eq(issueAttachments.issueId, issues.id))
|
||||
.where(eq(issues.companyId, companyId)),
|
||||
input.targetDb
|
||||
.select({
|
||||
id: issueAttachments.id,
|
||||
companyId: issueAttachments.companyId,
|
||||
issueId: issueAttachments.issueId,
|
||||
issueCommentId: issueAttachments.issueCommentId,
|
||||
assetId: issueAttachments.assetId,
|
||||
provider: assets.provider,
|
||||
objectKey: assets.objectKey,
|
||||
contentType: assets.contentType,
|
||||
byteSize: assets.byteSize,
|
||||
sha256: assets.sha256,
|
||||
originalFilename: assets.originalFilename,
|
||||
createdByAgentId: assets.createdByAgentId,
|
||||
createdByUserId: assets.createdByUserId,
|
||||
assetCreatedAt: assets.createdAt,
|
||||
assetUpdatedAt: assets.updatedAt,
|
||||
attachmentCreatedAt: issueAttachments.createdAt,
|
||||
attachmentUpdatedAt: issueAttachments.updatedAt,
|
||||
})
|
||||
.from(issueAttachments)
|
||||
.innerJoin(assets, eq(issueAttachments.assetId, assets.id))
|
||||
.innerJoin(issues, eq(issueAttachments.issueId, issues.id))
|
||||
.where(eq(issues.companyId, companyId)),
|
||||
input.sourceDb
|
||||
.select()
|
||||
.from(projects)
|
||||
@@ -1371,11 +1701,6 @@ async function collectMergePlan(input: {
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.companyId, companyId)),
|
||||
input.sourceDb
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(issueDocuments)
|
||||
.innerJoin(issues, eq(issueDocuments.issueId, issues.id))
|
||||
.where(eq(issues.companyId, companyId)),
|
||||
]);
|
||||
|
||||
if (!targetCompanyRow) {
|
||||
@@ -1392,6 +1717,12 @@ async function collectMergePlan(input: {
|
||||
targetIssues: targetIssuesRows,
|
||||
sourceComments: sourceCommentsRows,
|
||||
targetComments: targetCommentsRows,
|
||||
sourceDocuments: sourceIssueDocumentsRows as IssueDocumentRow[],
|
||||
targetDocuments: targetIssueDocumentsRows as IssueDocumentRow[],
|
||||
sourceDocumentRevisions: sourceDocumentRevisionRows as DocumentRevisionRow[],
|
||||
targetDocumentRevisions: targetDocumentRevisionRows as DocumentRevisionRow[],
|
||||
sourceAttachments: sourceAttachmentRows as IssueAttachmentRow[],
|
||||
targetAttachments: targetAttachmentRows as IssueAttachmentRow[],
|
||||
targetAgents: targetAgentsRows,
|
||||
targetProjects: targetProjectsRows,
|
||||
targetProjectWorkspaces: targetProjectWorkspaceRows,
|
||||
@@ -1404,7 +1735,6 @@ async function collectMergePlan(input: {
|
||||
sourceProjects: sourceProjectsRows,
|
||||
targetProjects: targetProjectsRows,
|
||||
unsupportedRunCount: runCountRows[0]?.count ?? 0,
|
||||
unsupportedDocumentCount: documentCountRows[0]?.count ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1575,6 +1905,8 @@ async function promptForSourceEndpoint(excludeWorktreePath?: string): Promise<Re
|
||||
}
|
||||
|
||||
async function applyMergePlan(input: {
|
||||
sourceStorage: ConfiguredStorage;
|
||||
targetStorage: ConfiguredStorage;
|
||||
targetDb: ClosableDb;
|
||||
company: ResolvedMergeCompany;
|
||||
plan: Awaited<ReturnType<typeof collectMergePlan>>["plan"];
|
||||
@@ -1608,6 +1940,7 @@ async function applyMergePlan(input: {
|
||||
}
|
||||
|
||||
const insertedIssueIdentifiers = new Map<string, string>();
|
||||
let insertedIssues = 0;
|
||||
for (const issue of issueInserts) {
|
||||
const issueNumber = nextIssueNumber;
|
||||
nextIssueNumber += 1;
|
||||
@@ -1647,6 +1980,7 @@ async function applyMergePlan(input: {
|
||||
createdAt: issue.source.createdAt,
|
||||
updatedAt: issue.source.updatedAt,
|
||||
});
|
||||
insertedIssues += 1;
|
||||
}
|
||||
|
||||
const commentCandidates = input.plan.commentPlans.filter(
|
||||
@@ -1663,6 +1997,7 @@ async function applyMergePlan(input: {
|
||||
)
|
||||
: new Set<string>();
|
||||
|
||||
let insertedComments = 0;
|
||||
for (const comment of commentCandidates) {
|
||||
if (existingCommentIds.has(comment.source.id)) continue;
|
||||
const parentExists = await tx
|
||||
@@ -1681,11 +2016,199 @@ async function applyMergePlan(input: {
|
||||
createdAt: comment.source.createdAt,
|
||||
updatedAt: comment.source.updatedAt,
|
||||
});
|
||||
insertedComments += 1;
|
||||
}
|
||||
|
||||
const documentCandidates = input.plan.documentPlans.filter(
|
||||
(plan): plan is PlannedIssueDocumentInsert | PlannedIssueDocumentMerge =>
|
||||
plan.action === "insert" || plan.action === "merge_existing",
|
||||
);
|
||||
let insertedDocuments = 0;
|
||||
let mergedDocuments = 0;
|
||||
let insertedDocumentRevisions = 0;
|
||||
for (const documentPlan of documentCandidates) {
|
||||
const parentExists = await tx
|
||||
.select({ id: issues.id })
|
||||
.from(issues)
|
||||
.where(and(eq(issues.id, documentPlan.source.issueId), eq(issues.companyId, companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!parentExists) continue;
|
||||
|
||||
const conflictingKeyDocument = await tx
|
||||
.select({ documentId: issueDocuments.documentId })
|
||||
.from(issueDocuments)
|
||||
.where(and(eq(issueDocuments.issueId, documentPlan.source.issueId), eq(issueDocuments.key, documentPlan.source.key)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (
|
||||
conflictingKeyDocument
|
||||
&& conflictingKeyDocument.documentId !== documentPlan.source.documentId
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingDocument = await tx
|
||||
.select({ id: documents.id })
|
||||
.from(documents)
|
||||
.where(eq(documents.id, documentPlan.source.documentId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!existingDocument) {
|
||||
await tx.insert(documents).values({
|
||||
id: documentPlan.source.documentId,
|
||||
companyId,
|
||||
title: documentPlan.source.title,
|
||||
format: documentPlan.source.format,
|
||||
latestBody: documentPlan.source.latestBody,
|
||||
latestRevisionId: documentPlan.latestRevisionId,
|
||||
latestRevisionNumber: documentPlan.latestRevisionNumber,
|
||||
createdByAgentId: documentPlan.targetCreatedByAgentId,
|
||||
createdByUserId: documentPlan.source.createdByUserId,
|
||||
updatedByAgentId: documentPlan.targetUpdatedByAgentId,
|
||||
updatedByUserId: documentPlan.source.updatedByUserId,
|
||||
createdAt: documentPlan.source.documentCreatedAt,
|
||||
updatedAt: documentPlan.source.documentUpdatedAt,
|
||||
});
|
||||
await tx.insert(issueDocuments).values({
|
||||
id: documentPlan.source.id,
|
||||
companyId,
|
||||
issueId: documentPlan.source.issueId,
|
||||
documentId: documentPlan.source.documentId,
|
||||
key: documentPlan.source.key,
|
||||
createdAt: documentPlan.source.linkCreatedAt,
|
||||
updatedAt: documentPlan.source.linkUpdatedAt,
|
||||
});
|
||||
insertedDocuments += 1;
|
||||
} else {
|
||||
const existingLink = await tx
|
||||
.select({ id: issueDocuments.id })
|
||||
.from(issueDocuments)
|
||||
.where(eq(issueDocuments.documentId, documentPlan.source.documentId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existingLink) {
|
||||
await tx.insert(issueDocuments).values({
|
||||
id: documentPlan.source.id,
|
||||
companyId,
|
||||
issueId: documentPlan.source.issueId,
|
||||
documentId: documentPlan.source.documentId,
|
||||
key: documentPlan.source.key,
|
||||
createdAt: documentPlan.source.linkCreatedAt,
|
||||
updatedAt: documentPlan.source.linkUpdatedAt,
|
||||
});
|
||||
} else {
|
||||
await tx
|
||||
.update(issueDocuments)
|
||||
.set({
|
||||
issueId: documentPlan.source.issueId,
|
||||
key: documentPlan.source.key,
|
||||
updatedAt: documentPlan.source.linkUpdatedAt,
|
||||
})
|
||||
.where(eq(issueDocuments.documentId, documentPlan.source.documentId));
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(documents)
|
||||
.set({
|
||||
title: documentPlan.source.title,
|
||||
format: documentPlan.source.format,
|
||||
latestBody: documentPlan.source.latestBody,
|
||||
latestRevisionId: documentPlan.latestRevisionId,
|
||||
latestRevisionNumber: documentPlan.latestRevisionNumber,
|
||||
updatedByAgentId: documentPlan.targetUpdatedByAgentId,
|
||||
updatedByUserId: documentPlan.source.updatedByUserId,
|
||||
updatedAt: documentPlan.source.documentUpdatedAt,
|
||||
})
|
||||
.where(eq(documents.id, documentPlan.source.documentId));
|
||||
mergedDocuments += 1;
|
||||
}
|
||||
|
||||
const existingRevisionIds = new Set(
|
||||
(
|
||||
await tx
|
||||
.select({ id: documentRevisions.id })
|
||||
.from(documentRevisions)
|
||||
.where(eq(documentRevisions.documentId, documentPlan.source.documentId))
|
||||
).map((row) => row.id),
|
||||
);
|
||||
for (const revisionPlan of documentPlan.revisionsToInsert) {
|
||||
if (existingRevisionIds.has(revisionPlan.source.id)) continue;
|
||||
await tx.insert(documentRevisions).values({
|
||||
id: revisionPlan.source.id,
|
||||
companyId,
|
||||
documentId: documentPlan.source.documentId,
|
||||
revisionNumber: revisionPlan.targetRevisionNumber,
|
||||
body: revisionPlan.source.body,
|
||||
changeSummary: revisionPlan.source.changeSummary,
|
||||
createdByAgentId: revisionPlan.targetCreatedByAgentId,
|
||||
createdByUserId: revisionPlan.source.createdByUserId,
|
||||
createdAt: revisionPlan.source.createdAt,
|
||||
});
|
||||
insertedDocumentRevisions += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const attachmentCandidates = input.plan.attachmentPlans.filter(
|
||||
(plan): plan is PlannedAttachmentInsert => plan.action === "insert",
|
||||
);
|
||||
const existingAttachmentIds = new Set(
|
||||
(
|
||||
await tx
|
||||
.select({ id: issueAttachments.id })
|
||||
.from(issueAttachments)
|
||||
.where(eq(issueAttachments.companyId, companyId))
|
||||
).map((row) => row.id),
|
||||
);
|
||||
let insertedAttachments = 0;
|
||||
for (const attachment of attachmentCandidates) {
|
||||
if (existingAttachmentIds.has(attachment.source.id)) continue;
|
||||
const parentExists = await tx
|
||||
.select({ id: issues.id })
|
||||
.from(issues)
|
||||
.where(and(eq(issues.id, attachment.source.issueId), eq(issues.companyId, companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!parentExists) continue;
|
||||
|
||||
const body = await input.sourceStorage.getObject(companyId, attachment.source.objectKey);
|
||||
await input.targetStorage.putObject(
|
||||
companyId,
|
||||
attachment.source.objectKey,
|
||||
body,
|
||||
attachment.source.contentType,
|
||||
);
|
||||
|
||||
await tx.insert(assets).values({
|
||||
id: attachment.source.assetId,
|
||||
companyId,
|
||||
provider: attachment.source.provider,
|
||||
objectKey: attachment.source.objectKey,
|
||||
contentType: attachment.source.contentType,
|
||||
byteSize: attachment.source.byteSize,
|
||||
sha256: attachment.source.sha256,
|
||||
originalFilename: attachment.source.originalFilename,
|
||||
createdByAgentId: attachment.targetCreatedByAgentId,
|
||||
createdByUserId: attachment.source.createdByUserId,
|
||||
createdAt: attachment.source.assetCreatedAt,
|
||||
updatedAt: attachment.source.assetUpdatedAt,
|
||||
});
|
||||
|
||||
await tx.insert(issueAttachments).values({
|
||||
id: attachment.source.id,
|
||||
companyId,
|
||||
issueId: attachment.source.issueId,
|
||||
assetId: attachment.source.assetId,
|
||||
issueCommentId: attachment.targetIssueCommentId,
|
||||
createdAt: attachment.source.attachmentCreatedAt,
|
||||
updatedAt: attachment.source.attachmentUpdatedAt,
|
||||
});
|
||||
insertedAttachments += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
insertedIssues: issueInserts.length,
|
||||
insertedComments: commentCandidates.filter((comment) => !existingCommentIds.has(comment.source.id)).length,
|
||||
insertedIssues,
|
||||
insertedComments,
|
||||
insertedDocuments,
|
||||
mergedDocuments,
|
||||
insertedDocumentRevisions,
|
||||
insertedAttachments,
|
||||
insertedIssueIdentifiers,
|
||||
};
|
||||
});
|
||||
@@ -1716,6 +2239,8 @@ export async function worktreeMergeHistoryCommand(sourceArg: string | undefined,
|
||||
const scopes = parseWorktreeMergeScopes(opts.scope);
|
||||
const sourceHandle = await openConfiguredDb(sourceEndpoint.configPath);
|
||||
const targetHandle = await openConfiguredDb(targetEndpoint.configPath);
|
||||
const sourceStorage = openConfiguredStorage(sourceEndpoint.configPath);
|
||||
const targetStorage = openConfiguredStorage(targetEndpoint.configPath);
|
||||
|
||||
try {
|
||||
const company = await resolveMergeCompany({
|
||||
@@ -1750,7 +2275,6 @@ export async function worktreeMergeHistoryCommand(sourceArg: string | undefined,
|
||||
sourcePath: `${sourceEndpoint.label} (${sourceEndpoint.rootPath})`,
|
||||
targetPath: `${targetEndpoint.label} (${targetEndpoint.rootPath})`,
|
||||
unsupportedRunCount: collected.unsupportedRunCount,
|
||||
unsupportedDocumentCount: collected.unsupportedDocumentCount,
|
||||
}));
|
||||
|
||||
if (!opts.apply) {
|
||||
@@ -1769,13 +2293,15 @@ export async function worktreeMergeHistoryCommand(sourceArg: string | undefined,
|
||||
}
|
||||
|
||||
const applied = await applyMergePlan({
|
||||
sourceStorage,
|
||||
targetStorage,
|
||||
targetDb: targetHandle.db,
|
||||
company,
|
||||
plan: collected.plan,
|
||||
});
|
||||
p.outro(
|
||||
pc.green(
|
||||
`Imported ${applied.insertedIssues} issues and ${applied.insertedComments} comments into ${company.issuePrefix}.`,
|
||||
`Imported ${applied.insertedIssues} issues, ${applied.insertedComments} comments, ${applied.insertedDocuments} documents (${applied.insertedDocumentRevisions} revisions, ${applied.mergedDocuments} merged), and ${applied.insertedAttachments} attachments into ${company.issuePrefix}.`,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user