feat(FN-4690): add tracking coverage for register-task-workflow API routes
Adds test coverage for task operation API routes including workflow tracking endpoints, with minor doc updates and a changeset for the `@runfusion/fusion` package. Fusion-Task-Id: FN-4690
This commit is contained in:
committed by
gsxdsm
parent
82273a47bb
commit
f7a7038582
5
.changeset/FN-4690-api-tracking-coverage.md
Normal file
5
.changeset/FN-4690-api-tracking-coverage.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Ensure duplicate/refine API task creation paths also attempt GitHub tracking issue creation as best-effort behavior.
|
||||||
@@ -572,7 +572,7 @@ Manual/non-auto-merge behavior:
|
|||||||
|
|
||||||
GitHub tracking issues are optional issues Fusion can create from Fusion tasks. They are **not** the same as imported source issues (`issueInfo` / `sourceIssue`): imported issues represent an existing GitHub issue that created the task, while tracking issues are new GitHub issues opened to track a Fusion task.
|
GitHub tracking issues are optional issues Fusion can create from Fusion tasks. They are **not** the same as imported source issues (`issueInfo` / `sourceIssue`): imported issues represent an existing GitHub issue that created the task, while tracking issues are new GitHub issues opened to track a Fusion task.
|
||||||
|
|
||||||
When task creation runs with tracking enabled, Fusion attempts issue creation via a **universal post-create hook** that fires for every task-creation path: dashboard HTTP routes, pi extension tools (`fn_task_create`, `fn_task_import_github*`, `fn_delegate_task`), CLI commands (`fn task add`, `fn task duplicate`, `fn task refine`), mission/feature triage, automation `create-task` workflow steps, agent-driven delegation, routine/cron-created tasks, and subtask creation paths. The hook is registered at process startup by the dashboard package and is best-effort and non-blocking: task creation still succeeds even if repo resolution fails or GitHub calls fail. For existing tasks, PATCH first persists any `githubTracking` mutation (enable/disable, repo override, or unlink), then evaluates whether the updated task is **enabled and still unlinked** and should trigger best-effort issue creation (including non-`githubTracking` edits). This keeps retry/create behavior consistent from Task Detail instead of relying on stale pre-patch state.
|
When task creation runs with tracking enabled, Fusion attempts issue creation via a **universal post-create hook** that fires for every task-creation path: dashboard HTTP routes, pi extension tools (`fn_task_create`, `fn_task_import_github*`, `fn_delegate_task`), CLI commands (`fn task add`, `fn task duplicate`, `fn task refine`), mission/feature triage, automation `create-task` workflow steps, agent-driven delegation, routine/cron-created tasks, and subtask creation paths. Dashboard REST API coverage explicitly includes `POST /api/tasks`, `POST /api/tasks/:id/duplicate`, `POST /api/tasks/:id/refine`, and `POST /api/subtasks/create-tasks`. The hook is registered at process startup by the dashboard package and is best-effort and non-blocking: task creation still succeeds even if repo resolution fails or GitHub calls fail. For existing tasks, PATCH first persists any `githubTracking` mutation (enable/disable, repo override, or unlink), then evaluates whether the updated task is **enabled and still unlinked** and should trigger best-effort issue creation (including non-`githubTracking` edits). This keeps retry/create behavior consistent from Task Detail instead of relying on stale pre-patch state.
|
||||||
|
|
||||||
Tracking behavior is controlled per task:
|
Tracking behavior is controlled per task:
|
||||||
|
|
||||||
|
|||||||
@@ -702,8 +702,26 @@ describe("POST /tasks/:id/duplicate", () => {
|
|||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
it("duplicates a task and returns 201 with new task", async () => {
|
it("duplicates a task, returns 201, and attempts tracking issue creation", async () => {
|
||||||
const newTask = { ...FAKE_TASK_DETAIL, id: "FN-002", column: "triage" };
|
const createIssueSpy = vi.spyOn(GitHubClient.prototype, "createIssue").mockResolvedValue({
|
||||||
|
owner: "task",
|
||||||
|
repo: "repo",
|
||||||
|
number: 91,
|
||||||
|
htmlUrl: "https://github.com/task/repo/issues/91",
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
githubTrackingDefaultRepo: "task/repo",
|
||||||
|
githubAuthMode: "token",
|
||||||
|
githubAuthToken: "tok",
|
||||||
|
});
|
||||||
|
|
||||||
|
const newTask = {
|
||||||
|
...FAKE_TASK_DETAIL,
|
||||||
|
id: "FN-002",
|
||||||
|
column: "triage",
|
||||||
|
githubTracking: { enabled: true, repoOverride: "task/repo" },
|
||||||
|
};
|
||||||
(store.duplicateTask as ReturnType<typeof vi.fn>).mockResolvedValue(newTask);
|
(store.duplicateTask as ReturnType<typeof vi.fn>).mockResolvedValue(newTask);
|
||||||
|
|
||||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/duplicate", JSON.stringify({}), {
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/duplicate", JSON.stringify({}), {
|
||||||
@@ -712,8 +730,36 @@ describe("POST /tasks/:id/duplicate", () => {
|
|||||||
|
|
||||||
expect(res.status).toBe(201);
|
expect(res.status).toBe(201);
|
||||||
expect(res.body.id).toBe("FN-002");
|
expect(res.body.id).toBe("FN-002");
|
||||||
expect(res.body.column).toBe("triage");
|
|
||||||
expect(store.duplicateTask).toHaveBeenCalledWith("KB-001");
|
expect(store.duplicateTask).toHaveBeenCalledWith("KB-001");
|
||||||
|
expect(createIssueSpy).toHaveBeenCalledWith(expect.objectContaining({ owner: "task", repo: "repo" }));
|
||||||
|
expect(store.linkGithubIssue).toHaveBeenCalledWith("FN-002", expect.objectContaining({ owner: "task", repo: "repo", number: 91 }));
|
||||||
|
createIssueSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("duplicate remains successful when tracking issue creation fails", async () => {
|
||||||
|
const createIssueSpy = vi.spyOn(GitHubClient.prototype, "createIssue").mockRejectedValue(new Error("boom"));
|
||||||
|
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
githubTrackingDefaultRepo: "task/repo",
|
||||||
|
githubAuthMode: "token",
|
||||||
|
githubAuthToken: "tok",
|
||||||
|
});
|
||||||
|
|
||||||
|
const newTask = {
|
||||||
|
...FAKE_TASK_DETAIL,
|
||||||
|
id: "FN-002",
|
||||||
|
column: "triage",
|
||||||
|
githubTracking: { enabled: true, repoOverride: "task/repo" },
|
||||||
|
};
|
||||||
|
(store.duplicateTask as ReturnType<typeof vi.fn>).mockResolvedValue(newTask);
|
||||||
|
|
||||||
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/duplicate", JSON.stringify({}), {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.id).toBe("FN-002");
|
||||||
|
expect(createIssueSpy).toHaveBeenCalledTimes(1);
|
||||||
|
createIssueSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 404 when source task not found", async () => {
|
it("returns 404 when source task not found", async () => {
|
||||||
@@ -758,8 +804,27 @@ describe("POST /tasks/:id/refine", () => {
|
|||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
it("creates refinement task from done task and returns 201", async () => {
|
it("creates refinement task from done task, returns 201, and attempts tracking issue creation", async () => {
|
||||||
const refinedTask = { ...FAKE_TASK_DETAIL, id: "FN-002", column: "triage", title: "Refinement: KB-001" };
|
const createIssueSpy = vi.spyOn(GitHubClient.prototype, "createIssue").mockResolvedValue({
|
||||||
|
owner: "task",
|
||||||
|
repo: "repo",
|
||||||
|
number: 92,
|
||||||
|
htmlUrl: "https://github.com/task/repo/issues/92",
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
githubTrackingDefaultRepo: "task/repo",
|
||||||
|
githubAuthMode: "token",
|
||||||
|
githubAuthToken: "tok",
|
||||||
|
});
|
||||||
|
|
||||||
|
const refinedTask = {
|
||||||
|
...FAKE_TASK_DETAIL,
|
||||||
|
id: "FN-002",
|
||||||
|
column: "triage",
|
||||||
|
title: "Refinement: KB-001",
|
||||||
|
githubTracking: { enabled: true, repoOverride: "task/repo" },
|
||||||
|
};
|
||||||
(store.refineTask as ReturnType<typeof vi.fn>).mockResolvedValue(refinedTask);
|
(store.refineTask as ReturnType<typeof vi.fn>).mockResolvedValue(refinedTask);
|
||||||
(store.logEntry as ReturnType<typeof vi.fn>).mockResolvedValue(FAKE_TASK_DETAIL);
|
(store.logEntry as ReturnType<typeof vi.fn>).mockResolvedValue(FAKE_TASK_DETAIL);
|
||||||
|
|
||||||
@@ -769,9 +834,39 @@ describe("POST /tasks/:id/refine", () => {
|
|||||||
|
|
||||||
expect(res.status).toBe(201);
|
expect(res.status).toBe(201);
|
||||||
expect(res.body.id).toBe("FN-002");
|
expect(res.body.id).toBe("FN-002");
|
||||||
expect(res.body.column).toBe("triage");
|
|
||||||
expect(store.refineTask).toHaveBeenCalledWith("KB-001", "Need improvements");
|
expect(store.refineTask).toHaveBeenCalledWith("KB-001", "Need improvements");
|
||||||
expect(store.logEntry).toHaveBeenCalledWith("KB-001", "Refinement requested", "Need improvements");
|
expect(store.logEntry).toHaveBeenCalledWith("KB-001", "Refinement requested", "Need improvements");
|
||||||
|
expect(createIssueSpy).toHaveBeenCalledWith(expect.objectContaining({ owner: "task", repo: "repo" }));
|
||||||
|
expect(store.linkGithubIssue).toHaveBeenCalledWith("FN-002", expect.objectContaining({ owner: "task", repo: "repo", number: 92 }));
|
||||||
|
createIssueSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refine remains successful when tracking issue creation fails", async () => {
|
||||||
|
const createIssueSpy = vi.spyOn(GitHubClient.prototype, "createIssue").mockRejectedValue(new Error("boom"));
|
||||||
|
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
githubTrackingDefaultRepo: "task/repo",
|
||||||
|
githubAuthMode: "token",
|
||||||
|
githubAuthToken: "tok",
|
||||||
|
});
|
||||||
|
|
||||||
|
const refinedTask = {
|
||||||
|
...FAKE_TASK_DETAIL,
|
||||||
|
id: "FN-002",
|
||||||
|
column: "triage",
|
||||||
|
title: "Refinement: KB-001",
|
||||||
|
githubTracking: { enabled: true, repoOverride: "task/repo" },
|
||||||
|
};
|
||||||
|
(store.refineTask as ReturnType<typeof vi.fn>).mockResolvedValue(refinedTask);
|
||||||
|
(store.logEntry as ReturnType<typeof vi.fn>).mockResolvedValue(FAKE_TASK_DETAIL);
|
||||||
|
|
||||||
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/refine", JSON.stringify({ feedback: "Need improvements" }), {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.id).toBe("FN-002");
|
||||||
|
expect(createIssueSpy).toHaveBeenCalledTimes(1);
|
||||||
|
createIssueSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates refinement task from in-review task and returns 201", async () => {
|
it("creates refinement task from in-review task and returns 201", async () => {
|
||||||
|
|||||||
@@ -717,6 +717,67 @@ describe("POST /tasks", () => {
|
|||||||
createIssueSpy.mockRestore();
|
createIssueSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
name: "project default when task override is omitted",
|
||||||
|
projectSettings: { githubTrackingEnabledByDefault: true, githubTrackingDefaultRepo: "task/repo", githubAuthMode: "token", githubAuthToken: "tok" },
|
||||||
|
globalSettings: {},
|
||||||
|
expectedCreatesIssue: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "global default when project default is omitted",
|
||||||
|
projectSettings: { githubTrackingDefaultRepo: "task/repo", githubAuthMode: "token", githubAuthToken: "tok" },
|
||||||
|
globalSettings: { githubTrackingEnabledByDefault: true },
|
||||||
|
expectedCreatesIssue: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "disabled when defaults are off at every level",
|
||||||
|
projectSettings: { githubTrackingDefaultRepo: "task/repo", githubAuthMode: "token", githubAuthToken: "tok" },
|
||||||
|
globalSettings: { githubTrackingEnabledByDefault: false },
|
||||||
|
expectedCreatesIssue: false,
|
||||||
|
},
|
||||||
|
])("honors tracking precedence: $name", async ({ projectSettings, globalSettings, expectedCreatesIssue }) => {
|
||||||
|
const createIssueSpy = vi.spyOn(GitHubClient.prototype, "createIssue").mockResolvedValue({
|
||||||
|
owner: "task",
|
||||||
|
repo: "repo",
|
||||||
|
number: 43,
|
||||||
|
htmlUrl: "https://github.com/task/repo/issues/43",
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue(projectSettings);
|
||||||
|
const mockGlobalSettingsStore = {
|
||||||
|
getSettings: vi.fn().mockResolvedValue(globalSettings),
|
||||||
|
updateSettings: vi.fn().mockResolvedValue({}),
|
||||||
|
getSettingsPath: vi.fn().mockReturnValue("/fake/home/.fusion/settings.json"),
|
||||||
|
init: vi.fn().mockResolvedValue(false),
|
||||||
|
invalidateCache: vi.fn(),
|
||||||
|
};
|
||||||
|
(store.getGlobalSettingsStore as ReturnType<typeof vi.fn>).mockReturnValue(mockGlobalSettingsStore);
|
||||||
|
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
...FAKE_TASK_DETAIL,
|
||||||
|
githubTracking: expectedCreatesIssue ? { enabled: true, repoOverride: "task/repo" } : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await REQUEST(
|
||||||
|
buildApp(),
|
||||||
|
"POST",
|
||||||
|
"/api/tasks",
|
||||||
|
JSON.stringify({ description: "Track settings precedence" }),
|
||||||
|
{ "Content-Type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
if (expectedCreatesIssue) {
|
||||||
|
expect(createIssueSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(store.linkGithubIssue).toHaveBeenCalledWith("FN-001", expect.objectContaining({ owner: "task", repo: "repo", number: 43 }));
|
||||||
|
} else {
|
||||||
|
expect(createIssueSpy).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
|
||||||
|
createIssueSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
it("uses store.createTask for local task creation", async () => {
|
it("uses store.createTask for local task creation", async () => {
|
||||||
const createTask = vi.fn().mockResolvedValue({
|
const createTask = vi.fn().mockResolvedValue({
|
||||||
...FAKE_TASK_DETAIL,
|
...FAKE_TASK_DETAIL,
|
||||||
|
|||||||
@@ -605,6 +605,7 @@ 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);
|
||||||
|
await maybeCreateTaskTrackingIssue(scopedStore, newTask, options?.githubToken);
|
||||||
res.status(201).json(newTask);
|
res.status(201).json(newTask);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
@@ -631,6 +632,7 @@ 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 maybeCreateTaskTrackingIssue(scopedStore, refinedTask, options?.githubToken);
|
||||||
await scopedStore.logEntry(req.params.id, "Refinement requested", trimmedFeedback);
|
await scopedStore.logEntry(req.params.id, "Refinement requested", trimmedFeedback);
|
||||||
res.status(201).json(refinedTask);
|
res.status(201).json(refinedTask);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
|||||||
Reference in New Issue
Block a user