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>
This commit is contained in:
7
.changeset/fn-7575-dedupe-done-comments.md
Normal file
7
.changeset/fn-7575-dedupe-done-comments.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Stop posting two completion comments on a linked issue when a task is both imported and tracked.
|
||||
category: fix
|
||||
dev: A task can carry both linkages at once (GitHub adopts a sourceIssue as githubTracking.issue via `source_issue_linked`; GitLab's buildGitLabTaskProvenance always emits both), so with githubCommentOnDone/gitlabCommentOnDone on, the issue-comment and tracking-comment services both commented on the same issue. The issue-comment services now suppress themselves when the tracking service provably posts to the same target — matched on issue identity for GitHub (case-insensitive owner/repo + number) and by construction for GitLab (resolveGitLabTarget prefers the tracked item). The tracking comment wins because it carries commit/branch/PR/files plus the release lines. Tracking pointed at a different issue, tracking disabled/unlinked, and same-column re-emits (where the tracking service no-ops) all still post as before; the custom comment template no longer renders on tracked issues.
|
||||
@@ -634,7 +634,7 @@ Default notes:
|
||||
| `autoArchiveDuplicateTasksEnabled` | `boolean` | `false` | FN-7658: gates whether same-agent duplicate intake (FN-4892) auto-archives the later task. Default `false` — the duplicate is flagged in place (`nearDuplicateOf`/`nearDuplicateScore` marker, yellow "Duplicate" chip with Keep/Archive actions) instead of being archived automatically. Set `true` to restore the pre-FN-7658 auto-archive behavior. Does not affect ghost-bug preflight or tombstone-resurrection blocking. |
|
||||
| `archiveAgentLogMode` | `"none" \| "compact" \| "full"` | `"compact"` | Agent log retention strategy for cold archive snapshots. |
|
||||
| `autoUpdatePrStatus` | `boolean` | `false` | Auto-refresh PR status badges. |
|
||||
| `githubCommentOnDone` | `boolean` | `false` | When enabled, tasks imported from GitHub issues post a completion comment to the source issue when the task moves to `done`. |
|
||||
| `githubCommentOnDone` | `boolean` | `false` | When enabled, tasks imported from GitHub issues post a completion comment to the source issue when the task moves to `done`. Suppressed when the source issue is also the task's *tracked* issue (`githubTracking.enabled` with the same `owner/repo#number`): the GitHub tracking comment already reports completion there, with commit/branch/PR/files details, so the issue would otherwise receive two comments. In that case `githubCommentTemplate` is not used and the task log records `Skipped GitHub issue completion comment`. When tracking points at a *different* issue, both issues are commented as before. |
|
||||
| `githubCommentTemplate` | `string` | `undefined` | Optional issue comment template used by `githubCommentOnDone`. Supports `{taskId}` and `{taskTitle}` placeholders. If unset, Fusion uses a default completion message. When the linked source issue's repository is the Fusion self-repo (`runfusion/fusion`, case-insensitive), Fusion appends a `Current version: v<current>` line and a `Target release: v<nextMinor>` line (next-minor bump, patch reset to 0, e.g. `0.55.0` → `0.56.0`), resolved via the published `@runfusion/fusion` CLI package version. If that version is unresolved/unparseable, the base comment is posted with no version lines. Comments on every other repository are byte-for-byte unchanged. |
|
||||
| `githubCloseSourceIssueOnDone` | `boolean` | `false` | When enabled, source-imported GitHub issues are automatically closed with `state_reason: completed` when the Fusion task moves to `done`. A startup reconciliation sweep also closes missed open source issues on boot. |
|
||||
| `githubTrackingEnabledByDefault` | `boolean` | `false` | Project-level default for enabling issue tracking on ordinary new tasks. When this is false, the Quick Entry GitHub toggle is disabled until tracking is enabled in Settings. Imported GitHub issues still follow this default unless `githubLinkImportedIssuesToTracking` is enabled. |
|
||||
@@ -648,7 +648,7 @@ Default notes:
|
||||
| `gitlabApiBaseUrl` | `string` | `undefined` (effective global fallback, then `<instance>/api/v4`) | Optional project GitLab REST API base URL. Blank/unset inherits global `gitlabApiBaseUrl`; if still unset, Fusion derives `<instance>/api/v4`, preserving self-managed path prefixes. Override only for API gateways with a different absolute HTTP(S) base URL. |
|
||||
| `gitlabAuthToken` | `string` | `undefined` | Project GitLab access token for later GitLab import/tracking/comment/close HTTP API tasks. Project value takes precedence over global `gitlabAuthToken`, then process `GITLAB_TOKEN`. The dashboard renders the field as a password input and trims values on save; blank clears the project override. Dashboard location: **Settings → Project → Merge → GitLab Authentication**. |
|
||||
| `gitlabAuthTokenType` | `"personal" \| "project" \| "group"` | `undefined` (effective `"personal"` when a token exists) | Project GitLab token family label. Supported values are personal access token, project access token, and group access token. Select the token family that matches the credential; unsupported values fail auth resolution with `invalid_token_type`. |
|
||||
| `gitlabCommentOnDone` | `boolean` | `false` | When enabled, tasks imported from GitLab project issues, group-backed issues with project identity, or merge requests post a completion note when the task moves to `done`. Requires a GitLab token with write-capable API access (`api`) to the target project. |
|
||||
| `gitlabCommentOnDone` | `boolean` | `false` | When enabled, tasks imported from GitLab project issues, group-backed issues with project identity, or merge requests post a completion note when the task moves to `done`. Suppressed when the task has a linked GitLab tracked item (`gitlabTracking.item`), which GitLab imports always set: the GitLab tracking comment already reports completion on that same item, so it would otherwise receive two notes. In that case `gitlabCommentTemplate` is not used and the task log records `Skipped GitLab source comment`. The setting still applies to imported tasks whose tracked item has been unlinked. Requires a GitLab token with write-capable API access (`api`) to the target project. |
|
||||
| `gitlabCommentTemplate` | `string` | `undefined` | Optional GitLab source note template used by `gitlabCommentOnDone`. Supports `{taskId}` and `{taskTitle}` placeholders. If unset, Fusion uses the same concise completion message shape as GitHub source comments. |
|
||||
| `gitlabCloseSourceIssueOnDone` | `boolean` | `false` | When enabled, source-imported GitLab project issues and merge requests close when the Fusion task moves to `done` and reopen when moved out of `done`, where GitLab supports `state_event`. Fusion never merges merge requests; merged MRs and group issues without backing `projectId`/`projectPath` plus IID are skipped with task-log diagnostics. |
|
||||
| `githubTrackingDedupEnabled` | `boolean` | `true` | When enabled, tracking issue creation searches open and closed repo issues for likely duplicates before opening a new issue (gh CLI search first, with REST search fallback). Set `false` to skip dedup and always create a new issue when tracking is enabled. Dashboard location: **Settings → Project → General → GitHub Tracking**. |
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
GitHubIssueCommentService,
|
||||
isFusionSelfRepo,
|
||||
} from "../github-issue-comment.js";
|
||||
import { GitHubTrackingCommentService } from "../github-tracking-comments.js";
|
||||
|
||||
const { mockCommentOnIssue } = vi.hoisted(() => ({
|
||||
mockCommentOnIssue: vi.fn(),
|
||||
@@ -18,6 +19,10 @@ vi.mock("../github.js", () => ({
|
||||
}; }),
|
||||
}));
|
||||
|
||||
vi.mock("../github-auth.js", () => ({
|
||||
resolveGithubTrackingAuth: () => ({ ok: true, auth: { mode: "token", token: "ghp_test" } }),
|
||||
}));
|
||||
|
||||
class MockStore extends EventEmitter {
|
||||
private settings: Record<string, unknown>;
|
||||
logEntry: Mock;
|
||||
@@ -318,3 +323,161 @@ describe("GitHubIssueCommentService", () => {
|
||||
expect(mockCommentOnIssue).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* FNXC:GitHubIssueComment 2026-07-15-11:20:
|
||||
* Regression coverage for the double-comment bug: a task carrying BOTH the sourceIssue import
|
||||
* linkage and an adopted githubTracking linkage received two done comments on ONE issue.
|
||||
* Surface enumeration — suppression must fire ONLY on proven same-issue overlap, and every case
|
||||
* where the tracking service stays silent must still get its comment:
|
||||
* suppress: same issue, incl. case-different owner/repo slugs
|
||||
* post: different issue | tracking disabled | no tracking issue | from === to re-emit
|
||||
*/
|
||||
describe("GitHubIssueCommentService duplicate-comment suppression", () => {
|
||||
let store: MockStore;
|
||||
let service: GitHubIssueCommentService;
|
||||
|
||||
const trackedSameIssue = { enabled: true, issue: { owner: "owner", repo: "repo", number: 123, url: "u", createdAt: "now" } };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
store = new MockStore({ githubCommentOnDone: true });
|
||||
service = new GitHubIssueCommentService(store as unknown as TaskStore, () => "ghp_test", () => "0.60.0");
|
||||
service.start();
|
||||
});
|
||||
|
||||
afterEach(() => service.stop());
|
||||
|
||||
it("suppresses its comment when the tracking service covers the same issue", async () => {
|
||||
store.emit("task:moved", { task: createTask({ githubTracking: trackedSameIssue }), from: "in-progress", to: "done" });
|
||||
await flushAsync();
|
||||
|
||||
expect(mockCommentOnIssue).not.toHaveBeenCalled();
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-2623",
|
||||
"Skipped GitHub issue completion comment",
|
||||
"owner/repo#123 is tracked; GitHub tracking comment covers it",
|
||||
);
|
||||
});
|
||||
|
||||
it("suppresses when the tracking slug differs only by case", async () => {
|
||||
const tracking = { enabled: true, issue: { owner: "Owner", repo: "Repo", number: 123, url: "u", createdAt: "now" } };
|
||||
store.emit("task:moved", { task: createTask({ githubTracking: tracking }), from: "in-progress", to: "done" });
|
||||
await flushAsync();
|
||||
|
||||
expect(mockCommentOnIssue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still posts when tracking points at a DIFFERENT issue (two issues, two comments is correct)", async () => {
|
||||
const tracking = { enabled: true, issue: { owner: "other", repo: "tracker", number: 9, url: "u", createdAt: "now" } };
|
||||
store.emit("task:moved", { task: createTask({ githubTracking: tracking }), from: "in-progress", to: "done" });
|
||||
await flushAsync();
|
||||
|
||||
expect(mockCommentOnIssue).toHaveBeenCalledTimes(1);
|
||||
expect(mockCommentOnIssue).toHaveBeenCalledWith("owner", "repo", 123, expect.any(String));
|
||||
});
|
||||
|
||||
it("still posts when the same-numbered issue lives in a different repo", async () => {
|
||||
const tracking = { enabled: true, issue: { owner: "owner", repo: "other-repo", number: 123, url: "u", createdAt: "now" } };
|
||||
store.emit("task:moved", { task: createTask({ githubTracking: tracking }), from: "in-progress", to: "done" });
|
||||
await flushAsync();
|
||||
|
||||
expect(mockCommentOnIssue).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("still posts when tracking is linked but disabled", async () => {
|
||||
store.emit("task:moved", { task: createTask({ githubTracking: { ...trackedSameIssue, enabled: false } }), from: "in-progress", to: "done" });
|
||||
await flushAsync();
|
||||
|
||||
expect(mockCommentOnIssue).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("still posts when tracking is enabled with no linked issue", async () => {
|
||||
store.emit("task:moved", { task: createTask({ githubTracking: { enabled: true } }), from: "in-progress", to: "done" });
|
||||
await flushAsync();
|
||||
|
||||
expect(mockCommentOnIssue).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
/*
|
||||
* FNXC:GitHubIssueComment 2026-07-15-11:20:
|
||||
* The tracking service no-ops when from === to, so suppressing on that event would drop the
|
||||
* comment entirely rather than dedupe it.
|
||||
*/
|
||||
it("still posts on a same-column re-emit, where the tracking service stays silent", async () => {
|
||||
store.emit("task:moved", { task: createTask({ githubTracking: trackedSameIssue }), from: "done", to: "done" });
|
||||
await flushAsync();
|
||||
|
||||
expect(mockCommentOnIssue).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* FNXC:GitHubIssueComment 2026-07-15-11:20:
|
||||
* Symptom verification for the double-comment bug at the real wiring: BOTH services listening on one
|
||||
* store, registered in register-git-github.ts order. The per-service tests above cannot catch a
|
||||
* suppression predicate that disagrees with what the tracking service actually does, so assert the
|
||||
* total comment count on the issue.
|
||||
*/
|
||||
describe("both comment services on one store", () => {
|
||||
function bothServices(settings: Record<string, unknown>) {
|
||||
const store = new MockStore(settings);
|
||||
const issueService = new GitHubIssueCommentService(store as unknown as TaskStore, () => "ghp_test", () => "0.60.0");
|
||||
const trackingService = new GitHubTrackingCommentService(store as unknown as TaskStore);
|
||||
issueService.start();
|
||||
trackingService.start();
|
||||
return { store, stop: () => { issueService.stop(); trackingService.stop(); } };
|
||||
}
|
||||
|
||||
const adoptedSourceIssue = {
|
||||
githubTracking: { enabled: true, issue: { owner: "owner", repo: "repo", number: 123, url: "u", createdAt: "now" } },
|
||||
branch: "fusion/fn-2623",
|
||||
};
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("posts exactly one comment — the richer tracking one — when both linkages point at one issue", async () => {
|
||||
const { store, stop } = bothServices({ githubCommentOnDone: true, githubAuthMode: "token", githubAuthToken: "ghp_test" });
|
||||
store.emit("task:moved", { task: createTask(adoptedSourceIssue), from: "in-progress", to: "done" });
|
||||
await flushAsync();
|
||||
stop();
|
||||
|
||||
expect(mockCommentOnIssue).toHaveBeenCalledTimes(1);
|
||||
const body = mockCommentOnIssue.mock.calls[0]?.[3] as string;
|
||||
expect(body).toContain("✅ Done —");
|
||||
expect(body).toContain("Branch: fusion/fn-2623");
|
||||
expect(body).not.toContain("has been completed and resolved");
|
||||
});
|
||||
|
||||
it("posts one comment per issue when the linkages point at different issues", async () => {
|
||||
const { store, stop } = bothServices({ githubCommentOnDone: true, githubAuthMode: "token", githubAuthToken: "ghp_test" });
|
||||
const tracking = { enabled: true, issue: { owner: "other", repo: "tracker", number: 9, url: "u", createdAt: "now" } };
|
||||
store.emit("task:moved", { task: createTask({ githubTracking: tracking }), from: "in-progress", to: "done" });
|
||||
await flushAsync();
|
||||
stop();
|
||||
|
||||
expect(mockCommentOnIssue).toHaveBeenCalledTimes(2);
|
||||
expect(mockCommentOnIssue.mock.calls.map((call) => `${call[0]}/${call[1]}#${call[2]}`).sort())
|
||||
.toEqual(["other/tracker#9", "owner/repo#123"]);
|
||||
});
|
||||
|
||||
it("posts exactly one comment for an imported issue with tracking off", async () => {
|
||||
const { store, stop } = bothServices({ githubCommentOnDone: true, githubAuthMode: "token", githubAuthToken: "ghp_test" });
|
||||
store.emit("task:moved", { task: createTask(), from: "in-progress", to: "done" });
|
||||
await flushAsync();
|
||||
stop();
|
||||
|
||||
expect(mockCommentOnIssue).toHaveBeenCalledTimes(1);
|
||||
expect(mockCommentOnIssue.mock.calls[0]?.[3]).toContain("has been completed and resolved");
|
||||
});
|
||||
|
||||
it("posts exactly one comment for a tracked task with githubCommentOnDone off", async () => {
|
||||
const { store, stop } = bothServices({ githubCommentOnDone: false, githubAuthMode: "token", githubAuthToken: "ghp_test" });
|
||||
store.emit("task:moved", { task: createTask(adoptedSourceIssue), from: "in-progress", to: "done" });
|
||||
await flushAsync();
|
||||
stop();
|
||||
|
||||
expect(mockCommentOnIssue).toHaveBeenCalledTimes(1);
|
||||
expect(mockCommentOnIssue.mock.calls[0]?.[3]).toContain("✅ Done —");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,19 @@ function store(settings: any = {}) {
|
||||
logEntry: vi.fn(),
|
||||
});
|
||||
}
|
||||
/*
|
||||
* FNXC:GitLabIssueComment 2026-07-15-11:20:
|
||||
* Two distinct task shapes, because this service and GitLabTrackingCommentService now own disjoint
|
||||
* cases (no more double comments on one item):
|
||||
* `task` — TRACKED: sourceIssue + gitlabTracking.item. The tracking service comments;
|
||||
* this service suppresses. buildGitLabTaskProvenance() emits this shape.
|
||||
* `untrackedTask` — IMPORTED, NOT TRACKED: sourceIssue + source.sourceMetadata, no item (e.g. the
|
||||
* item was unlinked). resolveGitLabTarget() falls back to sourceMetadata, the
|
||||
* tracking service stays silent, and THIS service posts. The live
|
||||
* `gitlabCommentOnDone` path.
|
||||
*/
|
||||
const task: any = { id: "FN-1", title: "Fix", sourceIssue: { provider: "gitlab", repository: "g/p", issueNumber: 2, url: "https://gitlab.example.com/g/p/-/issues/2" }, gitlabTracking: { item: { kind: "project_issue", instanceUrl: "https://gitlab.example.com", host: "gitlab.example.com", url: "https://gitlab.example.com/g/p/-/issues/2", projectPath: "g/p", iid: 2, title: "Fix", state: "opened", linkedAt: "now" } } };
|
||||
const untrackedTask: any = { id: "FN-1", title: "Fix", sourceIssue: { provider: "gitlab", repository: "g/p", issueNumber: 2, url: "https://gitlab.example.com/g/p/-/issues/2" }, source: { sourceType: "gitlab_import", sourceMetadata: { resourceType: "project_issue", iid: 2, projectPath: "g/p" } } };
|
||||
|
||||
describe("GitLabIssueCommentService", () => {
|
||||
beforeEach(() => vi.unstubAllGlobals());
|
||||
@@ -20,7 +32,7 @@ describe("GitLabIssueCommentService", () => {
|
||||
vi.stubGlobal("fetch", fetchImpl);
|
||||
const s = store();
|
||||
new GitLabIssueCommentService(s as any).start();
|
||||
s.emit("task:moved", { task, to: "done" });
|
||||
s.emit("task:moved", { task: untrackedTask, from: "in-progress", to: "done" });
|
||||
await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalled());
|
||||
expect(fetchImpl.mock.calls[0][0]).toBe("https://gitlab.example.com/api/v4/projects/g%2Fp/issues/2/notes");
|
||||
expect(s.logEntry).toHaveBeenCalledWith("FN-1", "Posted GitLab issue completion comment", "g/p#2");
|
||||
@@ -28,15 +40,16 @@ describe("GitLabIssueCommentService", () => {
|
||||
/*
|
||||
* FNXC:GitLabIssueComment 2026-07-15-10:05:
|
||||
* Parity coverage for the issue #1916 release lines. Version is injected so assertions do not
|
||||
* drift with each real release.
|
||||
* drift with each real release. Uses the untracked shape — a tracked item is suppressed here and
|
||||
* carries its release lines through GitLabTrackingCommentService instead.
|
||||
*/
|
||||
it("appends release version lines for a Fusion self-repo source issue", async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ id: 1 }));
|
||||
vi.stubGlobal("fetch", fetchImpl);
|
||||
const s = store();
|
||||
new GitLabIssueCommentService(s as any, () => "0.60.0").start();
|
||||
const selfRepoTask = { ...task, gitlabTracking: { item: { ...task.gitlabTracking.item, projectPath: "runfusion/fusion" } } };
|
||||
s.emit("task:moved", { task: selfRepoTask, to: "done" });
|
||||
const selfRepoTask = { ...untrackedTask, source: { ...untrackedTask.source, sourceMetadata: { ...untrackedTask.source.sourceMetadata, projectPath: "runfusion/fusion" } }, sourceIssue: { ...untrackedTask.sourceIssue, repository: "runfusion/fusion" } };
|
||||
s.emit("task:moved", { task: selfRepoTask, from: "in-progress", 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("Current version: v0.60.0");
|
||||
@@ -48,12 +61,66 @@ describe("GitLabIssueCommentService", () => {
|
||||
vi.stubGlobal("fetch", fetchImpl);
|
||||
const s = store();
|
||||
new GitLabIssueCommentService(s as any, () => "0.60.0").start();
|
||||
s.emit("task:moved", { task, to: "done" });
|
||||
s.emit("task:moved", { task: untrackedTask, from: "in-progress", 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).toBe("✅ Task FN-1 (Fix) has been completed and resolved.");
|
||||
});
|
||||
|
||||
/*
|
||||
* FNXC:GitLabIssueComment 2026-07-15-11:20:
|
||||
* Regression coverage for the double-comment bug. buildGitLabTaskProvenance() always returns BOTH
|
||||
* sourceIssue and gitlabTracking.item, so before the fix EVERY imported GitLab task with
|
||||
* gitlabCommentOnDone on was commented twice — once here, once by GitLabTrackingCommentService.
|
||||
* Suppression must fire only when the tracking service will actually post.
|
||||
*/
|
||||
it("suppresses its comment when a tracked item covers the same target", async () => {
|
||||
const fetchImpl = vi.fn(); vi.stubGlobal("fetch", fetchImpl);
|
||||
const s = store(); new GitLabIssueCommentService(s as any).start();
|
||||
s.emit("task:moved", { task, from: "in-progress", to: "done" });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
expect(s.logEntry).toHaveBeenCalledWith("FN-1", "Skipped GitLab source comment", "g/p#2 is tracked; GitLab tracking comment covers it");
|
||||
});
|
||||
|
||||
it("still posts for an imported issue with no tracked item", async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ id: 1 })); vi.stubGlobal("fetch", fetchImpl);
|
||||
const s = store(); new GitLabIssueCommentService(s as any).start();
|
||||
s.emit("task:moved", { task: untrackedTask, from: "in-progress", to: "done" });
|
||||
await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(1));
|
||||
expect(s.logEntry).toHaveBeenCalledWith("FN-1", "Posted GitLab issue completion comment", "g/p#2");
|
||||
});
|
||||
|
||||
/*
|
||||
* FNXC:GitLabIssueComment 2026-07-15-11:20:
|
||||
* The tracking service no-ops when from === to, so suppressing there would drop the only comment.
|
||||
*/
|
||||
it("still posts on a same-column re-emit, where the tracking service stays silent", async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ id: 1 })); vi.stubGlobal("fetch", fetchImpl);
|
||||
const s = store(); new GitLabIssueCommentService(s as any).start();
|
||||
s.emit("task:moved", { task, from: "done", to: "done" });
|
||||
await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
/*
|
||||
* FNXC:GitLabIssueComment 2026-07-15-11:20:
|
||||
* Documents a PRE-EXISTING gap, unchanged by the suppression fix: resolveGitLabTarget() early-returns
|
||||
* resolveGitLabTargetFromItem(item) whenever an item is present and never falls back to
|
||||
* sourceMetadata. So an item too incomplete to resolve (no projectId/projectPath) silences BOTH
|
||||
* services — the tracking one bails on the same unresolvable item, and this one skips as incomplete.
|
||||
* Suppression is not implicated (it is unreachable here); asserted so a future sourceMetadata
|
||||
* fallback is a deliberate change with a failing test, not an accident.
|
||||
*/
|
||||
it("skips as incomplete when the tracked item cannot resolve (pre-existing: neither service comments)", async () => {
|
||||
const fetchImpl = vi.fn(); vi.stubGlobal("fetch", fetchImpl);
|
||||
const s = store(); new GitLabIssueCommentService(s as any).start();
|
||||
const unusableItem = { ...untrackedTask, gitlabTracking: { item: { kind: "project_issue", iid: 2, instanceUrl: "https://gitlab.example.com", host: "gitlab.example.com", url: "u", createdAt: "now" } } };
|
||||
s.emit("task:moved", { task: unusableItem, from: "in-progress", to: "done" });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
expect(s.logEntry).toHaveBeenCalledWith("FN-1", "Skipped GitLab source comment", "Linked GitLab source metadata is incomplete");
|
||||
});
|
||||
|
||||
it("skips non-GitLab and incomplete source metadata", async () => {
|
||||
const fetchImpl = vi.fn(); vi.stubGlobal("fetch", fetchImpl);
|
||||
const s = store(); new GitLabIssueCommentService(s as any).start();
|
||||
|
||||
@@ -8,21 +8,69 @@ import {
|
||||
isFusionSelfRepo,
|
||||
} from "./fusion-release-version.js";
|
||||
|
||||
interface TaskSourceIssueRef {
|
||||
provider: string;
|
||||
repository: string;
|
||||
issueNumber: number;
|
||||
}
|
||||
|
||||
interface TaskMovedEvent {
|
||||
task: {
|
||||
id: string;
|
||||
title?: string;
|
||||
sourceIssue?: {
|
||||
provider: string;
|
||||
repository: string;
|
||||
issueNumber: number;
|
||||
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
|
||||
@@ -92,6 +140,21 @@ export class GitHubIssueCommentService {
|
||||
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)
|
||||
|
||||
@@ -1,13 +1,39 @@
|
||||
import type { ProjectSettings, Task, TaskStore } from "@fusion/core";
|
||||
import { resolveGitLabClient, resolveGitLabTarget, safeLogGitLabEntry } from "./gitlab-lifecycle.js";
|
||||
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.";
|
||||
|
||||
/*
|
||||
@@ -55,6 +81,11 @@ export class GitLabIssueCommentService {
|
||||
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 ?? "");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user