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>
172 lines
10 KiB
TypeScript
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");
|
|
});
|
|
});
|