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
|
// @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 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 { registerPlanningSubtaskRoutes } from "../routes/register-planning-subtask-routes.js";
|
||||||
|
import { registerGithubTrackingHook } from "../github-tracking-hook.js";
|
||||||
import { request as performRequest } from "../test-request.js";
|
import { request as performRequest } from "../test-request.js";
|
||||||
import { GitHubClient } from "../github.js";
|
import { GitHubClient } from "../github.js";
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ describe("planning routes github tracking background dispatch", () => {
|
|||||||
|
|
||||||
let idCounter = 1;
|
let idCounter = 1;
|
||||||
const createdTasks = new Map<string, Record<string, unknown>>();
|
const createdTasks = new Map<string, Record<string, unknown>>();
|
||||||
|
let storeRef: TaskStore | undefined;
|
||||||
const store = {
|
const store = {
|
||||||
createTask: vi.fn(async (input: { title?: string; description: string }) => {
|
createTask: vi.fn(async (input: { title?: string; description: string }) => {
|
||||||
const task = {
|
const task = {
|
||||||
@@ -60,6 +62,14 @@ describe("planning routes github tracking background dispatch", () => {
|
|||||||
column: "triage",
|
column: "triage",
|
||||||
};
|
};
|
||||||
createdTasks.set(task.id, task);
|
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;
|
return task;
|
||||||
}),
|
}),
|
||||||
updateTask: vi.fn(async (id: string, patch: Record<string, unknown>) => {
|
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),
|
linkGithubIssue: vi.fn(async () => undefined),
|
||||||
recordActivity: vi.fn(async () => undefined),
|
recordActivity: vi.fn(async () => undefined),
|
||||||
} as unknown as TaskStore;
|
} as unknown as TaskStore;
|
||||||
|
storeRef = store;
|
||||||
|
|
||||||
|
registerGithubTrackingHook({ logger: { warn: planningWarn, info: vi.fn() } });
|
||||||
|
|
||||||
app = express();
|
app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
@@ -114,6 +127,10 @@ describe("planning routes github tracking background dispatch", () => {
|
|||||||
createIssueSpy = vi.spyOn(GitHubClient.prototype, "createIssue");
|
createIssueSpy = vi.spyOn(GitHubClient.prototype, "createIssue");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setTaskCreatedHook(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
it("POST /planning/create-task returns before createIssue resolves", async () => {
|
it("POST /planning/create-task returns before createIssue resolves", async () => {
|
||||||
const issueDeferred = deferred<{ number: number; htmlUrl: string; createdAt: string }>();
|
const issueDeferred = deferred<{ number: number; htmlUrl: string; createdAt: string }>();
|
||||||
createIssueSpy.mockReturnValue(issueDeferred.promise as never);
|
createIssueSpy.mockReturnValue(issueDeferred.promise as never);
|
||||||
|
|||||||
@@ -1967,15 +1967,24 @@ describe("PATCH /tasks/:id", () => {
|
|||||||
id: "KB-001",
|
id: "KB-001",
|
||||||
githubTracking: { enabled: true, repoOverride: "runfusion/fusion" },
|
githubTracking: { enabled: true, repoOverride: "runfusion/fusion" },
|
||||||
});
|
});
|
||||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
// First getTask (inside createTrackingIssueForTask) returns the task
|
||||||
...FAKE_TASK_DETAIL,
|
// pre-issue. Subsequent getTask (route refresh after creation) returns
|
||||||
id: "KB-001",
|
// the task with the linked issue so the response body reflects it.
|
||||||
githubTracking: {
|
(store.getTask as ReturnType<typeof vi.fn>)
|
||||||
enabled: true,
|
.mockResolvedValueOnce({
|
||||||
repoOverride: "runfusion/fusion",
|
...FAKE_TASK_DETAIL,
|
||||||
issue: { owner: "runfusion", repo: "fusion", number: 73, url: "https://github.com/runfusion/fusion/issues/73", createdAt: "2026-01-01T00:00:00.000Z" },
|
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({
|
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
|
||||||
githubTracking: {
|
githubTracking: {
|
||||||
@@ -2166,16 +2175,25 @@ describe("PATCH /tasks/:id", () => {
|
|||||||
title: "Retitled",
|
title: "Retitled",
|
||||||
githubTracking: { enabled: true, repoOverride: "runfusion/fusion" },
|
githubTracking: { enabled: true, repoOverride: "runfusion/fusion" },
|
||||||
});
|
});
|
||||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
// First getTask (inside createTrackingIssueForTask) returns the task
|
||||||
...FAKE_TASK_DETAIL,
|
// pre-issue. Subsequent getTask (route refresh) returns the linked state.
|
||||||
id: "KB-001",
|
(store.getTask as ReturnType<typeof vi.fn>)
|
||||||
title: "Retitled",
|
.mockResolvedValueOnce({
|
||||||
githubTracking: {
|
...FAKE_TASK_DETAIL,
|
||||||
enabled: true,
|
id: "KB-001",
|
||||||
repoOverride: "runfusion/fusion",
|
title: "Retitled",
|
||||||
issue: { owner: "runfusion", repo: "fusion", number: 101, url: "https://github.com/runfusion/fusion/issues/101", createdAt: "2026-01-01T00:00:00.000Z" },
|
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" }), {
|
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({ title: "Retitled" }), {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|||||||
@@ -912,6 +912,15 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
try {
|
try {
|
||||||
const { store: scopedStore } = await getProjectContext(req);
|
const { store: scopedStore } = await getProjectContext(req);
|
||||||
const newTask = await scopedStore.duplicateTask(req.params.id);
|
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);
|
res.status(201).json(newTask);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
@@ -939,6 +948,13 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
|
|
||||||
const refinedTask = await scopedStore.refineTask(req.params.id, trimmedFeedback);
|
const refinedTask = await scopedStore.refineTask(req.params.id, trimmedFeedback);
|
||||||
await scopedStore.logEntry(req.params.id, "Refinement requested", 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);
|
res.status(201).json(refinedTask);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
|
|||||||
Reference in New Issue
Block a user