Add worktree history merge command
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
182
cli/src/__tests__/worktree-merge-history.test.ts
Normal file
182
cli/src/__tests__/worktree-merge-history.test.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildWorktreeMergePlan, parseWorktreeMergeScopes } from "../commands/worktree-merge-history-lib.js";
|
||||
|
||||
function makeIssue(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "issue-1",
|
||||
companyId: "company-1",
|
||||
projectId: null,
|
||||
projectWorkspaceId: null,
|
||||
goalId: "goal-1",
|
||||
parentId: null,
|
||||
title: "Issue",
|
||||
description: null,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
executionLockedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "local-board",
|
||||
issueNumber: 1,
|
||||
identifier: "PAP-1",
|
||||
requestDepth: 0,
|
||||
billingCode: null,
|
||||
assigneeAdapterOverrides: null,
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspacePreference: null,
|
||||
executionWorkspaceSettings: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
cancelledAt: null,
|
||||
hiddenAt: null,
|
||||
createdAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
...overrides,
|
||||
} as any;
|
||||
}
|
||||
|
||||
function makeComment(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "comment-1",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
authorAgentId: null,
|
||||
authorUserId: "local-board",
|
||||
body: "hello",
|
||||
createdAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
...overrides,
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("worktree merge history planner", () => {
|
||||
it("parses default scopes", () => {
|
||||
expect(parseWorktreeMergeScopes(undefined)).toEqual(["issues", "comments"]);
|
||||
expect(parseWorktreeMergeScopes("issues")).toEqual(["issues"]);
|
||||
});
|
||||
|
||||
it("dedupes nested worktree issues by preserved source uuid", () => {
|
||||
const sharedIssue = makeIssue({ id: "issue-a", identifier: "PAP-10", title: "Shared" });
|
||||
const branchOneIssue = makeIssue({
|
||||
id: "issue-b",
|
||||
identifier: "PAP-22",
|
||||
title: "Branch one issue",
|
||||
createdAt: new Date("2026-03-20T01:00:00.000Z"),
|
||||
});
|
||||
const branchTwoIssue = makeIssue({
|
||||
id: "issue-c",
|
||||
identifier: "PAP-23",
|
||||
title: "Branch two issue",
|
||||
createdAt: new Date("2026-03-20T02:00:00.000Z"),
|
||||
});
|
||||
|
||||
const plan = buildWorktreeMergePlan({
|
||||
companyId: "company-1",
|
||||
companyName: "Paperclip",
|
||||
issuePrefix: "PAP",
|
||||
previewIssueCounterStart: 500,
|
||||
scopes: ["issues", "comments"],
|
||||
sourceIssues: [sharedIssue, branchOneIssue, branchTwoIssue],
|
||||
targetIssues: [sharedIssue, branchOneIssue],
|
||||
sourceComments: [],
|
||||
targetComments: [],
|
||||
targetAgents: [],
|
||||
targetProjects: [],
|
||||
targetProjectWorkspaces: [],
|
||||
targetGoals: [{ id: "goal-1" }] as any,
|
||||
});
|
||||
|
||||
expect(plan.counts.issuesToInsert).toBe(1);
|
||||
expect(plan.issuePlans.filter((item) => item.action === "insert").map((item) => item.source.id)).toEqual(["issue-c"]);
|
||||
expect(plan.issuePlans.find((item) => item.source.id === "issue-c" && item.action === "insert")).toMatchObject({
|
||||
previewIdentifier: "PAP-501",
|
||||
});
|
||||
});
|
||||
|
||||
it("clears missing references and coerces in_progress without an assignee", () => {
|
||||
const plan = buildWorktreeMergePlan({
|
||||
companyId: "company-1",
|
||||
companyName: "Paperclip",
|
||||
issuePrefix: "PAP",
|
||||
previewIssueCounterStart: 10,
|
||||
scopes: ["issues"],
|
||||
sourceIssues: [
|
||||
makeIssue({
|
||||
id: "issue-x",
|
||||
identifier: "PAP-99",
|
||||
status: "in_progress",
|
||||
assigneeAgentId: "agent-missing",
|
||||
projectId: "project-missing",
|
||||
projectWorkspaceId: "workspace-missing",
|
||||
goalId: "goal-missing",
|
||||
}),
|
||||
],
|
||||
targetIssues: [],
|
||||
sourceComments: [],
|
||||
targetComments: [],
|
||||
targetAgents: [],
|
||||
targetProjects: [],
|
||||
targetProjectWorkspaces: [],
|
||||
targetGoals: [],
|
||||
});
|
||||
|
||||
const insert = plan.issuePlans[0] as any;
|
||||
expect(insert.targetStatus).toBe("todo");
|
||||
expect(insert.targetAssigneeAgentId).toBeNull();
|
||||
expect(insert.targetProjectId).toBeNull();
|
||||
expect(insert.targetProjectWorkspaceId).toBeNull();
|
||||
expect(insert.targetGoalId).toBeNull();
|
||||
expect(insert.adjustments).toEqual([
|
||||
"clear_assignee_agent",
|
||||
"clear_project",
|
||||
"clear_project_workspace",
|
||||
"clear_goal",
|
||||
"coerce_in_progress_to_todo",
|
||||
]);
|
||||
});
|
||||
|
||||
it("imports comments onto shared or newly imported issues while skipping existing comments", () => {
|
||||
const sharedIssue = makeIssue({ id: "issue-a", identifier: "PAP-10" });
|
||||
const newIssue = makeIssue({
|
||||
id: "issue-b",
|
||||
identifier: "PAP-11",
|
||||
createdAt: new Date("2026-03-20T01:00:00.000Z"),
|
||||
});
|
||||
const existingComment = makeComment({ id: "comment-existing", issueId: "issue-a" });
|
||||
const sharedIssueComment = makeComment({ id: "comment-shared", issueId: "issue-a" });
|
||||
const newIssueComment = makeComment({
|
||||
id: "comment-new-issue",
|
||||
issueId: "issue-b",
|
||||
authorAgentId: "missing-agent",
|
||||
createdAt: new Date("2026-03-20T01:05:00.000Z"),
|
||||
});
|
||||
|
||||
const plan = buildWorktreeMergePlan({
|
||||
companyId: "company-1",
|
||||
companyName: "Paperclip",
|
||||
issuePrefix: "PAP",
|
||||
previewIssueCounterStart: 10,
|
||||
scopes: ["issues", "comments"],
|
||||
sourceIssues: [sharedIssue, newIssue],
|
||||
targetIssues: [sharedIssue],
|
||||
sourceComments: [existingComment, sharedIssueComment, newIssueComment],
|
||||
targetComments: [existingComment],
|
||||
targetAgents: [],
|
||||
targetProjects: [],
|
||||
targetProjectWorkspaces: [],
|
||||
targetGoals: [{ id: "goal-1" }] as any,
|
||||
});
|
||||
|
||||
expect(plan.counts.commentsToInsert).toBe(2);
|
||||
expect(plan.counts.commentsExisting).toBe(1);
|
||||
expect(plan.commentPlans.filter((item) => item.action === "insert").map((item) => item.source.id)).toEqual([
|
||||
"comment-shared",
|
||||
"comment-new-issue",
|
||||
]);
|
||||
expect(plan.adjustments.clear_author_agent).toBe(1);
|
||||
});
|
||||
});
|
||||
329
cli/src/commands/worktree-merge-history-lib.ts
Normal file
329
cli/src/commands/worktree-merge-history-lib.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
import {
|
||||
agents,
|
||||
goals,
|
||||
issueComments,
|
||||
issues,
|
||||
projects,
|
||||
projectWorkspaces,
|
||||
} from "@paperclipai/db";
|
||||
|
||||
type IssueRow = typeof issues.$inferSelect;
|
||||
type CommentRow = typeof issueComments.$inferSelect;
|
||||
type AgentRow = typeof agents.$inferSelect;
|
||||
type ProjectRow = typeof projects.$inferSelect;
|
||||
type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect;
|
||||
type GoalRow = typeof goals.$inferSelect;
|
||||
|
||||
export const WORKTREE_MERGE_SCOPES = ["issues", "comments"] as const;
|
||||
export type WorktreeMergeScope = (typeof WORKTREE_MERGE_SCOPES)[number];
|
||||
|
||||
export type ImportAdjustment =
|
||||
| "clear_assignee_agent"
|
||||
| "clear_project"
|
||||
| "clear_project_workspace"
|
||||
| "clear_goal"
|
||||
| "clear_author_agent"
|
||||
| "coerce_in_progress_to_todo";
|
||||
|
||||
export type IssueMergeAction = "skip_existing" | "insert";
|
||||
export type CommentMergeAction = "skip_existing" | "skip_missing_parent" | "insert";
|
||||
|
||||
export type PlannedIssueInsert = {
|
||||
source: IssueRow;
|
||||
action: "insert";
|
||||
previewIssueNumber: number;
|
||||
previewIdentifier: string;
|
||||
targetStatus: string;
|
||||
targetAssigneeAgentId: string | null;
|
||||
targetCreatedByAgentId: string | null;
|
||||
targetProjectId: string | null;
|
||||
targetProjectWorkspaceId: string | null;
|
||||
targetGoalId: string | null;
|
||||
adjustments: ImportAdjustment[];
|
||||
};
|
||||
|
||||
export type PlannedIssueSkip = {
|
||||
source: IssueRow;
|
||||
action: "skip_existing";
|
||||
driftKeys: string[];
|
||||
};
|
||||
|
||||
export type PlannedCommentInsert = {
|
||||
source: CommentRow;
|
||||
action: "insert";
|
||||
targetAuthorAgentId: string | null;
|
||||
adjustments: ImportAdjustment[];
|
||||
};
|
||||
|
||||
export type PlannedCommentSkip = {
|
||||
source: CommentRow;
|
||||
action: "skip_existing" | "skip_missing_parent";
|
||||
};
|
||||
|
||||
export type WorktreeMergePlan = {
|
||||
companyId: string;
|
||||
companyName: string;
|
||||
issuePrefix: string;
|
||||
previewIssueCounterStart: number;
|
||||
scopes: WorktreeMergeScope[];
|
||||
issuePlans: Array<PlannedIssueInsert | PlannedIssueSkip>;
|
||||
commentPlans: Array<PlannedCommentInsert | PlannedCommentSkip>;
|
||||
counts: {
|
||||
issuesToInsert: number;
|
||||
issuesExisting: number;
|
||||
issueDrift: number;
|
||||
commentsToInsert: number;
|
||||
commentsExisting: number;
|
||||
commentsMissingParent: number;
|
||||
};
|
||||
adjustments: Record<ImportAdjustment, number>;
|
||||
};
|
||||
|
||||
function compareIssueCoreFields(source: IssueRow, target: IssueRow): string[] {
|
||||
const driftKeys: string[] = [];
|
||||
if (source.title !== target.title) driftKeys.push("title");
|
||||
if ((source.description ?? null) !== (target.description ?? null)) driftKeys.push("description");
|
||||
if (source.status !== target.status) driftKeys.push("status");
|
||||
if (source.priority !== target.priority) driftKeys.push("priority");
|
||||
if ((source.parentId ?? null) !== (target.parentId ?? null)) driftKeys.push("parentId");
|
||||
if ((source.projectId ?? null) !== (target.projectId ?? null)) driftKeys.push("projectId");
|
||||
if ((source.projectWorkspaceId ?? null) !== (target.projectWorkspaceId ?? null)) driftKeys.push("projectWorkspaceId");
|
||||
if ((source.goalId ?? null) !== (target.goalId ?? null)) driftKeys.push("goalId");
|
||||
if ((source.assigneeAgentId ?? null) !== (target.assigneeAgentId ?? null)) driftKeys.push("assigneeAgentId");
|
||||
if ((source.assigneeUserId ?? null) !== (target.assigneeUserId ?? null)) driftKeys.push("assigneeUserId");
|
||||
return driftKeys;
|
||||
}
|
||||
|
||||
function incrementAdjustment(
|
||||
counts: Record<ImportAdjustment, number>,
|
||||
adjustment: ImportAdjustment,
|
||||
): void {
|
||||
counts[adjustment] += 1;
|
||||
}
|
||||
|
||||
function sortIssuesForImport(sourceIssues: IssueRow[]): IssueRow[] {
|
||||
const byId = new Map(sourceIssues.map((issue) => [issue.id, issue]));
|
||||
const memoDepth = new Map<string, number>();
|
||||
|
||||
const depthFor = (issue: IssueRow, stack = new Set<string>()): number => {
|
||||
const memoized = memoDepth.get(issue.id);
|
||||
if (memoized !== undefined) return memoized;
|
||||
if (!issue.parentId) {
|
||||
memoDepth.set(issue.id, 0);
|
||||
return 0;
|
||||
}
|
||||
if (stack.has(issue.id)) {
|
||||
memoDepth.set(issue.id, 0);
|
||||
return 0;
|
||||
}
|
||||
const parent = byId.get(issue.parentId);
|
||||
if (!parent) {
|
||||
memoDepth.set(issue.id, 0);
|
||||
return 0;
|
||||
}
|
||||
stack.add(issue.id);
|
||||
const depth = depthFor(parent, stack) + 1;
|
||||
stack.delete(issue.id);
|
||||
memoDepth.set(issue.id, depth);
|
||||
return depth;
|
||||
};
|
||||
|
||||
return [...sourceIssues].sort((left, right) => {
|
||||
const depthDelta = depthFor(left) - depthFor(right);
|
||||
if (depthDelta !== 0) return depthDelta;
|
||||
const createdDelta = left.createdAt.getTime() - right.createdAt.getTime();
|
||||
if (createdDelta !== 0) return createdDelta;
|
||||
return left.id.localeCompare(right.id);
|
||||
});
|
||||
}
|
||||
|
||||
export function parseWorktreeMergeScopes(rawValue: string | undefined): WorktreeMergeScope[] {
|
||||
if (!rawValue || rawValue.trim().length === 0) {
|
||||
return ["issues", "comments"];
|
||||
}
|
||||
|
||||
const parsed = rawValue
|
||||
.split(",")
|
||||
.map((value) => value.trim().toLowerCase())
|
||||
.filter((value): value is WorktreeMergeScope =>
|
||||
(WORKTREE_MERGE_SCOPES as readonly string[]).includes(value),
|
||||
);
|
||||
|
||||
if (parsed.length === 0) {
|
||||
throw new Error(
|
||||
`Invalid scope "${rawValue}". Expected a comma-separated list of: ${WORKTREE_MERGE_SCOPES.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return [...new Set(parsed)];
|
||||
}
|
||||
|
||||
export function buildWorktreeMergePlan(input: {
|
||||
companyId: string;
|
||||
companyName: string;
|
||||
issuePrefix: string;
|
||||
previewIssueCounterStart: number;
|
||||
scopes: WorktreeMergeScope[];
|
||||
sourceIssues: IssueRow[];
|
||||
targetIssues: IssueRow[];
|
||||
sourceComments: CommentRow[];
|
||||
targetComments: CommentRow[];
|
||||
targetAgents: AgentRow[];
|
||||
targetProjects: ProjectRow[];
|
||||
targetProjectWorkspaces: ProjectWorkspaceRow[];
|
||||
targetGoals: GoalRow[];
|
||||
}): WorktreeMergePlan {
|
||||
const targetIssuesById = new Map(input.targetIssues.map((issue) => [issue.id, issue]));
|
||||
const targetCommentIds = new Set(input.targetComments.map((comment) => comment.id));
|
||||
const targetAgentIds = new Set(input.targetAgents.map((agent) => agent.id));
|
||||
const targetProjectIds = new Set(input.targetProjects.map((project) => project.id));
|
||||
const targetProjectWorkspaceIds = new Set(input.targetProjectWorkspaces.map((workspace) => workspace.id));
|
||||
const targetGoalIds = new Set(input.targetGoals.map((goal) => goal.id));
|
||||
const scopes = new Set(input.scopes);
|
||||
|
||||
const adjustmentCounts: Record<ImportAdjustment, number> = {
|
||||
clear_assignee_agent: 0,
|
||||
clear_project: 0,
|
||||
clear_project_workspace: 0,
|
||||
clear_goal: 0,
|
||||
clear_author_agent: 0,
|
||||
coerce_in_progress_to_todo: 0,
|
||||
};
|
||||
|
||||
const issuePlans: Array<PlannedIssueInsert | PlannedIssueSkip> = [];
|
||||
let nextPreviewIssueNumber = input.previewIssueCounterStart;
|
||||
for (const issue of sortIssuesForImport(input.sourceIssues)) {
|
||||
const existing = targetIssuesById.get(issue.id);
|
||||
if (existing) {
|
||||
issuePlans.push({
|
||||
source: issue,
|
||||
action: "skip_existing",
|
||||
driftKeys: compareIssueCoreFields(issue, existing),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
nextPreviewIssueNumber += 1;
|
||||
const adjustments: ImportAdjustment[] = [];
|
||||
const targetAssigneeAgentId =
|
||||
issue.assigneeAgentId && targetAgentIds.has(issue.assigneeAgentId) ? issue.assigneeAgentId : null;
|
||||
if (issue.assigneeAgentId && !targetAssigneeAgentId) {
|
||||
adjustments.push("clear_assignee_agent");
|
||||
incrementAdjustment(adjustmentCounts, "clear_assignee_agent");
|
||||
}
|
||||
|
||||
const targetCreatedByAgentId =
|
||||
issue.createdByAgentId && targetAgentIds.has(issue.createdByAgentId) ? issue.createdByAgentId : null;
|
||||
|
||||
const targetProjectId =
|
||||
issue.projectId && targetProjectIds.has(issue.projectId) ? issue.projectId : null;
|
||||
if (issue.projectId && !targetProjectId) {
|
||||
adjustments.push("clear_project");
|
||||
incrementAdjustment(adjustmentCounts, "clear_project");
|
||||
}
|
||||
|
||||
const targetProjectWorkspaceId =
|
||||
targetProjectId
|
||||
&& issue.projectWorkspaceId
|
||||
&& targetProjectWorkspaceIds.has(issue.projectWorkspaceId)
|
||||
? issue.projectWorkspaceId
|
||||
: null;
|
||||
if (issue.projectWorkspaceId && !targetProjectWorkspaceId) {
|
||||
adjustments.push("clear_project_workspace");
|
||||
incrementAdjustment(adjustmentCounts, "clear_project_workspace");
|
||||
}
|
||||
|
||||
const targetGoalId =
|
||||
issue.goalId && targetGoalIds.has(issue.goalId) ? issue.goalId : null;
|
||||
if (issue.goalId && !targetGoalId) {
|
||||
adjustments.push("clear_goal");
|
||||
incrementAdjustment(adjustmentCounts, "clear_goal");
|
||||
}
|
||||
|
||||
let targetStatus = issue.status;
|
||||
if (
|
||||
targetStatus === "in_progress"
|
||||
&& !targetAssigneeAgentId
|
||||
&& !(issue.assigneeUserId && issue.assigneeUserId.trim().length > 0)
|
||||
) {
|
||||
targetStatus = "todo";
|
||||
adjustments.push("coerce_in_progress_to_todo");
|
||||
incrementAdjustment(adjustmentCounts, "coerce_in_progress_to_todo");
|
||||
}
|
||||
|
||||
issuePlans.push({
|
||||
source: issue,
|
||||
action: "insert",
|
||||
previewIssueNumber: nextPreviewIssueNumber,
|
||||
previewIdentifier: `${input.issuePrefix}-${nextPreviewIssueNumber}`,
|
||||
targetStatus,
|
||||
targetAssigneeAgentId,
|
||||
targetCreatedByAgentId,
|
||||
targetProjectId,
|
||||
targetProjectWorkspaceId,
|
||||
targetGoalId,
|
||||
adjustments,
|
||||
});
|
||||
}
|
||||
|
||||
const issueIdsAvailableAfterImport = new Set<string>([
|
||||
...input.targetIssues.map((issue) => issue.id),
|
||||
...issuePlans.filter((plan): plan is PlannedIssueInsert => plan.action === "insert").map((plan) => plan.source.id),
|
||||
]);
|
||||
|
||||
const commentPlans: Array<PlannedCommentInsert | PlannedCommentSkip> = [];
|
||||
if (scopes.has("comments")) {
|
||||
const sortedComments = [...input.sourceComments].sort((left, right) => {
|
||||
const createdDelta = left.createdAt.getTime() - right.createdAt.getTime();
|
||||
if (createdDelta !== 0) return createdDelta;
|
||||
return left.id.localeCompare(right.id);
|
||||
});
|
||||
|
||||
for (const comment of sortedComments) {
|
||||
if (targetCommentIds.has(comment.id)) {
|
||||
commentPlans.push({ source: comment, action: "skip_existing" });
|
||||
continue;
|
||||
}
|
||||
if (!issueIdsAvailableAfterImport.has(comment.issueId)) {
|
||||
commentPlans.push({ source: comment, action: "skip_missing_parent" });
|
||||
continue;
|
||||
}
|
||||
|
||||
const adjustments: ImportAdjustment[] = [];
|
||||
const targetAuthorAgentId =
|
||||
comment.authorAgentId && targetAgentIds.has(comment.authorAgentId) ? comment.authorAgentId : null;
|
||||
if (comment.authorAgentId && !targetAuthorAgentId) {
|
||||
adjustments.push("clear_author_agent");
|
||||
incrementAdjustment(adjustmentCounts, "clear_author_agent");
|
||||
}
|
||||
|
||||
commentPlans.push({
|
||||
source: comment,
|
||||
action: "insert",
|
||||
targetAuthorAgentId,
|
||||
adjustments,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const counts = {
|
||||
issuesToInsert: issuePlans.filter((plan) => plan.action === "insert").length,
|
||||
issuesExisting: issuePlans.filter((plan) => plan.action === "skip_existing").length,
|
||||
issueDrift: issuePlans.filter((plan) => plan.action === "skip_existing" && plan.driftKeys.length > 0).length,
|
||||
commentsToInsert: commentPlans.filter((plan) => plan.action === "insert").length,
|
||||
commentsExisting: commentPlans.filter((plan) => plan.action === "skip_existing").length,
|
||||
commentsMissingParent: commentPlans.filter((plan) => plan.action === "skip_missing_parent").length,
|
||||
};
|
||||
|
||||
return {
|
||||
companyId: input.companyId,
|
||||
companyName: input.companyName,
|
||||
issuePrefix: input.issuePrefix,
|
||||
previewIssueCounterStart: input.previewIssueCounterStart,
|
||||
scopes: input.scopes,
|
||||
issuePlans,
|
||||
commentPlans,
|
||||
counts,
|
||||
adjustments: adjustmentCounts,
|
||||
};
|
||||
}
|
||||
@@ -17,13 +17,21 @@ import { execFileSync } from "node:child_process";
|
||||
import { createServer } from "node:net";
|
||||
import * as p from "@clack/prompts";
|
||||
import pc from "picocolors";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import {
|
||||
applyPendingMigrations,
|
||||
agents,
|
||||
companies,
|
||||
createDb,
|
||||
ensurePostgresDatabase,
|
||||
formatDatabaseBackupResult,
|
||||
goals,
|
||||
heartbeatRuns,
|
||||
issueComments,
|
||||
issueDocuments,
|
||||
issues,
|
||||
projectWorkspaces,
|
||||
projects,
|
||||
runDatabaseBackup,
|
||||
runDatabaseRestore,
|
||||
} from "@paperclipai/db";
|
||||
@@ -48,6 +56,12 @@ import {
|
||||
type WorktreeSeedMode,
|
||||
type WorktreeLocalPaths,
|
||||
} from "./worktree-lib.js";
|
||||
import {
|
||||
buildWorktreeMergePlan,
|
||||
parseWorktreeMergeScopes,
|
||||
type PlannedCommentInsert,
|
||||
type PlannedIssueInsert,
|
||||
} from "./worktree-merge-history-lib.js";
|
||||
|
||||
type WorktreeInitOptions = {
|
||||
name?: string;
|
||||
@@ -73,6 +87,14 @@ type WorktreeEnvOptions = {
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type WorktreeMergeHistoryOptions = {
|
||||
company?: string;
|
||||
scope?: string;
|
||||
apply?: boolean;
|
||||
dry?: boolean;
|
||||
yes?: boolean;
|
||||
};
|
||||
|
||||
type EmbeddedPostgresInstance = {
|
||||
initialise(): Promise<void>;
|
||||
start(): Promise<void>;
|
||||
@@ -1071,6 +1093,447 @@ export async function worktreeEnvCommand(opts: WorktreeEnvOptions): Promise<void
|
||||
console.log(formatShellExports(out));
|
||||
}
|
||||
|
||||
type ClosableDb = ReturnType<typeof createDb> & {
|
||||
$client?: { end?: (opts?: { timeout?: number }) => Promise<void> };
|
||||
};
|
||||
|
||||
type OpenDbHandle = {
|
||||
db: ClosableDb;
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
type ResolvedMergeCompany = {
|
||||
id: string;
|
||||
name: string;
|
||||
issuePrefix: string;
|
||||
};
|
||||
|
||||
function requirePathArgument(name: string, value: string | undefined): string {
|
||||
const trimmed = nonEmpty(value);
|
||||
if (!trimmed) {
|
||||
throw new Error(`${name} is required.`);
|
||||
}
|
||||
return path.resolve(trimmed);
|
||||
}
|
||||
|
||||
async function closeDb(db: ClosableDb): Promise<void> {
|
||||
await db.$client?.end?.({ timeout: 5 }).catch(() => undefined);
|
||||
}
|
||||
|
||||
async function openConfiguredDb(configPath: string): Promise<OpenDbHandle> {
|
||||
const config = readConfig(configPath);
|
||||
if (!config) {
|
||||
throw new Error(`Config not found at ${configPath}.`);
|
||||
}
|
||||
const envEntries = readPaperclipEnvEntries(resolvePaperclipEnvFile(configPath));
|
||||
let embeddedHandle: EmbeddedPostgresHandle | null = null;
|
||||
|
||||
try {
|
||||
if (config.database.mode === "embedded-postgres") {
|
||||
embeddedHandle = await ensureEmbeddedPostgres(
|
||||
config.database.embeddedPostgresDataDir,
|
||||
config.database.embeddedPostgresPort,
|
||||
);
|
||||
}
|
||||
const connectionString = resolveSourceConnectionString(config, envEntries, embeddedHandle?.port);
|
||||
const db = createDb(connectionString) as ClosableDb;
|
||||
return {
|
||||
db,
|
||||
stop: async () => {
|
||||
await closeDb(db);
|
||||
if (embeddedHandle?.startedByThisProcess) {
|
||||
await embeddedHandle.stop();
|
||||
}
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (embeddedHandle?.startedByThisProcess) {
|
||||
await embeddedHandle.stop().catch(() => undefined);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveMergeCompany(input: {
|
||||
sourceDb: ClosableDb;
|
||||
targetDb: ClosableDb;
|
||||
selector?: string;
|
||||
}): Promise<ResolvedMergeCompany> {
|
||||
const [sourceCompanies, targetCompanies] = await Promise.all([
|
||||
input.sourceDb
|
||||
.select({
|
||||
id: companies.id,
|
||||
name: companies.name,
|
||||
issuePrefix: companies.issuePrefix,
|
||||
})
|
||||
.from(companies),
|
||||
input.targetDb
|
||||
.select({
|
||||
id: companies.id,
|
||||
name: companies.name,
|
||||
issuePrefix: companies.issuePrefix,
|
||||
})
|
||||
.from(companies),
|
||||
]);
|
||||
|
||||
const targetById = new Map(targetCompanies.map((company) => [company.id, company]));
|
||||
const shared = sourceCompanies.filter((company) => targetById.has(company.id));
|
||||
const selector = nonEmpty(input.selector);
|
||||
if (selector) {
|
||||
const matched = shared.find(
|
||||
(company) => company.id === selector || company.issuePrefix.toLowerCase() === selector.toLowerCase(),
|
||||
);
|
||||
if (!matched) {
|
||||
throw new Error(`Could not resolve company "${selector}" in both source and target databases.`);
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
if (shared.length === 1) {
|
||||
return shared[0];
|
||||
}
|
||||
|
||||
if (shared.length === 0) {
|
||||
throw new Error("Source and target databases do not share a company id. Pass --company explicitly once both sides match.");
|
||||
}
|
||||
|
||||
const options = shared
|
||||
.map((company) => `${company.issuePrefix} (${company.name})`)
|
||||
.join(", ");
|
||||
throw new Error(`Multiple shared companies found. Re-run with --company <id-or-prefix>. Options: ${options}`);
|
||||
}
|
||||
|
||||
function renderMergePlan(plan: Awaited<ReturnType<typeof collectMergePlan>>["plan"], extras: {
|
||||
sourcePath: string;
|
||||
unsupportedRunCount: number;
|
||||
unsupportedDocumentCount: number;
|
||||
}): string {
|
||||
const lines = [
|
||||
`Mode: preview`,
|
||||
`Source: ${extras.sourcePath}`,
|
||||
`Company: ${plan.companyName} (${plan.issuePrefix})`,
|
||||
"",
|
||||
"Issues",
|
||||
`- insert: ${plan.counts.issuesToInsert}`,
|
||||
`- already present: ${plan.counts.issuesExisting}`,
|
||||
`- shared/imported issues with drift: ${plan.counts.issueDrift}`,
|
||||
];
|
||||
|
||||
const issueInserts = plan.issuePlans.filter((item): item is PlannedIssueInsert => item.action === "insert");
|
||||
if (issueInserts.length > 0) {
|
||||
lines.push("");
|
||||
lines.push("Planned issue imports");
|
||||
for (const issue of issueInserts) {
|
||||
const adjustments = issue.adjustments.length > 0 ? ` [${issue.adjustments.join(", ")}]` : "";
|
||||
lines.push(
|
||||
`- ${issue.source.identifier ?? issue.source.id} -> ${issue.previewIdentifier} (${issue.targetStatus})${adjustments}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (plan.scopes.includes("comments")) {
|
||||
lines.push("");
|
||||
lines.push("Comments");
|
||||
lines.push(`- insert: ${plan.counts.commentsToInsert}`);
|
||||
lines.push(`- already present: ${plan.counts.commentsExisting}`);
|
||||
lines.push(`- skipped (missing parent): ${plan.counts.commentsMissingParent}`);
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push("Adjustments");
|
||||
lines.push(`- cleared assignee agents: ${plan.adjustments.clear_assignee_agent}`);
|
||||
lines.push(`- cleared projects: ${plan.adjustments.clear_project}`);
|
||||
lines.push(`- cleared project workspaces: ${plan.adjustments.clear_project_workspace}`);
|
||||
lines.push(`- cleared goals: ${plan.adjustments.clear_goal}`);
|
||||
lines.push(`- cleared comment author agents: ${plan.adjustments.clear_author_agent}`);
|
||||
lines.push(`- 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.");
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function collectMergePlan(input: {
|
||||
sourceDb: ClosableDb;
|
||||
targetDb: ClosableDb;
|
||||
company: ResolvedMergeCompany;
|
||||
scopes: ReturnType<typeof parseWorktreeMergeScopes>;
|
||||
}) {
|
||||
const companyId = input.company.id;
|
||||
const [targetCompanyRow, sourceIssuesRows, targetIssuesRows, sourceCommentsRows, targetCommentsRows, targetAgentsRows, targetProjectsRows, targetProjectWorkspaceRows, targetGoalsRows, runCountRows, documentCountRows] = await Promise.all([
|
||||
input.targetDb
|
||||
.select({
|
||||
issueCounter: companies.issueCounter,
|
||||
})
|
||||
.from(companies)
|
||||
.where(eq(companies.id, companyId))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
input.sourceDb
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(eq(issues.companyId, companyId)),
|
||||
input.targetDb
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(eq(issues.companyId, companyId)),
|
||||
input.scopes.includes("comments")
|
||||
? input.sourceDb
|
||||
.select()
|
||||
.from(issueComments)
|
||||
.where(eq(issueComments.companyId, companyId))
|
||||
: Promise.resolve([]),
|
||||
input.scopes.includes("comments")
|
||||
? input.targetDb
|
||||
.select()
|
||||
.from(issueComments)
|
||||
.where(eq(issueComments.companyId, companyId))
|
||||
: Promise.resolve([]),
|
||||
input.targetDb
|
||||
.select()
|
||||
.from(agents)
|
||||
.where(eq(agents.companyId, companyId)),
|
||||
input.targetDb
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(eq(projects.companyId, companyId)),
|
||||
input.targetDb
|
||||
.select()
|
||||
.from(projectWorkspaces)
|
||||
.where(eq(projectWorkspaces.companyId, companyId)),
|
||||
input.targetDb
|
||||
.select()
|
||||
.from(goals)
|
||||
.where(eq(goals.companyId, companyId)),
|
||||
input.sourceDb
|
||||
.select({ count: sql<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) {
|
||||
throw new Error(`Target company ${companyId} was not found.`);
|
||||
}
|
||||
|
||||
const plan = buildWorktreeMergePlan({
|
||||
companyId,
|
||||
companyName: input.company.name,
|
||||
issuePrefix: input.company.issuePrefix,
|
||||
previewIssueCounterStart: targetCompanyRow.issueCounter,
|
||||
scopes: input.scopes,
|
||||
sourceIssues: sourceIssuesRows,
|
||||
targetIssues: targetIssuesRows,
|
||||
sourceComments: sourceCommentsRows,
|
||||
targetComments: targetCommentsRows,
|
||||
targetAgents: targetAgentsRows,
|
||||
targetProjects: targetProjectsRows,
|
||||
targetProjectWorkspaces: targetProjectWorkspaceRows,
|
||||
targetGoals: targetGoalsRows,
|
||||
});
|
||||
|
||||
return {
|
||||
plan,
|
||||
unsupportedRunCount: runCountRows[0]?.count ?? 0,
|
||||
unsupportedDocumentCount: documentCountRows[0]?.count ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function applyMergePlan(input: {
|
||||
targetDb: ClosableDb;
|
||||
company: ResolvedMergeCompany;
|
||||
plan: Awaited<ReturnType<typeof collectMergePlan>>["plan"];
|
||||
}) {
|
||||
const companyId = input.company.id;
|
||||
|
||||
return await input.targetDb.transaction(async (tx) => {
|
||||
const issueCandidates = input.plan.issuePlans.filter(
|
||||
(plan): plan is PlannedIssueInsert => plan.action === "insert",
|
||||
);
|
||||
const issueCandidateIds = issueCandidates.map((issue) => issue.source.id);
|
||||
const existingIssueIds = issueCandidateIds.length > 0
|
||||
? new Set(
|
||||
(await tx
|
||||
.select({ id: issues.id })
|
||||
.from(issues)
|
||||
.where(inArray(issues.id, issueCandidateIds)))
|
||||
.map((row) => row.id),
|
||||
)
|
||||
: new Set<string>();
|
||||
const issueInserts = issueCandidates.filter((issue) => !existingIssueIds.has(issue.source.id));
|
||||
|
||||
let nextIssueNumber = 0;
|
||||
if (issueInserts.length > 0) {
|
||||
const [companyRow] = await tx
|
||||
.update(companies)
|
||||
.set({ issueCounter: sql`${companies.issueCounter} + ${issueInserts.length}` })
|
||||
.where(eq(companies.id, companyId))
|
||||
.returning({ issueCounter: companies.issueCounter });
|
||||
nextIssueNumber = companyRow.issueCounter - issueInserts.length + 1;
|
||||
}
|
||||
|
||||
const insertedIssueIdentifiers = new Map<string, string>();
|
||||
for (const issue of issueInserts) {
|
||||
const issueNumber = nextIssueNumber;
|
||||
nextIssueNumber += 1;
|
||||
const identifier = `${input.company.issuePrefix}-${issueNumber}`;
|
||||
insertedIssueIdentifiers.set(issue.source.id, identifier);
|
||||
await tx.insert(issues).values({
|
||||
id: issue.source.id,
|
||||
companyId,
|
||||
projectId: issue.targetProjectId,
|
||||
projectWorkspaceId: issue.targetProjectWorkspaceId,
|
||||
goalId: issue.targetGoalId,
|
||||
parentId: issue.source.parentId,
|
||||
title: issue.source.title,
|
||||
description: issue.source.description,
|
||||
status: issue.targetStatus,
|
||||
priority: issue.source.priority,
|
||||
assigneeAgentId: issue.targetAssigneeAgentId,
|
||||
assigneeUserId: issue.source.assigneeUserId,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
executionLockedAt: null,
|
||||
createdByAgentId: issue.targetCreatedByAgentId,
|
||||
createdByUserId: issue.source.createdByUserId,
|
||||
issueNumber,
|
||||
identifier,
|
||||
requestDepth: issue.source.requestDepth,
|
||||
billingCode: issue.source.billingCode,
|
||||
assigneeAdapterOverrides: issue.targetAssigneeAgentId ? issue.source.assigneeAdapterOverrides : null,
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspacePreference: null,
|
||||
executionWorkspaceSettings: null,
|
||||
startedAt: issue.source.startedAt,
|
||||
completedAt: issue.source.completedAt,
|
||||
cancelledAt: issue.source.cancelledAt,
|
||||
hiddenAt: issue.source.hiddenAt,
|
||||
createdAt: issue.source.createdAt,
|
||||
updatedAt: issue.source.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
const commentCandidates = input.plan.commentPlans.filter(
|
||||
(plan): plan is PlannedCommentInsert => plan.action === "insert",
|
||||
);
|
||||
const commentCandidateIds = commentCandidates.map((comment) => comment.source.id);
|
||||
const existingCommentIds = commentCandidateIds.length > 0
|
||||
? new Set(
|
||||
(await tx
|
||||
.select({ id: issueComments.id })
|
||||
.from(issueComments)
|
||||
.where(inArray(issueComments.id, commentCandidateIds)))
|
||||
.map((row) => row.id),
|
||||
)
|
||||
: new Set<string>();
|
||||
|
||||
for (const comment of commentCandidates) {
|
||||
if (existingCommentIds.has(comment.source.id)) continue;
|
||||
const parentExists = await tx
|
||||
.select({ id: issues.id })
|
||||
.from(issues)
|
||||
.where(and(eq(issues.id, comment.source.issueId), eq(issues.companyId, companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!parentExists) continue;
|
||||
await tx.insert(issueComments).values({
|
||||
id: comment.source.id,
|
||||
companyId,
|
||||
issueId: comment.source.issueId,
|
||||
authorAgentId: comment.targetAuthorAgentId,
|
||||
authorUserId: comment.source.authorUserId,
|
||||
body: comment.source.body,
|
||||
createdAt: comment.source.createdAt,
|
||||
updatedAt: comment.source.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
insertedIssues: issueInserts.length,
|
||||
insertedComments: commentCandidates.filter((comment) => !existingCommentIds.has(comment.source.id)).length,
|
||||
insertedIssueIdentifiers,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function worktreeMergeHistoryCommand(sourceArg: string, opts: WorktreeMergeHistoryOptions): Promise<void> {
|
||||
if (opts.apply && opts.dry) {
|
||||
throw new Error("Use either --apply or --dry, not both.");
|
||||
}
|
||||
|
||||
const sourceRoot = requirePathArgument("Source worktree path", sourceArg);
|
||||
const sourceConfigPath = path.resolve(sourceRoot, ".paperclip", "config.json");
|
||||
if (!existsSync(sourceConfigPath)) {
|
||||
throw new Error(`Source worktree config not found at ${sourceConfigPath}.`);
|
||||
}
|
||||
|
||||
const targetConfigPath = resolveConfigPath();
|
||||
if (path.resolve(sourceConfigPath) === path.resolve(targetConfigPath)) {
|
||||
throw new Error("Source and target Paperclip configs are the same. Point --source at a different worktree.");
|
||||
}
|
||||
|
||||
const scopes = parseWorktreeMergeScopes(opts.scope);
|
||||
const sourceHandle = await openConfiguredDb(sourceConfigPath);
|
||||
const targetHandle = await openConfiguredDb(targetConfigPath);
|
||||
|
||||
try {
|
||||
const company = await resolveMergeCompany({
|
||||
sourceDb: sourceHandle.db,
|
||||
targetDb: targetHandle.db,
|
||||
selector: opts.company,
|
||||
});
|
||||
const collected = await collectMergePlan({
|
||||
sourceDb: sourceHandle.db,
|
||||
targetDb: targetHandle.db,
|
||||
company,
|
||||
scopes,
|
||||
});
|
||||
|
||||
console.log(renderMergePlan(collected.plan, {
|
||||
sourcePath: sourceRoot,
|
||||
unsupportedRunCount: collected.unsupportedRunCount,
|
||||
unsupportedDocumentCount: collected.unsupportedDocumentCount,
|
||||
}));
|
||||
|
||||
if (!opts.apply) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = opts.yes
|
||||
? true
|
||||
: await p.confirm({
|
||||
message: `Import ${collected.plan.counts.issuesToInsert} issues and ${collected.plan.counts.commentsToInsert} comments from ${path.basename(sourceRoot)}?`,
|
||||
initialValue: false,
|
||||
});
|
||||
if (p.isCancel(confirmed) || !confirmed) {
|
||||
p.log.warn("Import cancelled.");
|
||||
return;
|
||||
}
|
||||
|
||||
const applied = await applyMergePlan({
|
||||
targetDb: targetHandle.db,
|
||||
company,
|
||||
plan: collected.plan,
|
||||
});
|
||||
p.outro(
|
||||
pc.green(
|
||||
`Imported ${applied.insertedIssues} issues and ${applied.insertedComments} comments into ${company.issuePrefix}.`,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
await targetHandle.stop();
|
||||
await sourceHandle.stop();
|
||||
}
|
||||
}
|
||||
|
||||
export function registerWorktreeCommands(program: Command): void {
|
||||
const worktree = program.command("worktree").description("Worktree-local Paperclip instance helpers");
|
||||
|
||||
@@ -1114,6 +1577,17 @@ export function registerWorktreeCommands(program: Command): void {
|
||||
.option("--json", "Print JSON instead of shell exports")
|
||||
.action(worktreeEnvCommand);
|
||||
|
||||
program
|
||||
.command("worktree:merge-history")
|
||||
.description("Preview or import issue/comment history from another worktree into the current instance")
|
||||
.argument("<source>", "Path to the source worktree root")
|
||||
.option("--company <id-or-prefix>", "Company id or issue prefix to import")
|
||||
.option("--scope <items>", "Comma-separated scopes to import (issues, comments)", "issues,comments")
|
||||
.option("--apply", "Apply the import after previewing the plan", false)
|
||||
.option("--dry", "Preview only and do not import anything", false)
|
||||
.option("--yes", "Skip the interactive confirmation prompt when applying", false)
|
||||
.action(worktreeMergeHistoryCommand);
|
||||
|
||||
program
|
||||
.command("worktree:cleanup")
|
||||
.description("Safely remove a worktree, its branch, and its isolated instance data")
|
||||
|
||||
Reference in New Issue
Block a user