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();
|
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 () => {
|
it("closes open issues for done-column tracked tasks", async () => {
|
||||||
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });
|
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });
|
||||||
mockGetIssue.mockResolvedValue({ state: "open" });
|
mockGetIssue.mockResolvedValue({ state: "open" });
|
||||||
@@ -107,7 +136,7 @@ describe("GitHubTrackingReconciler", () => {
|
|||||||
const result = await new GitHubTrackingReconciler().reconcile(store);
|
const result = await new GitHubTrackingReconciler().reconcile(store);
|
||||||
|
|
||||||
expect(result.closed).toBe(0);
|
expect(result.closed).toBe(0);
|
||||||
expect(result.skipped).toBe(3);
|
expect(result.skipped).toBe(1);
|
||||||
expect(mockSetIssueState).not.toHaveBeenCalled();
|
expect(mockSetIssueState).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -116,8 +145,8 @@ describe("GitHubTrackingReconciler", () => {
|
|||||||
mockGetIssue.mockRejectedValueOnce(new Error("boom"));
|
mockGetIssue.mockRejectedValueOnce(new Error("boom"));
|
||||||
mockGetIssue.mockResolvedValueOnce({ state: "open" });
|
mockGetIssue.mockResolvedValueOnce({ state: "open" });
|
||||||
const store = createStore({ listTasks: [
|
const store = createStore({ listTasks: [
|
||||||
{ id: "FN-1", column: "done", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 1 } } },
|
{ 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", githubTracking: { enabled: true, issue: { owner: "o", repo: "r", number: 2 } } },
|
{ 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);
|
const result = await new GitHubTrackingReconciler().reconcile(store);
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type { Task } from "@fusion/core";
|
|||||||
|
|
||||||
const createIssueMock = vi.fn();
|
const createIssueMock = vi.fn();
|
||||||
const searchIssuesMock = vi.fn();
|
const searchIssuesMock = vi.fn();
|
||||||
|
const getIssueMock = vi.fn();
|
||||||
|
const setIssueStateMock = vi.fn();
|
||||||
const resolveAuthMock = vi.fn();
|
const resolveAuthMock = vi.fn();
|
||||||
const summarizeTitleMock = vi.fn();
|
const summarizeTitleMock = vi.fn();
|
||||||
|
|
||||||
@@ -19,6 +21,8 @@ vi.mock("../github.js", async (importOriginal) => ({
|
|||||||
GitHubClient: vi.fn().mockImplementation(function () { return {
|
GitHubClient: vi.fn().mockImplementation(function () { return {
|
||||||
createIssue: createIssueMock,
|
createIssue: createIssueMock,
|
||||||
searchIssues: searchIssuesMock,
|
searchIssues: searchIssuesMock,
|
||||||
|
getIssue: getIssueMock,
|
||||||
|
setIssueState: setIssueStateMock,
|
||||||
}; }),
|
}; }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -163,6 +167,8 @@ describe("maybeCreateTrackingIssue", () => {
|
|||||||
});
|
});
|
||||||
summarizeTitleMock.mockResolvedValue(null);
|
summarizeTitleMock.mockResolvedValue(null);
|
||||||
searchIssuesMock.mockResolvedValue([]);
|
searchIssuesMock.mockResolvedValue([]);
|
||||||
|
getIssueMock.mockResolvedValue({ state: "open" });
|
||||||
|
setIssueStateMock.mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns tracking_disabled when not enabled", async () => {
|
it("returns tracking_disabled when not enabled", async () => {
|
||||||
@@ -229,6 +235,49 @@ describe("maybeCreateTrackingIssue", () => {
|
|||||||
expect(createIssueMock).not.toHaveBeenCalled();
|
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 () => {
|
it("creates issue, links metadata, and records activity", async () => {
|
||||||
const linkGithubIssue = vi.fn();
|
const linkGithubIssue = vi.fn();
|
||||||
const recordActivity = vi.fn();
|
const recordActivity = vi.fn();
|
||||||
|
|||||||
@@ -78,6 +78,17 @@ function isTerminalTask(task: Task, lifecycleByTaskId: LifecycleByTaskId): boole
|
|||||||
|| task.column === (lifecycle?.archived ?? "archived");
|
|| 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)? */
|
/** Is this task in the ARCHIVED lane specifically (used for the FN-5577 done-heuristic)? */
|
||||||
function isArchivedTask(task: Task, lifecycleByTaskId: LifecycleByTaskId): boolean {
|
function isArchivedTask(task: Task, lifecycleByTaskId: LifecycleByTaskId): boolean {
|
||||||
return task.column === (lifecycleByTaskId.get(task.id)?.archived ?? "archived");
|
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 }> {
|
async reconcile(store: TaskStore): Promise<{ scanned: number; closed: number; skipped: number; errors: number }> {
|
||||||
const listedTasks = await store.listTasks({ slim: true, includeArchived: true });
|
const listedTasks = await store.listTasks({ slim: true, includeArchived: true });
|
||||||
const allTasks = Array.isArray(listedTasks) ? listedTasks : [];
|
const allTasks = Array.isArray(listedTasks) ? listedTasks : [];
|
||||||
const lifecycleByTaskId = await resolveLifecycleByTaskId(store, allTasks, new Map<string, WorkflowIr>(), {
|
/*
|
||||||
match: (task, lifecycle) => task.column === (lifecycle?.complete ?? "done")
|
FNXC:GithubTracking 2026-08-15-22:27:
|
||||||
|| task.column === (lifecycle?.archived ?? "archived"),
|
This board lists thousands of terminal rows oldest-first (`createdAt ASC`). The previous
|
||||||
limit: RECONCILE_SCAN_LIMIT,
|
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
|
||||||
const tasks = allTasks
|
entered the close pass. Restrict to linked tracking issues, then prefer recently updated
|
||||||
.filter((task) => isTerminalTask(task, lifecycleByTaskId))
|
rows so late-created issues are closed on the next sweep.
|
||||||
.slice(0, RECONCILE_SCAN_LIMIT);
|
*/
|
||||||
|
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 projectSettings = ((await store.getSettings()) ?? {}) as Pick<ProjectSettings, "githubAuthMode" | "githubAuthToken">;
|
||||||
const globalSettings = (await store.getGlobalSettingsStore?.()?.getSettings?.() ?? {}) as Pick<GlobalSettings, never>;
|
const globalSettings = (await store.getGlobalSettingsStore?.()?.getSettings?.() ?? {}) as Pick<GlobalSettings, never>;
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
AiServiceError,
|
AiServiceError,
|
||||||
MIN_DESCRIPTION_LENGTH,
|
MIN_DESCRIPTION_LENGTH,
|
||||||
|
columnsWithFlag,
|
||||||
|
declaresAnyLifecycleTrait,
|
||||||
parseRepoSlug,
|
parseRepoSlug,
|
||||||
resolveTaskGithubTracking,
|
resolveTaskGithubTracking,
|
||||||
|
resolveWorkflowIrForTask,
|
||||||
summarizeTitle,
|
summarizeTitle,
|
||||||
type GlobalSettings,
|
type GlobalSettings,
|
||||||
type ProjectSettings,
|
type ProjectSettings,
|
||||||
@@ -257,6 +260,64 @@ export type MaybeCreateTrackingIssueReason =
|
|||||||
| "auth_gh_not_authenticated"
|
| "auth_gh_not_authenticated"
|
||||||
| "auth_invalid_mode";
|
| "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(
|
function resolveTrackingTitleSummarizerModel(
|
||||||
projectSettings: ProjectSettings,
|
projectSettings: ProjectSettings,
|
||||||
globalSettings: GlobalSettings,
|
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" };
|
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 };
|
return { created: true, issue };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
|||||||
Reference in New Issue
Block a user