From 4eb532f24e4f02d138f3db581ff04a6fb8e49143 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 21 Jul 2026 16:08:45 -0700 Subject: [PATCH] fix(github): dedupe task progress updates Post at most one in-progress comment per Fusion task while keeping failed deliveries retryable. Persist a durable marker and fall back to the task log when local marker storage fails after GitHub accepts the comment. --- .changeset/single-github-progress-update.md | 7 ++ packages/core/src/types/task-tracking.ts | 3 +- .../github-tracking-comments.test.ts | 109 +++++++++++++++++- .../dashboard/src/github-tracking-comments.ts | 57 +++++++-- 4 files changed, 166 insertions(+), 10 deletions(-) create mode 100644 .changeset/single-github-progress-update.md diff --git a/.changeset/single-github-progress-update.md b/.changeset/single-github-progress-update.md new file mode 100644 index 0000000000..8f5daada96 --- /dev/null +++ b/.changeset/single-github-progress-update.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Send only one in-progress update per Fusion task on its linked GitHub tracking issue. +category: fix +dev: Persists the successful in-progress notification marker and retains legacy task-log deduplication. diff --git a/packages/core/src/types/task-tracking.ts b/packages/core/src/types/task-tracking.ts index 575af69316..74c3e6ddcb 100644 --- a/packages/core/src/types/task-tracking.ts +++ b/packages/core/src/types/task-tracking.ts @@ -113,6 +113,8 @@ export interface TaskGithubTracking { repoOverride?: string; /** Linked GitHub issue. Set after issue creation succeeds. Cleared via unlinkGithubIssue(). */ issue?: TaskGithubTrackedIssue; + /** ISO-8601 timestamp of the task's one permitted in-progress tracking comment. */ + inProgressCommentedAt?: string; /** ISO-8601 of the most recent manual unlink, retained for audit. */ unlinkedAt?: string; } @@ -142,4 +144,3 @@ export interface TaskSourceIssue { */ closedAt?: string; } - diff --git a/packages/dashboard/src/__tests__/github-tracking-comments.test.ts b/packages/dashboard/src/__tests__/github-tracking-comments.test.ts index cc3831e670..5ad585bf4d 100644 --- a/packages/dashboard/src/__tests__/github-tracking-comments.test.ts +++ b/packages/dashboard/src/__tests__/github-tracking-comments.test.ts @@ -42,6 +42,7 @@ vi.mock("../cli-package-version.js", async (importOriginal) => ({ class MockStore extends EventEmitter { logEntry: Mock; getTask: Mock; + updateTask: Mock; getSettings: Mock; getGlobalSettingsStore: Mock; @@ -50,6 +51,7 @@ class MockStore extends EventEmitter { this.logEntry = vi.fn().mockResolvedValue(undefined); // Null preserves the event snapshot unless a test supplies a newer authoritative row. this.getTask = vi.fn().mockResolvedValue(null); + this.updateTask = vi.fn().mockResolvedValue(undefined); this.getSettings = vi.fn().mockResolvedValue({ githubAuthMode: "token", githubAuthToken: "ghp_test" }); this.getGlobalSettingsStore = vi.fn(() => ({ getSettings: vi.fn().mockResolvedValue({}) })); } @@ -572,18 +574,121 @@ describe("GitHubTrackingCommentService", () => { expect(mockCommentOnIssue.mock.calls[0]?.[3]).not.toContain("Commit:"); }); - it("does not refetch or duplicate a comment for in-progress and same-column transitions", async () => { + it("does not duplicate a comment for in-progress and same-column transitions", async () => { service.start(); store.emit("task:moved", { task: createTask(), from: "todo", to: "in-progress" }); store.emit("task:moved", { task: createTask(), from: "done", to: "done" }); await flushAsync(); - expect(store.getTask).not.toHaveBeenCalled(); + expect(store.getTask).toHaveBeenCalledTimes(1); expect(mockCommentOnIssue).toHaveBeenCalledTimes(1); expect(mockCommentOnIssue.mock.calls[0]?.[3]).toContain("🚧 In progress"); }); + it("posts only one in-progress comment when a task leaves and re-enters the column", async () => { + service.start(); + + store.emit("task:moved", { task: createTask(), from: "todo", to: "in-progress" }); + await flushAsync(); + store.emit("task:moved", { task: createTask(), from: "todo", to: "in-progress" }); + await flushAsync(); + + expect(mockCommentOnIssue).toHaveBeenCalledTimes(1); + expect(mockCommentOnIssue.mock.calls[0]?.[3]).toContain("🚧 In progress"); + }); + + it("does not repost an in-progress comment after the service restarts", async () => { + service.start(); + service.stop(); + service = new GitHubTrackingCommentService(store as unknown as TaskStore); + service.start(); + + store.getTask.mockResolvedValueOnce(createTask({ + githubTracking: { + enabled: true, + inProgressCommentedAt: "2026-07-21T12:00:00.000Z", + issue: { + owner: "owner", + repo: "repo", + number: 42, + url: "https://github.com/owner/repo/issues/42", + createdAt: "2026-01-01T00:00:00.000Z", + }, + }, + })); + + store.emit("task:moved", { task: createTask(), from: "todo", to: "in-progress" }); + await flushAsync(); + + expect(mockCommentOnIssue).not.toHaveBeenCalled(); + }); + + it("does not repost for legacy tasks whose success is recorded only in the task log", async () => { + service.start(); + store.getTask.mockResolvedValueOnce(createTask({ + log: [{ + timestamp: "2026-07-21T12:00:00.000Z", + action: "Posted GitHub tracking comment", + outcome: "owner/repo#42 (in-progress)", + }], + })); + + store.emit("task:moved", { task: createTask(), from: "todo", to: "in-progress" }); + await flushAsync(); + + expect(mockCommentOnIssue).not.toHaveBeenCalled(); + }); + + it("allows a later in-progress transition to retry after GitHub rejects the first post", async () => { + service.start(); + mockCommentOnIssue.mockRejectedValueOnce(new Error("rate limited")); + + store.emit("task:moved", { task: createTask(), from: "todo", to: "in-progress" }); + await flushAsync(); + store.emit("task:moved", { task: createTask(), from: "todo", to: "in-progress" }); + await flushAsync(); + + expect(mockCommentOnIssue).toHaveBeenCalledTimes(2); + expect(store.updateTask).toHaveBeenCalledTimes(1); + }); + + it("records durable success when the marker write fails after GitHub accepts the comment", async () => { + service.start(); + store.updateTask.mockRejectedValueOnce(new Error("store unavailable")); + + store.emit("task:moved", { task: createTask(), from: "todo", to: "in-progress" }); + await flushAsync(); + + expect(mockCommentOnIssue).toHaveBeenCalledTimes(1); + expect(store.logEntry).toHaveBeenCalledWith( + "FN-1", + "Posted GitHub tracking comment", + "owner/repo#42 (in-progress)", + ); + expect(store.logEntry).not.toHaveBeenCalledWith( + "FN-1", + "Failed to post GitHub tracking comment", + "store unavailable", + ); + + service.stop(); + service = new GitHubTrackingCommentService(store as unknown as TaskStore); + service.start(); + store.getTask.mockResolvedValueOnce(createTask({ + log: [{ + timestamp: "2026-07-21T12:00:00.000Z", + action: "Posted GitHub tracking comment", + outcome: "owner/repo#42 (in-progress)", + }], + })); + + store.emit("task:moved", { task: createTask(), from: "todo", to: "in-progress" }); + await flushAsync(); + + expect(mockCommentOnIssue).toHaveBeenCalledTimes(1); + }); + it("writes success logs", async () => { service.start(); diff --git a/packages/dashboard/src/github-tracking-comments.ts b/packages/dashboard/src/github-tracking-comments.ts index e9522087ba..49dc1d9052 100644 --- a/packages/dashboard/src/github-tracking-comments.ts +++ b/packages/dashboard/src/github-tracking-comments.ts @@ -186,6 +186,7 @@ export function formatTrackingComment( export class GitHubTrackingCommentService { private readonly store: TaskStore; + private readonly inProgressCommentClaims = new Set(); private readonly onTaskMoved = (event: TaskMovedEvent): void => { void this.handleTaskMoved(event); }; @@ -248,25 +249,46 @@ export class GitHubTrackingCommentService { return; } + if (event.to === "in-progress") { + if (this.inProgressCommentClaims.has(event.task.id)) { + return; + } + this.inProgressCommentClaims.add(event.task.id); + } + /* * FNXC:GitHubTrackingComments 2026-07-16-12:40: - * A closed tracked issue must link its landing commit when one exists. The task:moved snapshot - * can predate mergeDetails persistence on human PR, no-op, and recovery done paths, so re-read - * the authoritative row before building the Done comment. Fall back to the snapshot when the - * read fails so the comment is never dropped. + * A closed tracked issue must link its landing commit when one exists, and in-progress comments + * must honor the durable one-per-task marker. Re-read the authoritative row before either + * transition; fall back to the event snapshot when the read fails so the comment is not dropped. */ - const taskForComment = event.to === "done" - ? await this.store.getTask(event.task.id).catch(() => null) ?? event.task - : event.task; + const authoritativeTask = await this.store.getTask(event.task.id).catch(() => null); + const taskForComment = authoritativeTask ?? event.task; + if ( + event.to === "in-progress" + && ( + taskForComment.githubTracking?.inProgressCommentedAt + || taskForComment.log?.some((entry) => ( + entry.action === "Posted GitHub tracking comment" + && entry.outcome?.endsWith("(in-progress)") + )) + ) + ) { + return; + } const body = event.to === "done" ? formatTrackingComment(taskForComment, event.to, { owner, repo }) : formatTrackingComment(taskForComment, event.to); + let commentPosted = false; try { const projectSettings = await this.store.getSettings() as Pick; const globalSettings = (await this.store.getGlobalSettingsStore?.()?.getSettings?.() ?? {}) as Pick; const resolution = resolveGithubTrackingAuth({ projectSettings, globalSettings }); if (!resolution.ok) { + if (event.to === "in-progress") { + this.inProgressCommentClaims.delete(event.task.id); + } await this.safeLogDeletedTaskEntry(event.task.id, "Skipped GitHub tracking comment", resolution.message); return; } @@ -275,12 +297,33 @@ export class GitHubTrackingCommentService { ? new GitHubClient({ token: resolution.auth.token, forceMode: "token" }) : new GitHubClient({ forceMode: "gh-cli" }); await client.commentOnIssue(owner, repo, number, body); + commentPosted = true; + if (event.to === "in-progress") { + try { + await this.store.updateTask(event.task.id, { + githubTracking: { inProgressCommentedAt: new Date().toISOString() }, + }); + } catch (markerError) { + await this.safeLogDeletedTaskEntry( + event.task.id, + "Posted GitHub tracking comment", + `${owner}/${repo}#${number} (${event.to})`, + ); + console.warn( + `[github-tracking-comments] Posted in-progress comment for ${event.task.id}, but failed to persist its marker: ${markerError instanceof Error ? markerError.message : String(markerError)}`, + ); + return; + } + } await this.safeLogDeletedTaskEntry( event.task.id, "Posted GitHub tracking comment", `${owner}/${repo}#${number} (${event.to})`, ); } catch (err) { + if (event.to === "in-progress" && !commentPosted) { + this.inProgressCommentClaims.delete(event.task.id); + } const message = err instanceof Error ? err.message : String(err); await this.safeLogDeletedTaskEntry( event.task.id,