Files
fusion/packages/dashboard/src/github-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

190 lines
6.8 KiB
TypeScript

import type { TaskStore } from "@fusion/core";
import { GitHubClient } from "./github.js";
import { getCliPackageVersion } from "./cli-package-version.js";
import {
FUSION_SELF_REPO,
computeNextMinorVersion,
formatReleaseVersionLines,
isFusionSelfRepo,
} from "./fusion-release-version.js";
interface TaskSourceIssueRef {
provider: string;
repository: string;
issueNumber: number;
}
interface TaskMovedEvent {
task: {
id: string;
title?: string;
sourceIssue?: TaskSourceIssueRef;
githubTracking?: {
enabled?: boolean;
issue?: { owner?: string; repo?: string; number?: number };
};
};
/** Present on the real store event; GitHubTrackingCommentService no-ops when `from === to`. */
from?: string;
to: string;
}
const DEFAULT_COMMENT_TEMPLATE = "✅ Task {taskId} ({taskTitle}) has been completed and resolved.";
/*
* FNXC:GitHubIssueComment 2026-07-15-11:20:
* Requirement: a task must never receive TWO done comments on the SAME issue. A task can carry both
* linkages at once — maybeCreateTrackingIssue() ADOPTS a github sourceIssue as githubTracking.issue
* (github-tracking.ts, `source_issue_linked`), pointing both services at one issue — so with
* `githubCommentOnDone` on, this service and GitHubTrackingCommentService both commented on it.
*
* Suppress THIS service only when the tracking service will provably post to the SAME issue: it owns
* the richer comment (commit/branch/PR/files/merged + release lines). The two may legitimately target
* DIFFERENT issues (a tracking issue linked separately from the source issue) — that is two comments
* on two issues, which is correct and must keep working, so match on identity, never on "both linked".
*
* Mirrors the tracking service's own `from === to` no-op guard: on a same-column re-emit the tracking
* service stays silent, so suppressing here would drop the only comment.
*/
function sameGitHubIssue(
sourceIssue: TaskSourceIssueRef,
trackingIssue: { owner?: string; repo?: string; number?: number },
): boolean {
const [owner, repo] = sourceIssue.repository.split("/");
if (!owner || !repo || !trackingIssue.owner || !trackingIssue.repo) {
return false;
}
return owner.toLowerCase() === trackingIssue.owner.toLowerCase()
&& repo.toLowerCase() === trackingIssue.repo.toLowerCase()
&& sourceIssue.issueNumber === trackingIssue.number;
}
/** True when GitHubTrackingCommentService will post its own done comment to this exact issue. */
function trackingCommentCoversSourceIssue(event: TaskMovedEvent, sourceIssue: TaskSourceIssueRef): boolean {
if (event.from === event.to) {
return false;
}
const tracking = event.task.githubTracking;
if (tracking?.enabled !== true || !tracking.issue) {
return false;
}
return sameGitHubIssue(sourceIssue, tracking.issue);
}
/*
* FNXC:GitHubIssueComment 2026-07-15-10:40:
* Self-repo detection and next-minor computation live in `fusion-release-version.ts` so this
* service and GitHubTrackingCommentService share one implementation. See that module for the
* requirement and the FN-7575 miss.
*
* NOT redundant with GitHubTrackingCommentService: this service covers the `sourceIssue` IMPORT
* linkage (documented `githubCommentOnDone`; docs/settings-reference.md), while that one covers the
* `githubTracking.enabled` linkage. An issue imported with tracking defaults off has sourceIssue and
* no tracking, so THIS is the only surface that comments. Do not delete it as a duplicate.
*/
export class GitHubIssueCommentService {
private readonly store: TaskStore;
private readonly getGitHubToken: () => string | undefined;
private readonly getCurrentVersion: () => string;
private readonly onTaskMoved = (event: TaskMovedEvent): void => {
void this.handleTaskMoved(event);
};
private started = false;
constructor(
store: TaskStore,
getGitHubToken?: () => string | undefined,
getCurrentVersion?: () => string,
) {
this.store = store;
this.getGitHubToken = getGitHubToken ?? (() => process.env.GITHUB_TOKEN);
this.getCurrentVersion = getCurrentVersion ?? (() => getCliPackageVersion(import.meta.url));
}
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") {
return;
}
const task = event.task;
const settings = await this.store.getSettings();
if (!settings.githubCommentOnDone) {
return;
}
const sourceIssue = task.sourceIssue;
if (!sourceIssue || sourceIssue.provider !== "github") {
return;
}
const [owner, repo] = sourceIssue.repository.split("/");
if (!owner || !repo) {
await this.store.logEntry(
task.id,
"Failed to post GitHub issue comment",
`Invalid GitHub repository format: ${sourceIssue.repository}`,
);
return;
}
if (trackingCommentCoversSourceIssue(event, sourceIssue)) {
/*
* FNXC:GitHubIssueComment 2026-07-15-11:20:
* Logged, not silent: suppression is otherwise invisible, and a custom `githubCommentTemplate`
* not appearing on a tracked issue is surprising enough to need a breadcrumb. Once per task
* completion, so this is not the high-frequency skip-log noise FN-8024 removed.
*/
await this.store.logEntry(
task.id,
"Skipped GitHub issue completion comment",
`${sourceIssue.repository}#${sourceIssue.issueNumber} is tracked; GitHub tracking comment covers it`,
);
return;
}
const template = settings.githubCommentTemplate || DEFAULT_COMMENT_TEMPLATE;
let commentBody = template
.replaceAll("{taskId}", task.id)
.replaceAll("{taskTitle}", task.title ?? "");
const versionLines = formatReleaseVersionLines(sourceIssue.repository, () => this.getCurrentVersion());
if (versionLines.length > 0) {
commentBody += `\n\n${versionLines.join("\n")}`;
}
try {
const client = new GitHubClient(this.getGitHubToken());
await client.commentOnIssue(owner, repo, sourceIssue.issueNumber, commentBody);
await this.store.logEntry(
task.id,
"Posted GitHub issue completion comment",
`${sourceIssue.repository}#${sourceIssue.issueNumber}`,
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await this.store.logEntry(
task.id,
"Failed to post GitHub issue comment",
message,
);
}
}
}
export { DEFAULT_COMMENT_TEMPLATE };
// Re-exported from ./fusion-release-version.js for back-compat with existing importers/tests.
export { FUSION_SELF_REPO, isFusionSelfRepo, computeNextMinorVersion };