Files
fusion/packages/dashboard/src/__tests__/gitlab-tracking-comments.test.ts
gsxdsm e0010f241e fleet: github-tracking-comments.ts 9 → 3 — and the sync-filter class is now in a FOURTH file (~25 sites on one decision) (#2715)
Claiming the **github-tracking pair**. This converts the comments half;
the reconciler half is the flagged class, with the evidence below.

## Census before/after

| | before | after |
|---|---:|---:|
| `github-tracking-comments.ts` | **9** | **3** |

Baseline re-recorded; `--strict` exits 0.

## Converted: 6

The `event.to === "in-progress"` / `=== "done"` sites in
`handleTaskMoved`, to the **wip** and **complete** roles. One
resolution, placed **immediately after the tracking-enabled gate** — so
a move on an **untracked** task pays nothing, which is most moves in
most projects.

## Deliberately not converted: 2 — the ordering is the reason

```ts
if (event.to !== "in-progress" && event.to !== "done") return;   // line 232
```

This runs **before** the tracked-task gate. Converting it moves the
resolution ahead of that gate and makes **every task move in the
project** resolve a workflow just to decide the task has no GitHub
issue. That's a real cost on the hottest event in the system, to convert
a guard whose only job is a cheap filter. Recorded at the site.

## The remaining 1

**Line 165** — `transition === "done"` inside `formatTrackingComment`, a
**pure formatter** with no store and no task. Same shape as
`project-engine.ts:2555`. Threading a resolution into a formatter to
pick a string is the wrong trade.

## `github-tracking-reconciler.ts` (9) — not claimed here, and here is
why

All nine are:

```ts
.filter((task) => task.column === "done" || task.column === "archived")
```

Synchronous filters over task **lists**, where per-task resolution is N
awaits inside a sync predicate.

**This is the fourth file with that exact shape** — after `store.ts`
(#2709), and the dependency pairs in `TaskDetailModal` (#2696) and
`register-task-workflow-routes` (#2700). By my count **roughly 25 sites
across four files now wait on one decision**:

1. **Prefetch lifecycle columns alongside the task list** and pass a
resolved map into these predicates — keeps them synchronous, one
resolution per *distinct workflow* rather than per task. This is the one
I'd argue for.
2. Make the predicates async and accept per-task resolution.

It's a design change, and four workers guessing separately is exactly
how two halves of one rule drift apart. One decision covers all of them.

## Verification

`pnpm test:gate` **GREEN** (158 + 10 + 487 + 71) ·
`github-tracking-comments` + `github-issue-comment` **81/81** · `pnpm
lint` clean · dashboard `tsc` clean · `--strict` exits 0.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 06:39:12 -07:00

172 lines
10 KiB
TypeScript

import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { GitLabTrackingCommentService, formatGitLabTrackingComment } from "../gitlab-tracking-comments.js";
function jsonResponse(body: unknown, status = 200) { return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); }
function store() { const emitter = new EventEmitter(); return Object.assign(emitter, { getSettings: vi.fn().mockResolvedValue({ gitlabAuthToken: "token", gitlabInstanceUrl: "https://gitlab.example.com" }), getGlobalSettingsStore: () => ({ getSettings: vi.fn().mockResolvedValue({}) }), logEntry: vi.fn() }); }
function task(kind: "project_issue" | "group_issue" | "merge_request" = "merge_request"): any { return { id: "FN-1", title: "Ship", description: "Body", gitlabTracking: { item: { kind, instanceUrl: "https://gitlab.example.com", host: "gitlab.example.com", url: kind === "merge_request" ? "https://gitlab.example.com/g/p/-/merge_requests/5" : "https://gitlab.example.com/g/p/-/issues/5", projectPath: "g/p", iid: 5, title: "Ship", state: "opened", linkedAt: "now" } } }; }
describe("GitLabTrackingCommentService", () => {
beforeEach(() => vi.unstubAllGlobals());
it("formats in-progress and done status comments", () => {
expect(formatGitLabTrackingComment(task(), "in-progress")).toContain("🚧 In progress");
expect(formatGitLabTrackingComment({ ...task(), branch: "fusion/FN-1", mergeDetails: { commitSha: "abcdef123", mergedAt: "today" } }, "done", "https://gitlab.example.com/g/p/-/merge_requests/5")).toContain("GitLab: https://gitlab.example.com/g/p/-/merge_requests/5");
});
it("posts comments to merge requests and group-backed project issues", async () => {
const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ id: 1 })); vi.stubGlobal("fetch", fetchImpl);
const s = store(); new GitLabTrackingCommentService(s as any).start();
s.emit("task:moved", { task: task("merge_request"), from: "todo", to: "done" });
s.emit("task:moved", { task: task("group_issue"), from: "todo", to: "in-progress" });
await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(2));
expect(fetchImpl.mock.calls[0][0]).toBe("https://gitlab.example.com/api/v4/projects/g%2Fp/merge_requests/5/notes");
expect(fetchImpl.mock.calls[1][0]).toBe("https://gitlab.example.com/api/v4/projects/g%2Fp/issues/5/notes");
expect(s.logEntry).toHaveBeenCalledWith("FN-1", "Posted GitLab tracking comment", "g/p!5 (done)");
});
/*
FNXC:WorkflowResolvedColumns 2026-07-31-09:35 (fleet phase — the GitLab half, and the PAIR):
`handleTaskMoved` decided which moves warrant a comment by comparing `event.to` to the literals
`in-progress` and `done`. On a renamed board neither matched, so GitLab tracking silently posted NO
comments — the linked issue or MR just stopped being updated, with no error and no log line.
The store fake gains a workflow reader ONLY for these cases; every case above omits it and keeps
asserting the legacy-id fallback, which is exactly why none of them could have caught this. (See
docs/solutions/test-failures/optional-flags-seam-hides-unconverted-column-guards.md.)
REVERT CHECK, measured: restoring the literal lane test makes the renamed case fail with 0 fetch calls.
*/
const RENAMED_IR = {
version: "v2",
id: "custom:renamed",
name: "Renamed",
nodes: [],
edges: [],
columns: [
{ id: "backlog", name: "Backlog", traits: [{ trait: "intake" }, { trait: "hold" }] },
{ id: "building", name: "Building", traits: [{ trait: "wip" }] },
{ id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] },
],
};
function renamedStore() {
const s = store();
return Object.assign(s, {
getTaskWorkflowSelection: () => ({ workflowId: "custom:renamed", stepIds: [] }),
getWorkflowDefinition: async () => ({ ir: RENAMED_IR }),
});
}
it("posts a tracking comment when a card reaches a RENAMED complete lane", async () => {
const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ id: 1 })); vi.stubGlobal("fetch", fetchImpl);
const s = renamedStore(); new GitLabTrackingCommentService(s as any).start();
s.emit("task:moved", { task: task("merge_request"), from: "backlog", to: "shipped" });
await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(1));
// The comment SHAPE is still the fixed "done" mode — a role decides the lane, not the wording.
expect(s.logEntry).toHaveBeenCalledWith("FN-1", "Posted GitLab tracking comment", "g/p!5 (shipped)");
});
it("posts a tracking comment when a card reaches a RENAMED wip lane", async () => {
const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ id: 1 })); vi.stubGlobal("fetch", fetchImpl);
const s = renamedStore(); new GitLabTrackingCommentService(s as any).start();
s.emit("task:moved", { task: task("group_issue"), from: "backlog", to: "building" });
await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(1));
});
it("stays silent for a move into a lane that plays no notable role on that renamed board", async () => {
// Non-vacuous: without this, a service commenting on EVERY move satisfies both cases above.
const fetchImpl = vi.fn(); vi.stubGlobal("fetch", fetchImpl);
const s = renamedStore(); new GitLabTrackingCommentService(s as any).start();
s.emit("task:moved", { task: task(), from: "building", to: "backlog" });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(fetchImpl).not.toHaveBeenCalled();
});
it("skips missing auth without calling GitLab", async () => {
const s = store(); s.getSettings.mockResolvedValueOnce({ gitlabAuthToken: "" }); const fetchImpl = vi.fn(); vi.stubGlobal("fetch", fetchImpl);
new GitLabTrackingCommentService(s as any).start(); s.emit("task:moved", { task: task(), from: "todo", to: "done" });
await vi.waitFor(() => expect(s.logEntry).toHaveBeenCalled());
expect(fetchImpl).not.toHaveBeenCalled();
});
});
/*
* FNXC:GitLabTrackingComments 2026-07-15-10:05:
* Parity coverage for the issue #1916 release lines on the GitLab surface.
*/
describe("formatGitLabTrackingComment release version lines", () => {
function selfRepoTask(overrides: Record<string, unknown> = {}): any {
return { ...task("project_issue"), branch: "fusion/fn-1", mergeDetails: { commitSha: "abcdef123", mergedAt: "today" }, ...overrides };
}
it("appends release lines when the linked project path is the Fusion self-repo", () => {
const comment = formatGitLabTrackingComment(selfRepoTask(), "done", undefined, { repository: "runfusion/fusion", currentVersion: "0.60.0" });
expect(comment).toContain("Current version: v0.60.0");
expect(comment).toContain("Target release: v0.61.0");
});
it("matches the self-repo project path case-insensitively", () => {
const comment = formatGitLabTrackingComment(selfRepoTask(), "done", undefined, { repository: "Runfusion/Fusion", currentVersion: "0.60.0" });
expect(comment).toContain("Target release: v0.61.0");
});
it("leaves done comments on every other project byte-for-byte unchanged", () => {
const withVersion = formatGitLabTrackingComment(selfRepoTask(), "done", undefined, { repository: "g/p", currentVersion: "0.60.0" });
expect(withVersion).not.toContain("Target release");
expect(withVersion).toBe(formatGitLabTrackingComment(selfRepoTask(), "done"));
});
it("omits release lines on the in-progress transition", () => {
expect(formatGitLabTrackingComment(selfRepoTask(), "in-progress", undefined, { repository: "runfusion/fusion", currentVersion: "0.60.0" })).not.toContain("Target release");
});
it("falls back silently for the unresolved sentinel and unparseable versions", () => {
for (const currentVersion of ["0.0.0", "not-a-version"]) {
const comment = formatGitLabTrackingComment(selfRepoTask(), "done", undefined, { repository: "runfusion/fusion", currentVersion });
expect(comment).not.toContain("Target release");
expect(comment).toContain("✅ Done —");
}
});
it("never resolves the package version for non-self projects", () => {
const resolveVersion = vi.fn(() => "0.60.0");
formatGitLabTrackingComment(selfRepoTask(), "done", undefined, { repository: "g/p", currentVersion: resolveVersion });
expect(resolveVersion).not.toHaveBeenCalled();
});
it("keeps release lines within the length cap when the title forces truncation", () => {
const comment = formatGitLabTrackingComment(selfRepoTask({ title: "T".repeat(4000) }), "done", "https://gitlab.example.com/g/p/-/issues/5", { repository: "runfusion/fusion", currentVersion: "0.60.0" });
expect(comment.length).toBeLessThanOrEqual(2000);
expect(comment).toContain("Target release: v0.61.0");
});
/*
* FNXC:GitLabTrackingComments 2026-07-15-10:05:
* resolveGitLabTargetFromItem() prefers the numeric projectId over projectPath, so passing the
* resolved target.project would stringify an id ("12345") and never match the self-repo slug.
* The service passes item.projectPath; assert the posted body proves that wiring.
*/
it("posts release lines using the project path even when a numeric projectId is present", async () => {
const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ id: 1 })); vi.stubGlobal("fetch", fetchImpl);
const s = store();
new GitLabTrackingCommentService(s as any).start();
const tracked = task("project_issue");
tracked.gitlabTracking.item.projectPath = "runfusion/fusion";
tracked.gitlabTracking.item.projectId = 12345;
s.emit("task:moved", { task: tracked, from: "todo", to: "done" });
await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalled());
const body = JSON.parse(String((fetchImpl.mock.calls[0][1] as any).body)).body as string;
expect(body).toContain("Target release: v");
expect(body).toContain("Current version: v");
});
it("posts no release lines for any other linked project", async () => {
const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ id: 1 })); vi.stubGlobal("fetch", fetchImpl);
const s = store();
new GitLabTrackingCommentService(s as any).start();
s.emit("task:moved", { task: task("project_issue"), from: "todo", to: "done" });
await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalled());
const body = JSON.parse(String((fetchImpl.mock.calls[0][1] as any).body)).body as string;
expect(body).toContain("✅ Done —");
expect(body).not.toContain("Target release");
});
});