Files
fusion/packages/engine/src/__tests__/pr-comment-handler.test.ts
gsxdsm cb57093d03 refactor: domain folder layout (types, API, core, engine) (#2398)
## Summary

Wave 17 organizes Fusion into **domain folders** (stacks on #2397).

### Layout
- **core/types/** — board, task, agents, settings, merge, workflow,
mesh, …
- **core/src/** — agents, ai, async-stores, workflows, tasks, config,
db, …
- **dashboard/app/api/** — client, tasks, agents, git, missions,
planning, …
- **engine/src/** — agents, auth, execution, merge, missions, overseer,
worktree, …

Root keepers retained for large entrypoints (`store.ts`, `executor.ts`,
`merger.ts`, …).

Public barrels (`@fusion/core`, `@fusion/engine`, `app/api.ts` → legacy)
stay stable.

## Test plan
- [x] `@fusion/core` typecheck
- [x] `@fusion/engine` typecheck (pre-existing playwright-core noise
only)
- [ ] CI merge gate

**Stack:** #2394 → #2397 → **this PR**
2026-08-03 00:20:53 -07:00

503 lines
21 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { PrCommentHandler } from "../merge/pr-comment-handler.js";
import { RENAMED_VOCAB, lifecycleIr } from "./_workflow-vocabulary-fixture.js";
import type { TaskStore, Task } from "@fusion/core";
const mockStore = {
addTaskComment: vi.fn<(id: string, text: string, author?: string) => Promise<Task>>(),
getTask: vi.fn<(id: string) => Promise<Task>>().mockResolvedValue({ id: "FN-001", review: undefined } as Task),
updateTask: vi.fn<(id: string, updates: Partial<Task>) => Promise<Task>>().mockResolvedValue({ id: "FN-001" } as Task),
createTask: vi.fn<(input: Parameters<TaskStore["createTask"]>[0]) => Promise<Task>>().mockResolvedValue({ id: "FN-123" } as Task),
listTasks: vi.fn<() => Promise<Task[]>>().mockResolvedValue([]),
logEntry: vi.fn<(id: string, action: string, outcome?: string) => Promise<Task>>().mockResolvedValue({ id: "FN-001" } as Task),
recordRunAuditEvent: vi.fn<(event: unknown) => Promise<void>>().mockResolvedValue(),
moveTask: vi.fn<(id: string, column: Task["column"]) => Promise<Task>>().mockResolvedValue({ id: "FN-001", column: "in-progress" } as Task),
/*
FNXC:WorkflowResolvedColumns 2026-07-30-20:10:
The workflow readers `resolveTerminalColumnsFor` needs. Default to "no workflow" so every existing case
keeps the legacy-pair fallback unchanged; the renamed case overrides them per test.
*/
getTaskWorkflowSelection: vi.fn<(id: string) => unknown>().mockReturnValue(undefined),
getWorkflowDefinition: vi.fn<(id: string) => Promise<unknown>>().mockResolvedValue(undefined),
} as unknown as TaskStore;
describe("PrCommentHandler", () => {
let handler: PrCommentHandler;
beforeEach(() => {
vi.clearAllMocks();
(mockStore.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({ id: "FN-001", review: undefined } as Task);
(mockStore.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(mockStore.createTask as ReturnType<typeof vi.fn>).mockResolvedValue({ id: "FN-123" } as Task);
(mockStore.logEntry as ReturnType<typeof vi.fn>).mockResolvedValue({ id: "FN-001" } as Task);
handler = new PrCommentHandler(mockStore);
});
const mockPrInfo = {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open" as const,
title: "Test PR",
headBranch: "fusion/fn-001",
baseBranch: "main",
commentCount: 0,
};
describe("isNonActionable", () => {
it.each([
"LGTM",
"lgtm",
"Looks good",
"Looks good to me",
"Thanks",
"Thank you",
"Nice",
"Great work",
"👍",
"✅",
])("filters out non-actionable comment: %s", async (body) => {
await handler.handleNewComments("FN-001", mockPrInfo, [
{
id: 1,
body,
user: { login: "reviewer" },
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
html_url: "https://github.com/owner/repo/pull/42#issuecomment-1",
},
]);
expect(mockStore.addTaskComment).not.toHaveBeenCalled();
});
});
describe("isActionable", () => {
it.each([
{ body: "Please fix the indentation", keyword: "fix" },
{ body: "Should change the variable name", keyword: "change" },
{ body: "Update the documentation", keyword: "update" },
{ body: "Remove the unused import", keyword: "remove" },
{ body: "Add error handling", keyword: "add" },
{ body: "You should refactor this", keyword: "should" },
{ body: "Needs to handle edge cases", keyword: "needs to" },
{ body: "Consider using a different approach", keyword: "consider" },
{ body: "I suggest renaming this", keyword: "suggest" },
{ body: "Recommend adding tests", keyword: "recommend" },
])("creates comment for actionable feedback containing '$keyword': $body", async ({ body }) => {
await handler.handleNewComments("FN-001", mockPrInfo, [
{
id: 1,
body,
user: { login: "reviewer" },
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
html_url: "https://github.com/owner/repo/pull/42#issuecomment-1",
},
]);
expect(mockStore.addTaskComment).toHaveBeenCalled();
});
});
describe("code suggestions", () => {
it("creates comment for comments with code blocks", async () => {
await handler.handleNewComments("FN-001", mockPrInfo, [
{
id: 1,
body: "```typescript\nconst x = 1;\n```",
user: { login: "reviewer" },
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
html_url: "https://github.com/owner/repo/pull/42#issuecomment-1",
},
]);
expect(mockStore.addTaskComment).toHaveBeenCalled();
});
it("creates comment for inline code suggestions", async () => {
await handler.handleNewComments("FN-001", mockPrInfo, [
{
id: 1,
body: "Use `const` instead of `let`",
user: { login: "reviewer" },
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
html_url: "https://github.com/owner/repo/pull/42#issuecomment-1",
},
]);
expect(mockStore.addTaskComment).toHaveBeenCalled();
});
});
describe("comment content", () => {
it("includes PR info and comment details", async () => {
await handler.handleNewComments("FN-001", mockPrInfo, [
{
id: 1,
body: "Please fix the bug",
user: { login: "reviewer" },
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
html_url: "https://github.com/owner/repo/pull/42#issuecomment-1",
},
]);
const call = (mockStore.addTaskComment as ReturnType<typeof vi.fn>).mock.calls[0];
const text = call[1] as string;
expect(text).toContain("PR Review Feedback");
expect(text).toContain("@reviewer");
expect(text).toContain("#42");
expect(text).toContain("open");
expect(text).toContain("Please fix the bug");
expect(text).toContain("View on GitHub");
});
it("truncates long comments", async () => {
const longBody = "Please fix this issue: " + "a".repeat(1000);
await handler.handleNewComments("FN-001", mockPrInfo, [
{
id: 1,
body: longBody,
user: { login: "reviewer" },
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
html_url: "https://github.com/owner/repo/pull/42#issuecomment-1",
},
]);
const call = (mockStore.addTaskComment as ReturnType<typeof vi.fn>).mock.calls[0];
const text = call[1] as string;
expect(text.length).toBeLessThan(longBody.length);
expect(text).toContain("...");
});
it("marks as agent-authored", async () => {
await handler.handleNewComments("FN-001", mockPrInfo, [
{
id: 1,
body: "Please fix this",
user: { login: "reviewer" },
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
html_url: "https://github.com/owner/repo/pull/42#issuecomment-1",
},
]);
expect(mockStore.addTaskComment).toHaveBeenCalledWith(
"FN-001",
expect.any(String),
"agent"
);
});
it("calls out follow-up context when feedback arrives after PR is merged", async () => {
await handler.handleNewComments("FN-001", { ...mockPrInfo, status: "merged" }, [
{
id: 1,
body: "Please add one more regression test",
user: { login: "reviewer" },
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
html_url: "https://github.com/owner/repo/pull/42#issuecomment-1",
},
]);
const text = (mockStore.addTaskComment as ReturnType<typeof vi.fn>).mock.calls[0][1] as string;
expect(text).toContain("This PR is already merged");
expect(text).toContain("follow-up work");
});
});
describe("handleChangesRequested", () => {
it("persists review item feedback when changes are requested", async () => {
(mockStore.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({ id: "FN-001", column: "in-review", review: undefined } as Task);
await handler.handleChangesRequested("FN-001", mockPrInfo, "reviewer", "Please add tests");
expect(mockStore.updateTask).toHaveBeenCalledWith(
"FN-001",
expect.objectContaining({
review: expect.objectContaining({
mode: "pull-request",
items: expect.arrayContaining([
expect.objectContaining({ source: "github-pr", status: "queued" }),
]),
}),
reviewState: expect.objectContaining({
source: "pull-request",
items: expect.arrayContaining([
expect.objectContaining({ source: "github-pr", body: "Please add tests" }),
]),
}),
}),
);
expect(mockStore.moveTask).toHaveBeenCalledWith("FN-001", "in-progress");
});
/*
FNXC:WorkflowResolvedColumns 2026-07-30-18:30 (engine):
DIFFERENTIAL over the column vocabulary. The case above asserts the LEGACY ids, which is what both
literals on this path compared against — it passed before this conversion and would pass for a broken
one, because the fake supplies no workflow and the resolver degrades to the built-in board.
The failure is silent and human-facing: on a renamed review lane the gate returned early, so a
reviewer's "changes requested" produced NO steering comment and the card never went back to work.
The feedback simply vanished behind a log line.
Both literals are covered, deliberately. The destination (`moveTask(taskId, "in-progress")`) is a
call argument the census cannot see; converting only the gate would admit the review and then attempt
a move into a lane the board may not declare.
REVERT CHECK, measured (each independently):
- gate restored to `task.column !== "in-review"` -> fails; updateTask/moveTask never called.
- destination restored to the literal -> fails; moveTask called with "in-progress"
instead of the renamed wip lane.
*/
it("handles a changes-requested review on a RENAMED board and requeues to its OWN wip lane", async () => {
const ir = lifecycleIr(RENAMED_VOCAB, "pr-comment-lifecycle", { mergeOrchestration: true });
(mockStore.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(
{ id: "FN-001", column: RENAMED_VOCAB.review, review: undefined } as Task,
);
Object.assign(mockStore, {
getTaskWorkflowSelectionAsync: vi.fn(async () => ({ workflowId: "pr-comment-lifecycle", stepIds: [] })),
getTaskWorkflowSelection: vi.fn(() => ({ workflowId: "pr-comment-lifecycle", stepIds: [] })),
getWorkflowDefinition: vi.fn(async (id: string) => (id === "pr-comment-lifecycle" ? { ir } : undefined)),
});
await handler.handleChangesRequested("FN-001", mockPrInfo, "reviewer", "Please add tests");
// The reviewer's feedback is recorded rather than dropped...
expect(mockStore.updateTask).toHaveBeenCalledWith(
"FN-001",
expect.objectContaining({
reviewState: expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({ source: "github-pr", body: "Please add tests" }),
]),
}),
}),
);
// ...and the card returns to THIS board's wip lane, not the legacy id.
expect(mockStore.moveTask).toHaveBeenCalledWith("FN-001", RENAMED_VOCAB.wip);
expect(mockStore.moveTask).not.toHaveBeenCalledWith("FN-001", "in-progress");
});
/*
FNXC:WorkflowResolvedColumns 2026-07-30-18:55 (#2807 review — greptile P1):
PINS THE NARROWING. The first version admitted any column carrying mergeOrchestration OR
mergeBlocker OR humanReview. On a workflow that puts those on SEPARATE columns that is a widening,
not a conversion: a card parked in a human-approval lane gets bounced into wip by a PR review that
has nothing to do with it.
This board declares a human-review/merge-blocker column that is NOT the merge-orchestration lane —
the exact shape the union got wrong, and one the default board cannot express.
REVERT CHECK, measured: restoring the three-flag union makes this fail — the handler admits the
review and moves the card out of a lane it was never responsible for.
*/
it("does not admit a PR review from a human-approval lane that is not the merge stage", async () => {
const base = lifecycleIr(RENAMED_VOCAB, "pr-comment-lifecycle", { mergeOrchestration: true });
const ir = {
...base,
columns: [
...base.columns,
{ id: "approval", name: "Approval", traits: [{ trait: "human-review" as const }, { trait: "merge-blocker" as const }] },
],
};
(mockStore.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(
{ id: "FN-001", column: "approval", review: undefined } as Task,
);
Object.assign(mockStore, {
getTaskWorkflowSelectionAsync: vi.fn(async () => ({ workflowId: "pr-comment-lifecycle", stepIds: [] })),
getTaskWorkflowSelection: vi.fn(() => ({ workflowId: "pr-comment-lifecycle", stepIds: [] })),
getWorkflowDefinition: vi.fn(async (id: string) => (id === "pr-comment-lifecycle" ? { ir } : undefined)),
});
await handler.handleChangesRequested("FN-001", mockPrInfo, "reviewer", "Please add tests");
expect(mockStore.moveTask).not.toHaveBeenCalled();
expect(mockStore.updateTask).not.toHaveBeenCalled();
});
it("still ignores a review on a RENAMED board when the card is not in a review lane", async () => {
/*
Non-vacuous companion: without it, a gate admitting every column would satisfy the case above.
Same renamed board, same review — only the card's lane changes.
*/
const ir = lifecycleIr(RENAMED_VOCAB, "pr-comment-lifecycle", { mergeOrchestration: true });
(mockStore.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(
{ id: "FN-001", column: RENAMED_VOCAB.hold, review: undefined } as Task,
);
Object.assign(mockStore, {
getTaskWorkflowSelectionAsync: vi.fn(async () => ({ workflowId: "pr-comment-lifecycle", stepIds: [] })),
getTaskWorkflowSelection: vi.fn(() => ({ workflowId: "pr-comment-lifecycle", stepIds: [] })),
getWorkflowDefinition: vi.fn(async (id: string) => (id === "pr-comment-lifecycle" ? { ir } : undefined)),
});
await handler.handleChangesRequested("FN-001", mockPrInfo, "reviewer", "Please add tests");
expect(mockStore.moveTask).not.toHaveBeenCalled();
});
});
describe("createFollowUpTask", () => {
it("creates follow-up task for unaddressed feedback", async () => {
await handler.createFollowUpTask("FN-001", mockPrInfo, [
{
id: 1,
body: "This needs fixing",
user: { login: "reviewer" },
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
html_url: "https://github.com/owner/repo/pull/42#issuecomment-1",
},
]);
/*
FNXC:WorkflowLifecycleColumns 2026-07-29-20:40 (U11):
This asserted `column: "triage"` and so PINNED the defect. `createTaskImpl`
resolves the column as `input.column || resolvedEntryColumn || fallbackIntake
|| "triage"`, so an explicit column OVERRIDES the workflow's intake — and
after #2515 `triage` is not a column the default lineage declares, so the
follow-up was created straight into the stranded state.
Now asserts the invariant instead of the id: the caller passes NO column, so
whatever intake the task's workflow declares is what wins.
*/
expect(mockStore.createTask).toHaveBeenCalledWith({
title: "Follow-up: Address PR #42 feedback",
description: expect.stringContaining("FN-001"),
dependencies: ["FN-001"],
source: {
sourceType: "api",
sourceParentTaskId: "FN-001",
sourceMetadata: {
prNumber: 42,
prUrl: "https://github.com/owner/repo/pull/42",
},
},
});
const [createArg] = (mockStore.createTask as unknown as { mock: { calls: [Record<string, unknown>][] } }).mock.calls[0];
expect(Object.hasOwn(createArg, "column")).toBe(false);
});
it("does nothing when no unaddressed comments", async () => {
await handler.createFollowUpTask("FN-001", mockPrInfo, []);
expect(mockStore.createTask).not.toHaveBeenCalled();
});
it("summarizes multiple comments", async () => {
await handler.createFollowUpTask("FN-001", mockPrInfo, [
{
id: 1,
body: "First issue to fix",
user: { login: "reviewer1" },
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
html_url: "https://github.com/owner/repo/pull/42#issuecomment-1",
},
{
id: 2,
body: "Second issue",
user: { login: "reviewer2" },
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
html_url: "https://github.com/owner/repo/pull/42#issuecomment-2",
},
]);
const call = (mockStore.createTask as ReturnType<typeof vi.fn>).mock.calls[0];
const description = call[0].description as string;
expect(description).toContain("@reviewer1");
expect(description).toContain("@reviewer2");
expect(description).toContain("First issue");
expect(description).toContain("Second issue");
});
/*
FNXC:WorkflowResolvedColumns 2026-07-30-20:10 (#2770 review — the SECOND copy, which I converted and did not cover):
`PrCommentHandler` carries the same dedup as `eval-followups.ts` and I converted both in one commit,
then wrote a regression case for only one of them. That is the Surface Enumeration rule I had just
accepted on #2766 and repeated in the very next PR, which is why this case exists rather than an
argument that the other file's coverage generalises.
The reuse case below uses `todo` — open under both the old literal and the resolved answer, so it
cannot tell them apart. This one puts the prior follow-up in a RENAMED complete lane, where they
disagree: keyed on {done, archived} the finished card reads as open and blocks the new one forever.
REVERT CHECK, measured: restoring the literal pair here fails this with `createTask` never called.
*/
it("files a fresh PR follow-up when the prior one finished in a RENAMED complete lane", async () => {
(mockStore.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([{
id: "FN-finished",
column: "shipped",
description: "finished pr follow-up",
sourceParentTaskId: "FN-001",
sourceMetadata: { prNumber: 42 },
}]);
(mockStore.getTaskWorkflowSelection as ReturnType<typeof vi.fn>).mockReturnValue({ workflowId: "wf-renamed", stepIds: [] });
(mockStore.getWorkflowDefinition as ReturnType<typeof vi.fn>).mockResolvedValue({
ir: {
version: "v2",
id: "wf-renamed",
name: "renamed",
nodes: [],
edges: [],
columns: [
{ id: "backlog", name: "Backlog", traits: [{ trait: "hold" }] },
{ id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] },
],
},
});
await handler.createFollowUpTask("FN-001", mockPrInfo, [
{
id: 1,
body: "This needs fixing",
user: { login: "reviewer" },
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
html_url: "https://github.com/owner/repo/pull/42#issuecomment-1",
},
]);
expect(mockStore.createTask).toHaveBeenCalled();
});
it("reuses an existing PR follow-up when the same parent/prNumber is still open", async () => {
(mockStore.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([{
id: "FN-existing",
column: "todo",
description: "existing pr follow-up",
sourceParentTaskId: "FN-001",
sourceMetadata: { prNumber: 42 },
}]);
(mockStore.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({ id: "FN-existing", log: [] } as unknown as Task);
await handler.createFollowUpTask("FN-001", mockPrInfo, [
{
id: 1,
body: "This needs fixing",
user: { login: "reviewer" },
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
html_url: "https://github.com/owner/repo/pull/42#issuecomment-1",
},
]);
// FNXC:PullRequestReview 2026-07-26-00:00: the "[verification recurrence]" logEntry
// assertion here belonged to the deleted shared follow-up dedup engine (which wrote a
// rate-limited recurrence note onto the reused card). The inlined dedup only has to
// prove no duplicate card is filed for the same parent/prNumber, which is asserted above.
expect(mockStore.createTask).not.toHaveBeenCalled();
});
});
});