fix: close Fusion tracking issues created after a task is already done
Late-created GitHub tracking issues never got a task:moved close, and the reconcile sweep only scanned the oldest 200 terminal rows.
This commit is contained in:
7
.changeset/fix-github-tracking-close-after-done.md
Normal file
7
.changeset/fix-github-tracking-close-after-done.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Close Fusion-created GitHub tracking issues when the task is already done.
|
||||
category: fix
|
||||
dev: Late-created tracking issues (opened after the task reached Done) now close immediately, and the reconcile sweep prefers recently updated tracked terminals instead of the oldest 200 board rows.
|
||||
@@ -65,6 +65,35 @@ describe("GitHubTrackingReconciler", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("closes a recently updated tracked done task even when older untracked terminal rows fill the scan window", async () => {
|
||||
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });
|
||||
mockGetIssue.mockResolvedValue({ state: "open" });
|
||||
const olderUntracked = Array.from({ length: RECONCILE_SCAN_LIMIT }, (_, i) => ({
|
||||
id: `FN-old-${String(i + 1).padStart(4, "0")}`,
|
||||
column: "archived",
|
||||
createdAt: `2026-01-01T00:00:00.${String(i).padStart(3, "0")}Z`,
|
||||
updatedAt: `2026-01-01T00:00:00.${String(i).padStart(3, "0")}Z`,
|
||||
}));
|
||||
const store = createStore({
|
||||
listTasks: [
|
||||
...olderUntracked,
|
||||
{
|
||||
id: "FN-9061",
|
||||
column: "done",
|
||||
createdAt: "2026-08-15T04:05:43.525Z",
|
||||
updatedAt: "2026-08-15T04:51:10.315Z",
|
||||
executionCompletedAt: "2026-08-15T04:46:44.428Z",
|
||||
githubTracking: { enabled: true, issue: { owner: "Runfusion", repo: "Fusion", number: 3458 } },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await new GitHubTrackingReconciler().reconcile(store);
|
||||
|
||||
expect(mockSetIssueState).toHaveBeenCalledWith("Runfusion", "Fusion", 3458, "closed", "completed");
|
||||
expect(result.closed).toBe(1);
|
||||
});
|
||||
|
||||
it("closes open issues for done-column tracked tasks", async () => {
|
||||
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });
|
||||
mockGetIssue.mockResolvedValue({ state: "open" });
|
||||
@@ -107,7 +136,7 @@ describe("GitHubTrackingReconciler", () => {
|
||||
const result = await new GitHubTrackingReconciler().reconcile(store);
|
||||
|
||||
expect(result.closed).toBe(0);
|
||||
expect(result.skipped).toBe(3);
|
||||
expect(result.skipped).toBe(1);
|
||||
expect(mockSetIssueState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -116,8 +145,8 @@ describe("GitHubTrackingReconciler", () => {
|
||||
mockGetIssue.mockRejectedValueOnce(new Error("boom"));
|
||||
mockGetIssue.mockResolvedValueOnce({ state: "open" });
|
||||
const store = createStore({ listTasks: [
|
||||
{ id: "FN-1", column: "done", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 1 } } },
|
||||
{ id: "FN-2", column: "done", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 2 } } },
|
||||
{ id: "FN-1", column: "done", updatedAt: "2026-08-15T04:51:00.000Z", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 1 } } },
|
||||
{ id: "FN-2", column: "done", updatedAt: "2026-08-15T04:50:00.000Z", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 2 } } },
|
||||
] });
|
||||
|
||||
const result = await new GitHubTrackingReconciler().reconcile(store);
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { Task } from "@fusion/core";
|
||||
|
||||
const createIssueMock = vi.fn();
|
||||
const searchIssuesMock = vi.fn();
|
||||
const getIssueMock = vi.fn();
|
||||
const setIssueStateMock = vi.fn();
|
||||
const resolveAuthMock = vi.fn();
|
||||
const summarizeTitleMock = vi.fn();
|
||||
|
||||
@@ -19,6 +21,8 @@ vi.mock("../github.js", async (importOriginal) => ({
|
||||
GitHubClient: vi.fn().mockImplementation(function () { return {
|
||||
createIssue: createIssueMock,
|
||||
searchIssues: searchIssuesMock,
|
||||
getIssue: getIssueMock,
|
||||
setIssueState: setIssueStateMock,
|
||||
}; }),
|
||||
}));
|
||||
|
||||
@@ -163,6 +167,8 @@ describe("maybeCreateTrackingIssue", () => {
|
||||
});
|
||||
summarizeTitleMock.mockResolvedValue(null);
|
||||
searchIssuesMock.mockResolvedValue([]);
|
||||
getIssueMock.mockResolvedValue({ state: "open" });
|
||||
setIssueStateMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("returns tracking_disabled when not enabled", async () => {
|
||||
@@ -229,6 +235,49 @@ describe("maybeCreateTrackingIssue", () => {
|
||||
expect(createIssueMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes a newly created tracking issue when the task is already done", async () => {
|
||||
const linkGithubIssue = vi.fn();
|
||||
const recordActivity = vi.fn();
|
||||
const logEntry = vi.fn();
|
||||
|
||||
const result = await maybeCreateTrackingIssue(buildTask({
|
||||
title: "Already finished",
|
||||
column: "done",
|
||||
executionCompletedAt: "2026-08-15T04:34:26.712Z",
|
||||
githubTracking: { enabled: true },
|
||||
}), {
|
||||
taskStore: { linkGithubIssue, recordActivity, logEntry } as any,
|
||||
projectSettings: {},
|
||||
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
|
||||
rootDir,
|
||||
logger: { warn: vi.fn(), info: vi.fn() },
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ created: true });
|
||||
expect(createIssueMock).toHaveBeenCalledTimes(1);
|
||||
expect(setIssueStateMock).toHaveBeenCalledWith("o", "r", 12, "closed", "completed");
|
||||
expect(logEntry).toHaveBeenCalledWith("FN-1", "Closed linked GitHub tracking issue", "o/r#12");
|
||||
});
|
||||
|
||||
it("does not close a tracking issue created for an in-progress task", async () => {
|
||||
const linkGithubIssue = vi.fn();
|
||||
const recordActivity = vi.fn();
|
||||
|
||||
await maybeCreateTrackingIssue(buildTask({
|
||||
title: "Still running",
|
||||
column: "in-progress",
|
||||
githubTracking: { enabled: true },
|
||||
}), {
|
||||
taskStore: { linkGithubIssue, recordActivity } as any,
|
||||
projectSettings: {},
|
||||
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
|
||||
rootDir,
|
||||
logger: { warn: vi.fn(), info: vi.fn() },
|
||||
});
|
||||
|
||||
expect(setIssueStateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates issue, links metadata, and records activity", async () => {
|
||||
const linkGithubIssue = vi.fn();
|
||||
const recordActivity = vi.fn();
|
||||
|
||||
@@ -78,6 +78,17 @@ function isTerminalTask(task: Task, lifecycleByTaskId: LifecycleByTaskId): boole
|
||||
|| task.column === (lifecycle?.archived ?? "archived");
|
||||
}
|
||||
|
||||
function hasLinkedTrackingIssue(task: Task): boolean {
|
||||
const issue = task.githubTracking?.issue;
|
||||
return task.githubTracking?.enabled === true
|
||||
&& Boolean(issue?.owner && issue.repo && issue.number);
|
||||
}
|
||||
|
||||
function compareUpdatedAtDesc(a: Task, b: Task): number {
|
||||
const delta = (b.updatedAt ?? "").localeCompare(a.updatedAt ?? "");
|
||||
return delta !== 0 ? delta : b.id.localeCompare(a.id);
|
||||
}
|
||||
|
||||
/** Is this task in the ARCHIVED lane specifically (used for the FN-5577 done-heuristic)? */
|
||||
function isArchivedTask(task: Task, lifecycleByTaskId: LifecycleByTaskId): boolean {
|
||||
return task.column === (lifecycleByTaskId.get(task.id)?.archived ?? "archived");
|
||||
@@ -125,14 +136,18 @@ export class GitHubTrackingReconciler {
|
||||
async reconcile(store: TaskStore): Promise<{ scanned: number; closed: number; skipped: number; errors: number }> {
|
||||
const listedTasks = await store.listTasks({ slim: true, includeArchived: true });
|
||||
const allTasks = Array.isArray(listedTasks) ? listedTasks : [];
|
||||
const lifecycleByTaskId = await resolveLifecycleByTaskId(store, allTasks, new Map<string, WorkflowIr>(), {
|
||||
match: (task, lifecycle) => task.column === (lifecycle?.complete ?? "done")
|
||||
|| task.column === (lifecycle?.archived ?? "archived"),
|
||||
limit: RECONCILE_SCAN_LIMIT,
|
||||
});
|
||||
const tasks = allTasks
|
||||
.filter((task) => isTerminalTask(task, lifecycleByTaskId))
|
||||
.slice(0, RECONCILE_SCAN_LIMIT);
|
||||
/*
|
||||
FNXC:GithubTracking 2026-08-15-22:27:
|
||||
This board lists thousands of terminal rows oldest-first (`createdAt ASC`). The previous
|
||||
scan took the first 200 terminal cards — almost all untracked archived history — so a
|
||||
recently completed tracked task (FN-9046 / FN-9054 / FN-9061, positions ~7880+) never
|
||||
entered the close pass. Restrict to linked tracking issues, then prefer recently updated
|
||||
rows so late-created issues are closed on the next sweep.
|
||||
*/
|
||||
const tracked = allTasks.filter(hasLinkedTrackingIssue);
|
||||
const newestTracked = [...tracked].sort(compareUpdatedAtDesc).slice(0, RECONCILE_SCAN_LIMIT);
|
||||
const lifecycleByTaskId = await resolveLifecycleByTaskId(store, newestTracked, new Map<string, WorkflowIr>());
|
||||
const tasks = newestTracked.filter((task) => isTerminalTask(task, lifecycleByTaskId));
|
||||
|
||||
const projectSettings = ((await store.getSettings()) ?? {}) as Pick<ProjectSettings, "githubAuthMode" | "githubAuthToken">;
|
||||
const globalSettings = (await store.getGlobalSettingsStore?.()?.getSettings?.() ?? {}) as Pick<GlobalSettings, never>;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import {
|
||||
AiServiceError,
|
||||
MIN_DESCRIPTION_LENGTH,
|
||||
columnsWithFlag,
|
||||
declaresAnyLifecycleTrait,
|
||||
parseRepoSlug,
|
||||
resolveTaskGithubTracking,
|
||||
resolveWorkflowIrForTask,
|
||||
summarizeTitle,
|
||||
type GlobalSettings,
|
||||
type ProjectSettings,
|
||||
@@ -257,6 +260,64 @@ export type MaybeCreateTrackingIssueReason =
|
||||
| "auth_gh_not_authenticated"
|
||||
| "auth_invalid_mode";
|
||||
|
||||
/*
|
||||
FNXC:GithubTracking 2026-08-15-22:27:
|
||||
A tracking issue created AFTER the Fusion task is already done/archived has no later
|
||||
task:moved event, so GitHubTrackingStateService never closes it. Observed on FN-9046 /
|
||||
FN-9054 / FN-9061: executionCompletedAt preceded issue.createdAt by 4–15 minutes, and
|
||||
the issues stayed OPEN. After create or dedup-link, close immediately when the task is
|
||||
already in a complete or archived lane. Failures are logged and never undo the link.
|
||||
*/
|
||||
async function closeTrackingIssueIfTaskAlreadyTerminal(
|
||||
task: Task,
|
||||
store: TaskStore,
|
||||
client: GitHubClient,
|
||||
issue: { owner: string; repo: string; number: number },
|
||||
): Promise<void> {
|
||||
const latest = typeof store.getTask === "function"
|
||||
? ((await store.getTask(task.id).catch(() => task)) ?? task)
|
||||
: task;
|
||||
const ir = await resolveWorkflowIrForTask(store, latest.id).catch(() => undefined);
|
||||
const traitsExpressed = ir !== undefined && declaresAnyLifecycleTrait(ir);
|
||||
const completeLanes = ir === undefined || !traitsExpressed ? ["done"] : columnsWithFlag(ir, "complete");
|
||||
const archivedLanes = ir === undefined || !traitsExpressed ? ["archived"] : columnsWithFlag(ir, "archived");
|
||||
const isComplete = completeLanes.includes(latest.column);
|
||||
const isArchived = archivedLanes.includes(latest.column);
|
||||
if (!isComplete && !isArchived) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await client.getIssue(issue.owner, issue.repo, issue.number);
|
||||
if (!existing || existing.state === "closed") {
|
||||
return;
|
||||
}
|
||||
const stateReason = isArchived && !latest.executionCompletedAt ? "not_planned" : "completed";
|
||||
await client.setIssueState(issue.owner, issue.repo, issue.number, "closed", stateReason);
|
||||
if (typeof store.logEntry === "function") {
|
||||
await store.logEntry(latest.id, "Closed linked GitHub tracking issue", `${issue.owner}/${issue.repo}#${issue.number}`);
|
||||
}
|
||||
if (typeof store.recordActivity === "function") {
|
||||
await store.recordActivity({
|
||||
type: "task:updated",
|
||||
taskId: latest.id,
|
||||
taskTitle: latest.title,
|
||||
details: `Closed linked GitHub tracking issue ${issue.owner}/${issue.repo}#${issue.number} because the task is already ${latest.column}`,
|
||||
metadata: {
|
||||
type: "github-issue-closed-already-terminal",
|
||||
repo: `${issue.owner}/${issue.repo}`,
|
||||
number: issue.number,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (typeof store.logEntry === "function") {
|
||||
await store.logEntry(latest.id, "Failed to close GitHub tracking issue", message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTrackingTitleSummarizerModel(
|
||||
projectSettings: ProjectSettings,
|
||||
globalSettings: GlobalSettings,
|
||||
@@ -517,6 +578,11 @@ export async function maybeCreateTrackingIssue(
|
||||
},
|
||||
});
|
||||
|
||||
await closeTrackingIssueIfTaskAlreadyTerminal(latestTask, deps.taskStore, githubClient, {
|
||||
owner: repo.owner,
|
||||
repo: repo.repo,
|
||||
number: bestMatch.candidate.number,
|
||||
});
|
||||
return { created: false, reason: "existing_issue_found" };
|
||||
}
|
||||
}
|
||||
@@ -551,6 +617,11 @@ export async function maybeCreateTrackingIssue(
|
||||
},
|
||||
});
|
||||
|
||||
await closeTrackingIssueIfTaskAlreadyTerminal(latestTask, deps.taskStore, githubClient, {
|
||||
owner: repo.owner,
|
||||
repo: repo.repo,
|
||||
number: issue.number,
|
||||
});
|
||||
return { created: true, issue };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
Reference in New Issue
Block a user