Merge pull request #1383 from paperclipai/fix/codex-managed-home-followups
Improve worktree merge/import followups
This commit is contained in:
392
cli/src/__tests__/worktree-merge-history.test.ts
Normal file
392
cli/src/__tests__/worktree-merge-history.test.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
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;
|
||||
}
|
||||
|
||||
function makeIssueDocument(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "issue-document-1",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
documentId: "document-1",
|
||||
key: "plan",
|
||||
linkCreatedAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
linkUpdatedAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
title: "Plan",
|
||||
format: "markdown",
|
||||
latestBody: "# Plan",
|
||||
latestRevisionId: "revision-1",
|
||||
latestRevisionNumber: 1,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "local-board",
|
||||
updatedByAgentId: null,
|
||||
updatedByUserId: "local-board",
|
||||
documentCreatedAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
documentUpdatedAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
...overrides,
|
||||
} as any;
|
||||
}
|
||||
|
||||
function makeDocumentRevision(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "revision-1",
|
||||
companyId: "company-1",
|
||||
documentId: "document-1",
|
||||
revisionNumber: 1,
|
||||
body: "# Plan",
|
||||
changeSummary: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "local-board",
|
||||
createdAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
...overrides,
|
||||
} as any;
|
||||
}
|
||||
|
||||
function makeAttachment(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "attachment-1",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
issueCommentId: null,
|
||||
assetId: "asset-1",
|
||||
provider: "local_disk",
|
||||
objectKey: "company-1/issues/issue-1/2026/03/20/asset.png",
|
||||
contentType: "image/png",
|
||||
byteSize: 12,
|
||||
sha256: "deadbeef",
|
||||
originalFilename: "asset.png",
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "local-board",
|
||||
assetCreatedAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
assetUpdatedAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
attachmentCreatedAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
attachmentUpdatedAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
...overrides,
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("worktree merge history planner", () => {
|
||||
it("parses default scopes", () => {
|
||||
expect(parseWorktreeMergeScopes(undefined)).toEqual(["issues", "comments"]);
|
||||
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("applies an explicit project mapping override instead of clearing the project", () => {
|
||||
const plan = buildWorktreeMergePlan({
|
||||
companyId: "company-1",
|
||||
companyName: "Paperclip",
|
||||
issuePrefix: "PAP",
|
||||
previewIssueCounterStart: 10,
|
||||
scopes: ["issues"],
|
||||
sourceIssues: [
|
||||
makeIssue({
|
||||
id: "issue-project-map",
|
||||
identifier: "PAP-77",
|
||||
projectId: "source-project-1",
|
||||
projectWorkspaceId: "source-workspace-1",
|
||||
}),
|
||||
],
|
||||
targetIssues: [],
|
||||
sourceComments: [],
|
||||
targetComments: [],
|
||||
targetAgents: [],
|
||||
targetProjects: [{ id: "target-project-1", name: "Mapped project", status: "in_progress" }] as any,
|
||||
targetProjectWorkspaces: [],
|
||||
targetGoals: [{ id: "goal-1" }] as any,
|
||||
projectIdOverrides: {
|
||||
"source-project-1": "target-project-1",
|
||||
},
|
||||
});
|
||||
|
||||
const insert = plan.issuePlans[0] as any;
|
||||
expect(insert.targetProjectId).toBe("target-project-1");
|
||||
expect(insert.projectResolution).toBe("mapped");
|
||||
expect(insert.mappedProjectName).toBe("Mapped project");
|
||||
expect(insert.targetProjectWorkspaceId).toBeNull();
|
||||
expect(insert.adjustments).toEqual(["clear_project_workspace"]);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it("merges document revisions onto an existing shared document and renumbers conflicts", () => {
|
||||
const sharedIssue = makeIssue({ id: "issue-a", identifier: "PAP-10" });
|
||||
const sourceDocument = makeIssueDocument({
|
||||
issueId: "issue-a",
|
||||
documentId: "document-a",
|
||||
latestBody: "# Branch plan",
|
||||
latestRevisionId: "revision-branch-2",
|
||||
latestRevisionNumber: 2,
|
||||
documentUpdatedAt: new Date("2026-03-20T02:00:00.000Z"),
|
||||
linkUpdatedAt: new Date("2026-03-20T02:00:00.000Z"),
|
||||
});
|
||||
const targetDocument = makeIssueDocument({
|
||||
issueId: "issue-a",
|
||||
documentId: "document-a",
|
||||
latestBody: "# Main plan",
|
||||
latestRevisionId: "revision-main-2",
|
||||
latestRevisionNumber: 2,
|
||||
documentUpdatedAt: new Date("2026-03-20T01:00:00.000Z"),
|
||||
linkUpdatedAt: new Date("2026-03-20T01:00:00.000Z"),
|
||||
});
|
||||
const sourceRevisionOne = makeDocumentRevision({ documentId: "document-a", id: "revision-1" });
|
||||
const sourceRevisionTwo = makeDocumentRevision({
|
||||
documentId: "document-a",
|
||||
id: "revision-branch-2",
|
||||
revisionNumber: 2,
|
||||
body: "# Branch plan",
|
||||
createdAt: new Date("2026-03-20T02:00:00.000Z"),
|
||||
});
|
||||
const targetRevisionOne = makeDocumentRevision({ documentId: "document-a", id: "revision-1" });
|
||||
const targetRevisionTwo = makeDocumentRevision({
|
||||
documentId: "document-a",
|
||||
id: "revision-main-2",
|
||||
revisionNumber: 2,
|
||||
body: "# Main plan",
|
||||
createdAt: new Date("2026-03-20T01:00:00.000Z"),
|
||||
});
|
||||
|
||||
const plan = buildWorktreeMergePlan({
|
||||
companyId: "company-1",
|
||||
companyName: "Paperclip",
|
||||
issuePrefix: "PAP",
|
||||
previewIssueCounterStart: 10,
|
||||
scopes: ["issues", "comments"],
|
||||
sourceIssues: [sharedIssue],
|
||||
targetIssues: [sharedIssue],
|
||||
sourceComments: [],
|
||||
targetComments: [],
|
||||
sourceDocuments: [sourceDocument],
|
||||
targetDocuments: [targetDocument],
|
||||
sourceDocumentRevisions: [sourceRevisionOne, sourceRevisionTwo],
|
||||
targetDocumentRevisions: [targetRevisionOne, targetRevisionTwo],
|
||||
sourceAttachments: [],
|
||||
targetAttachments: [],
|
||||
targetAgents: [],
|
||||
targetProjects: [],
|
||||
targetProjectWorkspaces: [],
|
||||
targetGoals: [{ id: "goal-1" }] as any,
|
||||
});
|
||||
|
||||
expect(plan.counts.documentsToMerge).toBe(1);
|
||||
expect(plan.counts.documentRevisionsToInsert).toBe(1);
|
||||
expect(plan.documentPlans[0]).toMatchObject({
|
||||
action: "merge_existing",
|
||||
latestRevisionId: "revision-branch-2",
|
||||
latestRevisionNumber: 3,
|
||||
});
|
||||
const mergePlan = plan.documentPlans[0] as any;
|
||||
expect(mergePlan.revisionsToInsert).toHaveLength(1);
|
||||
expect(mergePlan.revisionsToInsert[0]).toMatchObject({
|
||||
source: { id: "revision-branch-2" },
|
||||
targetRevisionNumber: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("imports attachments while clearing missing comment and author references", () => {
|
||||
const sharedIssue = makeIssue({ id: "issue-a", identifier: "PAP-10" });
|
||||
const attachment = makeAttachment({
|
||||
issueId: "issue-a",
|
||||
issueCommentId: "comment-missing",
|
||||
createdByAgentId: "agent-missing",
|
||||
});
|
||||
|
||||
const plan = buildWorktreeMergePlan({
|
||||
companyId: "company-1",
|
||||
companyName: "Paperclip",
|
||||
issuePrefix: "PAP",
|
||||
previewIssueCounterStart: 10,
|
||||
scopes: ["issues"],
|
||||
sourceIssues: [sharedIssue],
|
||||
targetIssues: [sharedIssue],
|
||||
sourceComments: [],
|
||||
targetComments: [],
|
||||
sourceDocuments: [],
|
||||
targetDocuments: [],
|
||||
sourceDocumentRevisions: [],
|
||||
targetDocumentRevisions: [],
|
||||
sourceAttachments: [attachment],
|
||||
targetAttachments: [],
|
||||
targetAgents: [],
|
||||
targetProjects: [],
|
||||
targetProjectWorkspaces: [],
|
||||
targetGoals: [{ id: "goal-1" }] as any,
|
||||
});
|
||||
|
||||
expect(plan.counts.attachmentsToInsert).toBe(1);
|
||||
expect(plan.adjustments.clear_attachment_agent).toBe(1);
|
||||
expect(plan.attachmentPlans[0]).toMatchObject({
|
||||
action: "insert",
|
||||
targetIssueCommentId: null,
|
||||
targetCreatedByAgentId: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
copyGitHooksToWorktreeGitDir,
|
||||
copySeededSecretsKey,
|
||||
readSourceAttachmentBody,
|
||||
rebindWorkspaceCwd,
|
||||
resolveSourceConfigPath,
|
||||
resolveGitWorktreeAddArgs,
|
||||
@@ -195,6 +196,43 @@ describe("worktree helpers", () => {
|
||||
expect(formatShellExports(env)).toContain("export PAPERCLIP_INSTANCE_ID='feature-worktree-support'");
|
||||
});
|
||||
|
||||
it("falls back across storage roots before skipping a missing attachment object", async () => {
|
||||
const missingErr = Object.assign(new Error("missing"), { code: "ENOENT" });
|
||||
const expected = Buffer.from("image-bytes");
|
||||
await expect(
|
||||
readSourceAttachmentBody(
|
||||
[
|
||||
{
|
||||
getObject: vi.fn().mockRejectedValue(missingErr),
|
||||
},
|
||||
{
|
||||
getObject: vi.fn().mockResolvedValue(expected),
|
||||
},
|
||||
],
|
||||
"company-1",
|
||||
"company-1/issues/issue-1/missing.png",
|
||||
),
|
||||
).resolves.toEqual(expected);
|
||||
});
|
||||
|
||||
it("returns null when an attachment object is missing from every lookup storage", async () => {
|
||||
const missingErr = Object.assign(new Error("missing"), { code: "ENOENT" });
|
||||
await expect(
|
||||
readSourceAttachmentBody(
|
||||
[
|
||||
{
|
||||
getObject: vi.fn().mockRejectedValue(missingErr),
|
||||
},
|
||||
{
|
||||
getObject: vi.fn().mockRejectedValue(Object.assign(new Error("missing"), { status: 404 })),
|
||||
},
|
||||
],
|
||||
"company-1",
|
||||
"company-1/issues/issue-1/missing.png",
|
||||
),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("generates vivid worktree colors as hex", () => {
|
||||
expect(generateWorktreeColor()).toMatch(/^#[0-9a-f]{6}$/);
|
||||
});
|
||||
|
||||
709
cli/src/commands/worktree-merge-history-lib.ts
Normal file
709
cli/src/commands/worktree-merge-history-lib.ts
Normal file
@@ -0,0 +1,709 @@
|
||||
import {
|
||||
agents,
|
||||
assets,
|
||||
documentRevisions,
|
||||
goals,
|
||||
issueAttachments,
|
||||
issueComments,
|
||||
issueDocuments,
|
||||
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;
|
||||
type IssueDocumentLinkRow = typeof issueDocuments.$inferSelect;
|
||||
type DocumentRevisionTableRow = typeof documentRevisions.$inferSelect;
|
||||
type IssueAttachmentTableRow = typeof issueAttachments.$inferSelect;
|
||||
type AssetRow = typeof assets.$inferSelect;
|
||||
|
||||
export const WORKTREE_MERGE_SCOPES = ["issues", "comments"] as const;
|
||||
export type WorktreeMergeScope = (typeof WORKTREE_MERGE_SCOPES)[number];
|
||||
|
||||
export type ImportAdjustment =
|
||||
| "clear_assignee_agent"
|
||||
| "clear_project"
|
||||
| "clear_project_workspace"
|
||||
| "clear_goal"
|
||||
| "clear_author_agent"
|
||||
| "coerce_in_progress_to_todo"
|
||||
| "clear_document_agent"
|
||||
| "clear_document_revision_agent"
|
||||
| "clear_attachment_agent";
|
||||
|
||||
export type IssueMergeAction = "skip_existing" | "insert";
|
||||
export type CommentMergeAction = "skip_existing" | "skip_missing_parent" | "insert";
|
||||
|
||||
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;
|
||||
projectResolution: "preserved" | "cleared" | "mapped";
|
||||
mappedProjectName: 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 IssueDocumentRow = {
|
||||
id: IssueDocumentLinkRow["id"];
|
||||
companyId: IssueDocumentLinkRow["companyId"];
|
||||
issueId: IssueDocumentLinkRow["issueId"];
|
||||
documentId: IssueDocumentLinkRow["documentId"];
|
||||
key: IssueDocumentLinkRow["key"];
|
||||
linkCreatedAt: IssueDocumentLinkRow["createdAt"];
|
||||
linkUpdatedAt: IssueDocumentLinkRow["updatedAt"];
|
||||
title: string | null;
|
||||
format: string;
|
||||
latestBody: string;
|
||||
latestRevisionId: string | null;
|
||||
latestRevisionNumber: number;
|
||||
createdByAgentId: string | null;
|
||||
createdByUserId: string | null;
|
||||
updatedByAgentId: string | null;
|
||||
updatedByUserId: string | null;
|
||||
documentCreatedAt: Date;
|
||||
documentUpdatedAt: Date;
|
||||
};
|
||||
|
||||
export type DocumentRevisionRow = {
|
||||
id: DocumentRevisionTableRow["id"];
|
||||
companyId: DocumentRevisionTableRow["companyId"];
|
||||
documentId: DocumentRevisionTableRow["documentId"];
|
||||
revisionNumber: DocumentRevisionTableRow["revisionNumber"];
|
||||
body: DocumentRevisionTableRow["body"];
|
||||
changeSummary: DocumentRevisionTableRow["changeSummary"];
|
||||
createdByAgentId: string | null;
|
||||
createdByUserId: string | null;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
export type IssueAttachmentRow = {
|
||||
id: IssueAttachmentTableRow["id"];
|
||||
companyId: IssueAttachmentTableRow["companyId"];
|
||||
issueId: IssueAttachmentTableRow["issueId"];
|
||||
issueCommentId: IssueAttachmentTableRow["issueCommentId"];
|
||||
assetId: IssueAttachmentTableRow["assetId"];
|
||||
provider: AssetRow["provider"];
|
||||
objectKey: AssetRow["objectKey"];
|
||||
contentType: AssetRow["contentType"];
|
||||
byteSize: AssetRow["byteSize"];
|
||||
sha256: AssetRow["sha256"];
|
||||
originalFilename: AssetRow["originalFilename"];
|
||||
createdByAgentId: string | null;
|
||||
createdByUserId: string | null;
|
||||
assetCreatedAt: Date;
|
||||
assetUpdatedAt: Date;
|
||||
attachmentCreatedAt: Date;
|
||||
attachmentUpdatedAt: Date;
|
||||
};
|
||||
|
||||
export type PlannedDocumentRevisionInsert = {
|
||||
source: DocumentRevisionRow;
|
||||
targetRevisionNumber: number;
|
||||
targetCreatedByAgentId: string | null;
|
||||
adjustments: ImportAdjustment[];
|
||||
};
|
||||
|
||||
export type PlannedIssueDocumentInsert = {
|
||||
source: IssueDocumentRow;
|
||||
action: "insert";
|
||||
targetCreatedByAgentId: string | null;
|
||||
targetUpdatedByAgentId: string | null;
|
||||
latestRevisionId: string | null;
|
||||
latestRevisionNumber: number;
|
||||
revisionsToInsert: PlannedDocumentRevisionInsert[];
|
||||
adjustments: ImportAdjustment[];
|
||||
};
|
||||
|
||||
export type PlannedIssueDocumentMerge = {
|
||||
source: IssueDocumentRow;
|
||||
action: "merge_existing";
|
||||
targetCreatedByAgentId: string | null;
|
||||
targetUpdatedByAgentId: string | null;
|
||||
latestRevisionId: string | null;
|
||||
latestRevisionNumber: number;
|
||||
revisionsToInsert: PlannedDocumentRevisionInsert[];
|
||||
adjustments: ImportAdjustment[];
|
||||
};
|
||||
|
||||
export type PlannedIssueDocumentSkip = {
|
||||
source: IssueDocumentRow;
|
||||
action: "skip_existing" | "skip_missing_parent" | "skip_conflicting_key";
|
||||
};
|
||||
|
||||
export type PlannedAttachmentInsert = {
|
||||
source: IssueAttachmentRow;
|
||||
action: "insert";
|
||||
targetIssueCommentId: string | null;
|
||||
targetCreatedByAgentId: string | null;
|
||||
adjustments: ImportAdjustment[];
|
||||
};
|
||||
|
||||
export type PlannedAttachmentSkip = {
|
||||
source: IssueAttachmentRow;
|
||||
action: "skip_existing" | "skip_missing_parent";
|
||||
};
|
||||
|
||||
export type WorktreeMergePlan = {
|
||||
companyId: string;
|
||||
companyName: string;
|
||||
issuePrefix: string;
|
||||
previewIssueCounterStart: number;
|
||||
scopes: WorktreeMergeScope[];
|
||||
issuePlans: Array<PlannedIssueInsert | PlannedIssueSkip>;
|
||||
commentPlans: Array<PlannedCommentInsert | PlannedCommentSkip>;
|
||||
documentPlans: Array<PlannedIssueDocumentInsert | PlannedIssueDocumentMerge | PlannedIssueDocumentSkip>;
|
||||
attachmentPlans: Array<PlannedAttachmentInsert | PlannedAttachmentSkip>;
|
||||
counts: {
|
||||
issuesToInsert: number;
|
||||
issuesExisting: number;
|
||||
issueDrift: number;
|
||||
commentsToInsert: number;
|
||||
commentsExisting: number;
|
||||
commentsMissingParent: number;
|
||||
documentsToInsert: number;
|
||||
documentsToMerge: number;
|
||||
documentsExisting: number;
|
||||
documentsConflictingKey: number;
|
||||
documentsMissingParent: number;
|
||||
documentRevisionsToInsert: number;
|
||||
attachmentsToInsert: number;
|
||||
attachmentsExisting: number;
|
||||
attachmentsMissingParent: number;
|
||||
};
|
||||
adjustments: Record<ImportAdjustment, number>;
|
||||
};
|
||||
|
||||
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 groupBy<T>(rows: T[], keyFor: (row: T) => string): Map<string, T[]> {
|
||||
const out = new Map<string, T[]>();
|
||||
for (const row of rows) {
|
||||
const key = keyFor(row);
|
||||
const existing = out.get(key);
|
||||
if (existing) {
|
||||
existing.push(row);
|
||||
} else {
|
||||
out.set(key, [row]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function sameDate(left: Date, right: Date): boolean {
|
||||
return left.getTime() === right.getTime();
|
||||
}
|
||||
|
||||
function sortDocumentRows(rows: IssueDocumentRow[]): IssueDocumentRow[] {
|
||||
return [...rows].sort((left, right) => {
|
||||
const createdDelta = left.documentCreatedAt.getTime() - right.documentCreatedAt.getTime();
|
||||
if (createdDelta !== 0) return createdDelta;
|
||||
const linkDelta = left.linkCreatedAt.getTime() - right.linkCreatedAt.getTime();
|
||||
if (linkDelta !== 0) return linkDelta;
|
||||
return left.documentId.localeCompare(right.documentId);
|
||||
});
|
||||
}
|
||||
|
||||
function sortDocumentRevisions(rows: DocumentRevisionRow[]): DocumentRevisionRow[] {
|
||||
return [...rows].sort((left, right) => {
|
||||
const revisionDelta = left.revisionNumber - right.revisionNumber;
|
||||
if (revisionDelta !== 0) return revisionDelta;
|
||||
const createdDelta = left.createdAt.getTime() - right.createdAt.getTime();
|
||||
if (createdDelta !== 0) return createdDelta;
|
||||
return left.id.localeCompare(right.id);
|
||||
});
|
||||
}
|
||||
|
||||
function sortAttachments(rows: IssueAttachmentRow[]): IssueAttachmentRow[] {
|
||||
return [...rows].sort((left, right) => {
|
||||
const createdDelta = left.attachmentCreatedAt.getTime() - right.attachmentCreatedAt.getTime();
|
||||
if (createdDelta !== 0) return createdDelta;
|
||||
return left.id.localeCompare(right.id);
|
||||
});
|
||||
}
|
||||
|
||||
function sortIssuesForImport(sourceIssues: IssueRow[]): IssueRow[] {
|
||||
const byId = new Map(sourceIssues.map((issue) => [issue.id, issue]));
|
||||
const memoDepth = new Map<string, number>();
|
||||
|
||||
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[];
|
||||
sourceDocuments?: IssueDocumentRow[];
|
||||
targetDocuments?: IssueDocumentRow[];
|
||||
sourceDocumentRevisions?: DocumentRevisionRow[];
|
||||
targetDocumentRevisions?: DocumentRevisionRow[];
|
||||
sourceAttachments?: IssueAttachmentRow[];
|
||||
targetAttachments?: IssueAttachmentRow[];
|
||||
targetAgents: AgentRow[];
|
||||
targetProjects: ProjectRow[];
|
||||
targetProjectWorkspaces: ProjectWorkspaceRow[];
|
||||
targetGoals: GoalRow[];
|
||||
projectIdOverrides?: Record<string, string | null | undefined>;
|
||||
}): 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 targetProjectsById = new Map(input.targetProjects.map((project) => [project.id, project]));
|
||||
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,
|
||||
clear_document_agent: 0,
|
||||
clear_document_revision_agent: 0,
|
||||
clear_attachment_agent: 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;
|
||||
|
||||
let targetProjectId =
|
||||
issue.projectId && targetProjectIds.has(issue.projectId) ? issue.projectId : null;
|
||||
let projectResolution: PlannedIssueInsert["projectResolution"] = targetProjectId ? "preserved" : "cleared";
|
||||
let mappedProjectName: string | null = null;
|
||||
const overrideProjectId =
|
||||
issue.projectId && input.projectIdOverrides
|
||||
? input.projectIdOverrides[issue.projectId] ?? null
|
||||
: null;
|
||||
if (!targetProjectId && overrideProjectId && targetProjectIds.has(overrideProjectId)) {
|
||||
targetProjectId = overrideProjectId;
|
||||
projectResolution = "mapped";
|
||||
mappedProjectName = targetProjectsById.get(overrideProjectId)?.name ?? null;
|
||||
}
|
||||
if (issue.projectId && !targetProjectId) {
|
||||
adjustments.push("clear_project");
|
||||
incrementAdjustment(adjustmentCounts, "clear_project");
|
||||
}
|
||||
|
||||
const targetProjectWorkspaceId =
|
||||
targetProjectId
|
||||
&& targetProjectId === issue.projectId
|
||||
&& 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,
|
||||
projectResolution,
|
||||
mappedProjectName,
|
||||
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 sourceDocuments = input.sourceDocuments ?? [];
|
||||
const targetDocuments = input.targetDocuments ?? [];
|
||||
const sourceDocumentRevisions = input.sourceDocumentRevisions ?? [];
|
||||
const targetDocumentRevisions = input.targetDocumentRevisions ?? [];
|
||||
|
||||
const targetDocumentsById = new Map(targetDocuments.map((document) => [document.documentId, document]));
|
||||
const targetDocumentsByIssueKey = new Map(targetDocuments.map((document) => [`${document.issueId}:${document.key}`, document]));
|
||||
const sourceRevisionsByDocumentId = groupBy(sourceDocumentRevisions, (revision) => revision.documentId);
|
||||
const targetRevisionsByDocumentId = groupBy(targetDocumentRevisions, (revision) => revision.documentId);
|
||||
const commentIdsAvailableAfterImport = new Set<string>([
|
||||
...input.targetComments.map((comment) => comment.id),
|
||||
...commentPlans.filter((plan): plan is PlannedCommentInsert => plan.action === "insert").map((plan) => plan.source.id),
|
||||
]);
|
||||
|
||||
const documentPlans: Array<PlannedIssueDocumentInsert | PlannedIssueDocumentMerge | PlannedIssueDocumentSkip> = [];
|
||||
for (const document of sortDocumentRows(sourceDocuments)) {
|
||||
if (!issueIdsAvailableAfterImport.has(document.issueId)) {
|
||||
documentPlans.push({ source: document, action: "skip_missing_parent" });
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingDocument = targetDocumentsById.get(document.documentId);
|
||||
const conflictingIssueKeyDocument = targetDocumentsByIssueKey.get(`${document.issueId}:${document.key}`);
|
||||
if (!existingDocument && conflictingIssueKeyDocument && conflictingIssueKeyDocument.documentId !== document.documentId) {
|
||||
documentPlans.push({ source: document, action: "skip_conflicting_key" });
|
||||
continue;
|
||||
}
|
||||
|
||||
const adjustments: ImportAdjustment[] = [];
|
||||
const targetCreatedByAgentId =
|
||||
document.createdByAgentId && targetAgentIds.has(document.createdByAgentId) ? document.createdByAgentId : null;
|
||||
const targetUpdatedByAgentId =
|
||||
document.updatedByAgentId && targetAgentIds.has(document.updatedByAgentId) ? document.updatedByAgentId : null;
|
||||
if (
|
||||
(document.createdByAgentId && !targetCreatedByAgentId)
|
||||
|| (document.updatedByAgentId && !targetUpdatedByAgentId)
|
||||
) {
|
||||
adjustments.push("clear_document_agent");
|
||||
incrementAdjustment(adjustmentCounts, "clear_document_agent");
|
||||
}
|
||||
|
||||
const sourceRevisions = sortDocumentRevisions(sourceRevisionsByDocumentId.get(document.documentId) ?? []);
|
||||
const targetRevisions = sortDocumentRevisions(targetRevisionsByDocumentId.get(document.documentId) ?? []);
|
||||
const existingRevisionIds = new Set(targetRevisions.map((revision) => revision.id));
|
||||
const usedRevisionNumbers = new Set(targetRevisions.map((revision) => revision.revisionNumber));
|
||||
let nextRevisionNumber = targetRevisions.reduce(
|
||||
(maxValue, revision) => Math.max(maxValue, revision.revisionNumber),
|
||||
0,
|
||||
) + 1;
|
||||
|
||||
const targetRevisionNumberById = new Map<string, number>(
|
||||
targetRevisions.map((revision) => [revision.id, revision.revisionNumber]),
|
||||
);
|
||||
const revisionsToInsert: PlannedDocumentRevisionInsert[] = [];
|
||||
|
||||
for (const revision of sourceRevisions) {
|
||||
if (existingRevisionIds.has(revision.id)) continue;
|
||||
let targetRevisionNumber = revision.revisionNumber;
|
||||
if (usedRevisionNumbers.has(targetRevisionNumber)) {
|
||||
while (usedRevisionNumbers.has(nextRevisionNumber)) {
|
||||
nextRevisionNumber += 1;
|
||||
}
|
||||
targetRevisionNumber = nextRevisionNumber;
|
||||
nextRevisionNumber += 1;
|
||||
}
|
||||
usedRevisionNumbers.add(targetRevisionNumber);
|
||||
targetRevisionNumberById.set(revision.id, targetRevisionNumber);
|
||||
|
||||
const revisionAdjustments: ImportAdjustment[] = [];
|
||||
const targetCreatedByAgentId =
|
||||
revision.createdByAgentId && targetAgentIds.has(revision.createdByAgentId) ? revision.createdByAgentId : null;
|
||||
if (revision.createdByAgentId && !targetCreatedByAgentId) {
|
||||
revisionAdjustments.push("clear_document_revision_agent");
|
||||
incrementAdjustment(adjustmentCounts, "clear_document_revision_agent");
|
||||
}
|
||||
|
||||
revisionsToInsert.push({
|
||||
source: revision,
|
||||
targetRevisionNumber,
|
||||
targetCreatedByAgentId,
|
||||
adjustments: revisionAdjustments,
|
||||
});
|
||||
}
|
||||
|
||||
const latestRevisionId = document.latestRevisionId ?? existingDocument?.latestRevisionId ?? null;
|
||||
const latestRevisionNumber =
|
||||
(latestRevisionId ? targetRevisionNumberById.get(latestRevisionId) : undefined)
|
||||
?? document.latestRevisionNumber
|
||||
?? existingDocument?.latestRevisionNumber
|
||||
?? 0;
|
||||
|
||||
if (!existingDocument) {
|
||||
documentPlans.push({
|
||||
source: document,
|
||||
action: "insert",
|
||||
targetCreatedByAgentId,
|
||||
targetUpdatedByAgentId,
|
||||
latestRevisionId,
|
||||
latestRevisionNumber,
|
||||
revisionsToInsert,
|
||||
adjustments,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const documentAlreadyMatches =
|
||||
existingDocument.key === document.key
|
||||
&& existingDocument.title === document.title
|
||||
&& existingDocument.format === document.format
|
||||
&& existingDocument.latestBody === document.latestBody
|
||||
&& (existingDocument.latestRevisionId ?? null) === latestRevisionId
|
||||
&& existingDocument.latestRevisionNumber === latestRevisionNumber
|
||||
&& (existingDocument.updatedByAgentId ?? null) === targetUpdatedByAgentId
|
||||
&& (existingDocument.updatedByUserId ?? null) === (document.updatedByUserId ?? null)
|
||||
&& sameDate(existingDocument.documentUpdatedAt, document.documentUpdatedAt)
|
||||
&& sameDate(existingDocument.linkUpdatedAt, document.linkUpdatedAt)
|
||||
&& revisionsToInsert.length === 0;
|
||||
|
||||
if (documentAlreadyMatches) {
|
||||
documentPlans.push({ source: document, action: "skip_existing" });
|
||||
continue;
|
||||
}
|
||||
|
||||
documentPlans.push({
|
||||
source: document,
|
||||
action: "merge_existing",
|
||||
targetCreatedByAgentId,
|
||||
targetUpdatedByAgentId,
|
||||
latestRevisionId,
|
||||
latestRevisionNumber,
|
||||
revisionsToInsert,
|
||||
adjustments,
|
||||
});
|
||||
}
|
||||
|
||||
const sourceAttachments = input.sourceAttachments ?? [];
|
||||
const targetAttachmentIds = new Set((input.targetAttachments ?? []).map((attachment) => attachment.id));
|
||||
const attachmentPlans: Array<PlannedAttachmentInsert | PlannedAttachmentSkip> = [];
|
||||
for (const attachment of sortAttachments(sourceAttachments)) {
|
||||
if (targetAttachmentIds.has(attachment.id)) {
|
||||
attachmentPlans.push({ source: attachment, action: "skip_existing" });
|
||||
continue;
|
||||
}
|
||||
if (!issueIdsAvailableAfterImport.has(attachment.issueId)) {
|
||||
attachmentPlans.push({ source: attachment, action: "skip_missing_parent" });
|
||||
continue;
|
||||
}
|
||||
|
||||
const adjustments: ImportAdjustment[] = [];
|
||||
const targetCreatedByAgentId =
|
||||
attachment.createdByAgentId && targetAgentIds.has(attachment.createdByAgentId)
|
||||
? attachment.createdByAgentId
|
||||
: null;
|
||||
if (attachment.createdByAgentId && !targetCreatedByAgentId) {
|
||||
adjustments.push("clear_attachment_agent");
|
||||
incrementAdjustment(adjustmentCounts, "clear_attachment_agent");
|
||||
}
|
||||
|
||||
attachmentPlans.push({
|
||||
source: attachment,
|
||||
action: "insert",
|
||||
targetIssueCommentId:
|
||||
attachment.issueCommentId && commentIdsAvailableAfterImport.has(attachment.issueCommentId)
|
||||
? attachment.issueCommentId
|
||||
: null,
|
||||
targetCreatedByAgentId,
|
||||
adjustments,
|
||||
});
|
||||
}
|
||||
|
||||
const counts = {
|
||||
issuesToInsert: issuePlans.filter((plan) => plan.action === "insert").length,
|
||||
issuesExisting: issuePlans.filter((plan) => plan.action === "skip_existing").length,
|
||||
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,
|
||||
documentsToInsert: documentPlans.filter((plan) => plan.action === "insert").length,
|
||||
documentsToMerge: documentPlans.filter((plan) => plan.action === "merge_existing").length,
|
||||
documentsExisting: documentPlans.filter((plan) => plan.action === "skip_existing").length,
|
||||
documentsConflictingKey: documentPlans.filter((plan) => plan.action === "skip_conflicting_key").length,
|
||||
documentsMissingParent: documentPlans.filter((plan) => plan.action === "skip_missing_parent").length,
|
||||
documentRevisionsToInsert: documentPlans.reduce(
|
||||
(sum, plan) =>
|
||||
sum + (plan.action === "insert" || plan.action === "merge_existing" ? plan.revisionsToInsert.length : 0),
|
||||
0,
|
||||
),
|
||||
attachmentsToInsert: attachmentPlans.filter((plan) => plan.action === "insert").length,
|
||||
attachmentsExisting: attachmentPlans.filter((plan) => plan.action === "skip_existing").length,
|
||||
attachmentsMissingParent: attachmentPlans.filter((plan) => plan.action === "skip_missing_parent").length,
|
||||
};
|
||||
|
||||
return {
|
||||
companyId: input.companyId,
|
||||
companyName: input.companyName,
|
||||
issuePrefix: input.issuePrefix,
|
||||
previewIssueCounterStart: input.previewIssueCounterStart,
|
||||
scopes: input.scopes,
|
||||
issuePlans,
|
||||
commentPlans,
|
||||
documentPlans,
|
||||
attachmentPlans,
|
||||
counts,
|
||||
adjustments: adjustmentCounts,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -65,6 +65,7 @@ export type CreateIssueLabel = z.infer<typeof createIssueLabelSchema>;
|
||||
|
||||
export const updateIssueSchema = createIssueSchema.partial().extend({
|
||||
comment: z.string().min(1).optional(),
|
||||
reopen: z.boolean().optional(),
|
||||
hiddenAt: z.string().datetime().nullable().optional(),
|
||||
});
|
||||
|
||||
|
||||
143
server/src/__tests__/issue-comment-reopen-routes.test.ts
Normal file
143
server/src/__tests__/issue-comment-reopen-routes.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { issueRoutes } from "../routes/issues.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const mockIssueService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
update: vi.fn(),
|
||||
addComment: vi.fn(),
|
||||
findMentionedAgents: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
canUser: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockHeartbeatService = vi.hoisted(() => ({
|
||||
wakeup: vi.fn(async () => undefined),
|
||||
reportRunActivity: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
documentService: () => ({}),
|
||||
executionWorkspaceService: () => ({}),
|
||||
goalService: () => ({}),
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
issueApprovalService: () => ({}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => ({}),
|
||||
workProductService: () => ({}),
|
||||
}));
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes({} as any, {} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
function makeIssue(status: "todo" | "done") {
|
||||
return {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "company-1",
|
||||
status,
|
||||
assigneeAgentId: "22222222-2222-4222-8222-222222222222",
|
||||
assigneeUserId: null,
|
||||
createdByUserId: "local-board",
|
||||
identifier: "PAP-580",
|
||||
title: "Comment reopen default",
|
||||
};
|
||||
}
|
||||
|
||||
describe("issue comment reopen routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockIssueService.addComment.mockResolvedValue({
|
||||
id: "comment-1",
|
||||
issueId: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "company-1",
|
||||
body: "hello",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
authorAgentId: null,
|
||||
authorUserId: "local-board",
|
||||
});
|
||||
mockIssueService.findMentionedAgents.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("treats reopen=true as a no-op when the issue is already open", async () => {
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...makeIssue("todo"),
|
||||
...patch,
|
||||
}));
|
||||
|
||||
const res = await request(createApp())
|
||||
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
|
||||
.send({ comment: "hello", reopen: true, assigneeAgentId: "33333333-3333-4333-8333-333333333333" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockIssueService.update).toHaveBeenCalledWith("11111111-1111-4111-8111-111111111111", {
|
||||
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
|
||||
});
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "issue.updated",
|
||||
details: expect.not.objectContaining({ reopened: true }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reopens closed issues via the PATCH comment path", async () => {
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...makeIssue("done"),
|
||||
...patch,
|
||||
}));
|
||||
|
||||
const res = await request(createApp())
|
||||
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
|
||||
.send({ comment: "hello", reopen: true, assigneeAgentId: "33333333-3333-4333-8333-333333333333" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockIssueService.update).toHaveBeenCalledWith("11111111-1111-4111-8111-111111111111", {
|
||||
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
|
||||
status: "todo",
|
||||
});
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "issue.updated",
|
||||
details: expect.objectContaining({
|
||||
reopened: true,
|
||||
reopenedFrom: "done",
|
||||
status: "todo",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -824,10 +824,14 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return;
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const { comment: commentBody, hiddenAt: hiddenAtRaw, ...updateFields } = req.body;
|
||||
const isClosed = existing.status === "done" || existing.status === "cancelled";
|
||||
const { comment: commentBody, reopen: reopenRequested, hiddenAt: hiddenAtRaw, ...updateFields } = req.body;
|
||||
if (hiddenAtRaw !== undefined) {
|
||||
updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null;
|
||||
}
|
||||
if (commentBody && reopenRequested === true && isClosed && updateFields.status === undefined) {
|
||||
updateFields.status = "todo";
|
||||
}
|
||||
let issue;
|
||||
try {
|
||||
issue = await svc.update(id, updateFields);
|
||||
@@ -875,6 +879,13 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
}
|
||||
|
||||
const hasFieldChanges = Object.keys(previous).length > 0;
|
||||
const reopened =
|
||||
commentBody &&
|
||||
reopenRequested === true &&
|
||||
isClosed &&
|
||||
previous.status !== undefined &&
|
||||
issue.status === "todo";
|
||||
const reopenFromStatus = reopened ? existing.status : null;
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
actorType: actor.actorType,
|
||||
@@ -888,6 +899,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
...updateFields,
|
||||
identifier: issue.identifier,
|
||||
...(commentBody ? { source: "comment" } : {}),
|
||||
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus } : {}),
|
||||
_previous: hasFieldChanges ? previous : undefined,
|
||||
},
|
||||
});
|
||||
@@ -913,6 +925,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
bodySnippet: comment.body.slice(0, 120),
|
||||
identifier: issue.identifier,
|
||||
issueTitle: issue.title,
|
||||
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}),
|
||||
...(hasFieldChanges ? { updated: true } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -50,7 +50,6 @@ interface CommentThreadProps {
|
||||
mentions?: MentionOption[];
|
||||
}
|
||||
|
||||
const CLOSED_STATUSES = new Set(["done", "cancelled"]);
|
||||
const DRAFT_DEBOUNCE_MS = 800;
|
||||
|
||||
function loadDraft(draftKey: string): string {
|
||||
@@ -261,7 +260,6 @@ export function CommentThread({
|
||||
companyId,
|
||||
projectId,
|
||||
onAdd,
|
||||
issueStatus,
|
||||
agentMap,
|
||||
imageUploadHandler,
|
||||
onAttachImage,
|
||||
@@ -286,8 +284,6 @@ export function CommentThread({
|
||||
const location = useLocation();
|
||||
const hasScrolledRef = useRef(false);
|
||||
|
||||
const isClosed = issueStatus ? CLOSED_STATUSES.has(issueStatus) : false;
|
||||
|
||||
const timeline = useMemo<TimelineItem[]>(() => {
|
||||
const commentItems: TimelineItem[] = comments.map((comment) => ({
|
||||
kind: "comment",
|
||||
@@ -369,10 +365,10 @@ export function CommentThread({
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onAdd(trimmed, isClosed && reopen ? true : undefined, reassignment ?? undefined);
|
||||
await onAdd(trimmed, reopen ? true : undefined, reassignment ?? undefined);
|
||||
setBody("");
|
||||
if (draftKey) clearDraft(draftKey);
|
||||
setReopen(false);
|
||||
setReopen(true);
|
||||
setReassignTarget(effectiveSuggestedAssigneeValue);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
@@ -439,17 +435,15 @@ export function CommentThread({
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{isClosed && (
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reopen}
|
||||
onChange={(e) => setReopen(e.target.checked)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
Re-open
|
||||
</label>
|
||||
)}
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reopen}
|
||||
onChange={(e) => setReopen(e.target.checked)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
Re-open
|
||||
</label>
|
||||
{enableReassign && reassignOptions.length > 0 && (
|
||||
<InlineEntitySelector
|
||||
value={reassignTarget}
|
||||
|
||||
Reference in New Issue
Block a user