Files
fusion/packages/dashboard/src/gitlab-issue-comment.ts
gsxdsm 1b9c7a7ca2 fix(FN-7575): stop double-commenting when a task is both imported and tracked
A task can carry BOTH linkages at once, pointing two services at ONE issue:
- GitHub: maybeCreateTrackingIssue() ADOPTS a github sourceIssue as
  githubTracking.issue (github-tracking.ts, `source_issue_linked`).
- GitLab: buildGitLabTaskProvenance() always returns sourceIssue AND
  gitlabTracking.item for the same item, so on GitLab EVERY imported task with
  gitlabCommentOnDone on was double-commented.

With comment-on-done enabled the issue-comment service and the tracking-comment
service both posted. Reproduced against the real wiring: two comments on
acme/widgets#42 ("✅ Task FN-1 ... resolved." then "✅ Done — ...").

The issue-comment services now suppress themselves when the tracking service
provably posts to the SAME target, and the tracking comment wins — it carries
commit/branch/PR/files/merged plus the release lines.

Identity, never "both linked": the two may legitimately target DIFFERENT issues
(a tracking issue linked separately from the source issue), which is two comments
on two issues and must keep working. GitHub matches on case-insensitive
owner/repo + number; GitLab is identical by construction because
resolveGitLabTarget() prefers the tracked item.

Both guards mirror the tracking services' `from === to` no-op guard: on a
same-column re-emit the tracking service stays silent, so suppressing there would
drop the only comment rather than dedupe it.

The net split is now disjoint: issue-comment owns "imported but not tracked",
tracking owns "tracked". Suppression is logged (once per completion, not the
high-frequency skip-noise FN-8024 removed) because a custom comment template
silently not rendering on a tracked issue is otherwise unexplainable.

Behavior change, documented in settings-reference.md: githubCommentTemplate /
gitlabCommentTemplate no longer render on a tracked issue.

Tests updated where they encoded the double-post path (they exercised the
services in isolation, so the duplicate was invisible). GitLab fixtures now
distinguish tracked vs imported-not-tracked shapes. Also asserts a PRE-EXISTING
gap left unchanged: resolveGitLabTarget() early-returns on an unresolvable item
and never falls back to sourceMetadata, so neither service comments there.

Verified non-vacuous: the 4 suppression tests fail against the pre-fix source;
the "still posts" tests pass either way by design. Gate green (294/122/63).

Fusion-Task-Id: FN-7575

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 20:24:49 -07:00

118 lines
5.3 KiB
TypeScript

import type { ProjectSettings, Task, TaskStore } from "@fusion/core";
import { resolveGitLabClient, resolveGitLabTarget, resolveGitLabTargetFromItem, safeLogGitLabEntry } from "./gitlab-lifecycle.js";
import { getCliPackageVersion } from "./cli-package-version.js";
import { formatReleaseVersionLines } from "./fusion-release-version.js";
interface TaskMovedEvent {
task: Task;
/** Present on the real store event; GitLabTrackingCommentService no-ops when `from === to`. */
from?: string;
to: string;
}
/*
* FNXC:GitLabIssueComment 2026-07-15-11:20:
* Requirement: a task must never receive TWO done comments on the SAME GitLab item. GitLab imports
* always carry BOTH linkages — buildGitLabTaskProvenance() returns sourceIssue AND gitlabTracking.item
* for the same item — so with `gitlabCommentOnDone` on, EVERY imported task was double-commented
* (broader than the GitHub case, where adoption is conditional).
*
* Identity here is by construction, not by comparison: resolveGitLabTarget() prefers
* gitlabTracking.item over sourceIssue, so whenever the item resolves, THIS service's target IS the
* tracking service's target. Suppress this service then and let the tracking service post the richer
* comment. With no item, resolveGitLabTarget() falls back to sourceIssue and this service still posts
* — the imported-with-tracking-off case.
*
* Mirrors the tracking service's `from === to` no-op guard: on a same-column re-emit it stays silent,
* so suppressing here would drop the only comment.
*/
function trackingCommentCoversTarget(event: TaskMovedEvent): boolean {
if (event.from === event.to) {
return false;
}
const item = event.task.gitlabTracking?.item;
return Boolean(item && resolveGitLabTargetFromItem(item));
}
export const DEFAULT_GITLAB_COMMENT_TEMPLATE = "✅ Task {taskId} ({taskTitle}) has been completed and resolved.";
/*
* FNXC:GitLabIssueComment 2026-07-15-10:40:
* Mirrors the GitHub self-repo release lines (issue #1916) via the shared fusion-release-version
* helper, so the two cannot drift the way github-issue-comment.ts drifted from
* github-tracking-comments.ts.
*
* NOT redundant with GitLabTrackingCommentService: this service covers the `sourceIssue` IMPORT
* linkage (documented `gitlabCommentOnDone`; docs/settings-reference.md), while that one covers the
* `gitlabTracking.item` linkage. An issue imported with tracking off has sourceIssue and no
* tracking, so THIS is the only surface that comments. Do not delete it as a duplicate.
*/
export class GitLabIssueCommentService {
private readonly store: TaskStore;
private readonly getCurrentVersion: () => string;
private readonly onTaskMoved = (event: TaskMovedEvent): void => { void this.handleTaskMoved(event); };
private started = false;
constructor(store: TaskStore, getCurrentVersion?: () => string) {
this.store = store;
this.getCurrentVersion = getCurrentVersion ?? (() => getCliPackageVersion());
}
start(): void {
if (this.started) return;
this.started = true;
this.store.on("task:moved", this.onTaskMoved);
}
stop(): void {
if (!this.started) return;
this.started = false;
this.store.off("task:moved", this.onTaskMoved);
}
private async handleTaskMoved(event: TaskMovedEvent): Promise<void> {
if (event.to !== "done" || event.task.sourceIssue?.provider !== "gitlab") return;
const settings = await this.store.getSettings() as Pick<ProjectSettings, "gitlabCommentOnDone" | "gitlabCommentTemplate">;
if (settings.gitlabCommentOnDone !== true) return;
const target = resolveGitLabTarget(event.task);
if (!target) {
await safeLogGitLabEntry(this.store, event.task.id, "Skipped GitLab source comment", "Linked GitLab source metadata is incomplete");
return;
}
if (trackingCommentCoversTarget(event)) {
await safeLogGitLabEntry(this.store, event.task.id, "Skipped GitLab source comment", `${target.label} is tracked; GitLab tracking comment covers it`);
return;
}
const template = settings.gitlabCommentTemplate || DEFAULT_GITLAB_COMMENT_TEMPLATE;
let body = template.replaceAll("{taskId}", event.task.id).replaceAll("{taskTitle}", event.task.title ?? "");
// Project PATH only — resolveGitLabTarget() prefers the numeric projectId, which never matches the slug.
const repository = event.task.gitlabTracking?.item?.projectPath ?? event.task.sourceIssue?.repository;
if (repository) {
const versionLines = formatReleaseVersionLines(repository, () => this.getCurrentVersion());
if (versionLines.length > 0) {
body += `\n\n${versionLines.join("\n")}`;
}
}
try {
const resolved = await resolveGitLabClient(this.store);
if (!resolved.ok) {
await safeLogGitLabEntry(this.store, event.task.id, "Skipped GitLab source comment", resolved.message);
return;
}
if (target.kind === "merge_request") {
await resolved.client.commentOnMergeRequest(target.project, target.iid, body);
} else {
await resolved.client.commentOnProjectIssue(target.project, target.iid, body);
}
await safeLogGitLabEntry(this.store, event.task.id, "Posted GitLab issue completion comment", target.label);
} catch (error) {
await safeLogGitLabEntry(this.store, event.task.id, "Failed to post GitLab issue comment", error instanceof Error ? error.message : String(error));
}
}
}