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>
118 lines
5.3 KiB
TypeScript
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));
|
|
}
|
|
}
|
|
}
|