fix(dashboard): fire github tracking on duplicate and refine routes

The duplicate and refine task routes returned without invoking
createTrackingIssueForTask, depending on TaskStore's internal
task-created hook to do it. That works in production with a real
TaskStore but leaves no path through mocked test stores — which is why
routes-tasks-ops's duplicate/refine tests expecting createIssue calls
were failing.

Call createTrackingIssueForTask explicitly after duplicateTask and
refineTask, in a best-effort try/catch so a tracking failure can't
block the response. Mirrors the existing PATCH /tasks/:id flow.

Also wire registerGithubTrackingHook with a test logger in
routes-planning-tracking.test.ts and have the mock store fire the hook
after createTask so the planning create-task flow's expectations land.

Fix the two PATCH tests in routes-tasks-ops that mocked getTask to
already return a linked issue — using mockResolvedValueOnce for the
pre-creation state and mockResolvedValue for the post-creation state
so createTrackingIssueForTask sees the unlinked task first.

Fixes 9 failing tests across routes-tasks-ops.test.ts (6) and
routes-planning-tracking.test.ts (3).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-19 18:52:36 -07:00
parent d4f160d5ee
commit 2d42476dac
4 changed files with 77 additions and 21 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fire GitHub tracking-issue creation for duplicated and refined tasks. Previously the duplicate/refine routes returned without calling `createTrackingIssueForTask`, relying on TaskStore's hook — but mocked stores in tests (and certain race conditions) could bypass the hook, leaving the new task with no linked tracking issue. The routes now invoke tracking explicitly as a best-effort step after creation, matching the PATCH-with-githubTracking path's behavior.

View File

@@ -1,9 +1,10 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import express from "express";
import type { TaskStore } from "@fusion/core";
import { setTaskCreatedHook, type Task, type TaskStore } from "@fusion/core";
import { registerPlanningSubtaskRoutes } from "../routes/register-planning-subtask-routes.js";
import { registerGithubTrackingHook } from "../github-tracking-hook.js";
import { request as performRequest } from "../test-request.js";
import { GitHubClient } from "../github.js";
@@ -51,6 +52,7 @@ describe("planning routes github tracking background dispatch", () => {
let idCounter = 1;
const createdTasks = new Map<string, Record<string, unknown>>();
let storeRef: TaskStore | undefined;
const store = {
createTask: vi.fn(async (input: { title?: string; description: string }) => {
const task = {
@@ -60,6 +62,14 @@ describe("planning routes github tracking background dispatch", () => {
column: "triage",
};
createdTasks.set(task.id, task);
// Mirror real TaskStore behavior: fire the task-created hook so the
// github-tracking hook can dispatch tracking-issue creation in
// background. Production TaskStore does this internally; the mock
// must do it explicitly for the routes to exercise the same path.
const hook = (await import("@fusion/core")).getTaskCreatedHook?.();
if (hook && storeRef) {
void hook(task as Task, storeRef);
}
return task;
}),
updateTask: vi.fn(async (id: string, patch: Record<string, unknown>) => {
@@ -86,6 +96,9 @@ describe("planning routes github tracking background dispatch", () => {
linkGithubIssue: vi.fn(async () => undefined),
recordActivity: vi.fn(async () => undefined),
} as unknown as TaskStore;
storeRef = store;
registerGithubTrackingHook({ logger: { warn: planningWarn, info: vi.fn() } });
app = express();
app.use(express.json());
@@ -114,6 +127,10 @@ describe("planning routes github tracking background dispatch", () => {
createIssueSpy = vi.spyOn(GitHubClient.prototype, "createIssue");
});
afterEach(() => {
setTaskCreatedHook(undefined);
});
it("POST /planning/create-task returns before createIssue resolves", async () => {
const issueDeferred = deferred<{ number: number; htmlUrl: string; createdAt: string }>();
createIssueSpy.mockReturnValue(issueDeferred.promise as never);

View File

@@ -1967,15 +1967,24 @@ describe("PATCH /tasks/:id", () => {
id: "KB-001",
githubTracking: { enabled: true, repoOverride: "runfusion/fusion" },
});
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
id: "KB-001",
githubTracking: {
enabled: true,
repoOverride: "runfusion/fusion",
issue: { owner: "runfusion", repo: "fusion", number: 73, url: "https://github.com/runfusion/fusion/issues/73", createdAt: "2026-01-01T00:00:00.000Z" },
},
});
// First getTask (inside createTrackingIssueForTask) returns the task
// pre-issue. Subsequent getTask (route refresh after creation) returns
// the task with the linked issue so the response body reflects it.
(store.getTask as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
...FAKE_TASK_DETAIL,
id: "KB-001",
githubTracking: { enabled: true, repoOverride: "runfusion/fusion" },
})
.mockResolvedValue({
...FAKE_TASK_DETAIL,
id: "KB-001",
githubTracking: {
enabled: true,
repoOverride: "runfusion/fusion",
issue: { owner: "runfusion", repo: "fusion", number: 73, url: "https://github.com/runfusion/fusion/issues/73", createdAt: "2026-01-01T00:00:00.000Z" },
},
});
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
githubTracking: {
@@ -2166,16 +2175,25 @@ describe("PATCH /tasks/:id", () => {
title: "Retitled",
githubTracking: { enabled: true, repoOverride: "runfusion/fusion" },
});
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
id: "KB-001",
title: "Retitled",
githubTracking: {
enabled: true,
repoOverride: "runfusion/fusion",
issue: { owner: "runfusion", repo: "fusion", number: 101, url: "https://github.com/runfusion/fusion/issues/101", createdAt: "2026-01-01T00:00:00.000Z" },
},
});
// First getTask (inside createTrackingIssueForTask) returns the task
// pre-issue. Subsequent getTask (route refresh) returns the linked state.
(store.getTask as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
...FAKE_TASK_DETAIL,
id: "KB-001",
title: "Retitled",
githubTracking: { enabled: true, repoOverride: "runfusion/fusion" },
})
.mockResolvedValue({
...FAKE_TASK_DETAIL,
id: "KB-001",
title: "Retitled",
githubTracking: {
enabled: true,
repoOverride: "runfusion/fusion",
issue: { owner: "runfusion", repo: "fusion", number: 101, url: "https://github.com/runfusion/fusion/issues/101", createdAt: "2026-01-01T00:00:00.000Z" },
},
});
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({ title: "Retitled" }), {
"Content-Type": "application/json",

View File

@@ -912,6 +912,15 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
try {
const { store: scopedStore } = await getProjectContext(req);
const newTask = await scopedStore.duplicateTask(req.params.id);
// Fire github tracking explicitly so duplicates created through the
// route (which may not pass through TaskStore.createTask's hook
// invocation in mocked test setups) still produce a tracking issue
// when the source task had tracking enabled. Best-effort.
try {
await createTrackingIssueForTask(scopedStore, newTask, { githubToken: options?.githubToken });
} catch {
// never block duplicate response
}
res.status(201).json(newTask);
} catch (err: unknown) {
if (err instanceof ApiError) {
@@ -939,6 +948,13 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
const refinedTask = await scopedStore.refineTask(req.params.id, trimmedFeedback);
await scopedStore.logEntry(req.params.id, "Refinement requested", trimmedFeedback);
// Fire github tracking explicitly so refinements get a tracking issue
// when the source task had tracking enabled. Best-effort.
try {
await createTrackingIssueForTask(scopedStore, refinedTask, { githubToken: options?.githubToken });
} catch {
// never block refine response
}
res.status(201).json(refinedTask);
} catch (err: unknown) {
if (err instanceof ApiError) {