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.
This commit is contained in:
gsxdsm
2026-07-21 16:08:45 -07:00
parent 0e29d9d43c
commit 4eb532f24e
4 changed files with 166 additions and 10 deletions

View File

@@ -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.

View File

@@ -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;
}

View File

@@ -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();

View File

@@ -186,6 +186,7 @@ export function formatTrackingComment(
export class GitHubTrackingCommentService {
private readonly store: TaskStore;
private readonly inProgressCommentClaims = new Set<string>();
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<ProjectSettings, "githubAuthMode" | "githubAuthToken">;
const globalSettings = (await this.store.getGlobalSettingsStore?.()?.getSettings?.() ?? {}) as Pick<GlobalSettings, never>;
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,