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:
5
.changeset/fix-github-tracking-on-duplicate-refine.md
Normal file
5
.changeset/fix-github-tracking-on-duplicate-refine.md
Normal 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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user