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 {
|
import {
|
||||||
copyGitHooksToWorktreeGitDir,
|
copyGitHooksToWorktreeGitDir,
|
||||||
copySeededSecretsKey,
|
copySeededSecretsKey,
|
||||||
|
readSourceAttachmentBody,
|
||||||
rebindWorkspaceCwd,
|
rebindWorkspaceCwd,
|
||||||
resolveSourceConfigPath,
|
resolveSourceConfigPath,
|
||||||
resolveGitWorktreeAddArgs,
|
resolveGitWorktreeAddArgs,
|
||||||
@@ -195,6 +196,43 @@ describe("worktree helpers", () => {
|
|||||||
expect(formatShellExports(env)).toContain("export PAPERCLIP_INSTANCE_ID='feature-worktree-support'");
|
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", () => {
|
it("generates vivid worktree colors as hex", () => {
|
||||||
expect(generateWorktreeColor()).toMatch(/^#[0-9a-f]{6}$/);
|
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({
|
export const updateIssueSchema = createIssueSchema.partial().extend({
|
||||||
comment: z.string().min(1).optional(),
|
comment: z.string().min(1).optional(),
|
||||||
|
reopen: z.boolean().optional(),
|
||||||
hiddenAt: z.string().datetime().nullable().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;
|
if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return;
|
||||||
|
|
||||||
const actor = getActorInfo(req);
|
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) {
|
if (hiddenAtRaw !== undefined) {
|
||||||
updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null;
|
updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null;
|
||||||
}
|
}
|
||||||
|
if (commentBody && reopenRequested === true && isClosed && updateFields.status === undefined) {
|
||||||
|
updateFields.status = "todo";
|
||||||
|
}
|
||||||
let issue;
|
let issue;
|
||||||
try {
|
try {
|
||||||
issue = await svc.update(id, updateFields);
|
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 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, {
|
await logActivity(db, {
|
||||||
companyId: issue.companyId,
|
companyId: issue.companyId,
|
||||||
actorType: actor.actorType,
|
actorType: actor.actorType,
|
||||||
@@ -888,6 +899,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||||||
...updateFields,
|
...updateFields,
|
||||||
identifier: issue.identifier,
|
identifier: issue.identifier,
|
||||||
...(commentBody ? { source: "comment" } : {}),
|
...(commentBody ? { source: "comment" } : {}),
|
||||||
|
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus } : {}),
|
||||||
_previous: hasFieldChanges ? previous : undefined,
|
_previous: hasFieldChanges ? previous : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -913,6 +925,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||||||
bodySnippet: comment.body.slice(0, 120),
|
bodySnippet: comment.body.slice(0, 120),
|
||||||
identifier: issue.identifier,
|
identifier: issue.identifier,
|
||||||
issueTitle: issue.title,
|
issueTitle: issue.title,
|
||||||
|
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}),
|
||||||
...(hasFieldChanges ? { updated: true } : {}),
|
...(hasFieldChanges ? { updated: true } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ interface CommentThreadProps {
|
|||||||
mentions?: MentionOption[];
|
mentions?: MentionOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const CLOSED_STATUSES = new Set(["done", "cancelled"]);
|
|
||||||
const DRAFT_DEBOUNCE_MS = 800;
|
const DRAFT_DEBOUNCE_MS = 800;
|
||||||
|
|
||||||
function loadDraft(draftKey: string): string {
|
function loadDraft(draftKey: string): string {
|
||||||
@@ -261,7 +260,6 @@ export function CommentThread({
|
|||||||
companyId,
|
companyId,
|
||||||
projectId,
|
projectId,
|
||||||
onAdd,
|
onAdd,
|
||||||
issueStatus,
|
|
||||||
agentMap,
|
agentMap,
|
||||||
imageUploadHandler,
|
imageUploadHandler,
|
||||||
onAttachImage,
|
onAttachImage,
|
||||||
@@ -286,8 +284,6 @@ export function CommentThread({
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const hasScrolledRef = useRef(false);
|
const hasScrolledRef = useRef(false);
|
||||||
|
|
||||||
const isClosed = issueStatus ? CLOSED_STATUSES.has(issueStatus) : false;
|
|
||||||
|
|
||||||
const timeline = useMemo<TimelineItem[]>(() => {
|
const timeline = useMemo<TimelineItem[]>(() => {
|
||||||
const commentItems: TimelineItem[] = comments.map((comment) => ({
|
const commentItems: TimelineItem[] = comments.map((comment) => ({
|
||||||
kind: "comment",
|
kind: "comment",
|
||||||
@@ -369,10 +365,10 @@ export function CommentThread({
|
|||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await onAdd(trimmed, isClosed && reopen ? true : undefined, reassignment ?? undefined);
|
await onAdd(trimmed, reopen ? true : undefined, reassignment ?? undefined);
|
||||||
setBody("");
|
setBody("");
|
||||||
if (draftKey) clearDraft(draftKey);
|
if (draftKey) clearDraft(draftKey);
|
||||||
setReopen(false);
|
setReopen(true);
|
||||||
setReassignTarget(effectiveSuggestedAssigneeValue);
|
setReassignTarget(effectiveSuggestedAssigneeValue);
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
@@ -439,17 +435,15 @@ export function CommentThread({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isClosed && (
|
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
|
||||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
|
<input
|
||||||
<input
|
type="checkbox"
|
||||||
type="checkbox"
|
checked={reopen}
|
||||||
checked={reopen}
|
onChange={(e) => setReopen(e.target.checked)}
|
||||||
onChange={(e) => setReopen(e.target.checked)}
|
className="rounded border-border"
|
||||||
className="rounded border-border"
|
/>
|
||||||
/>
|
Re-open
|
||||||
Re-open
|
</label>
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
{enableReassign && reassignOptions.length > 0 && (
|
{enableReassign && reassignOptions.length > 0 && (
|
||||||
<InlineEntitySelector
|
<InlineEntitySelector
|
||||||
value={reassignTarget}
|
value={reassignTarget}
|
||||||
|
|||||||
Reference in New Issue
Block a user