From 6871c510a4fd5718cf9a13a4776e4a5b78b7d8a7 Mon Sep 17 00:00:00 2001 From: Fusion Date: Sun, 10 May 2026 14:48:52 -0700 Subject: [PATCH] feat(FN-3953): enable tracking issue creation on task edit and document the Implements GitHub tracking issue creation on PATCH operations for FN-3953, with accompanying test coverage and documentation updates for edit-time tracking. Fusion-Task-Id: FN-3953 --- docs/architecture.md | 6 +- docs/task-management.md | 5 +- .../github-tracking-documentation.test.ts | 2 + .../src/__tests__/routes-tasks-ops.test.ts | 93 +++++++++++++++++++ .../routes/register-task-workflow-routes.ts | 16 ++++ 5 files changed, 117 insertions(+), 5 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index a86aca152..9f212b9f4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1246,11 +1246,11 @@ Git dashboard routes are registered in `register-git-github.ts`. | POST | `/api/git/commit` | Create a commit from staged changes with a required message. | | POST | `/api/git/discard` | Discard working-tree changes for specified files. | -### GitHub tracking lifecycle (task creation) +### GitHub tracking lifecycle (task creation + existing-task edits) -When a task is created, Fusion only attempts GitHub issue creation if per-task tracking is explicitly enabled (`task.githubTracking.enabled === true`). The lifecycle then resolves the repo in priority order: task override (`repoOverride`) → project `githubTrackingDefaultRepo` → global `githubTrackingDefaultRepo`. If no repo resolves, task creation still succeeds and an activity entry records the skip reason. GitHub API/CLI failures are best-effort only (swallowed with warning), so task creation is never blocked by GitHub availability. +Fusion attempts GitHub issue creation when per-task tracking is explicitly enabled (`task.githubTracking.enabled === true`) and the task is currently unlinked. This runs during task creation flows and during existing-task PATCH updates that include `githubTracking` changes (for example enable/retarget updates). The lifecycle resolves the repo in priority order: task override (`repoOverride`) → project `githubTrackingDefaultRepo` → global `githubTrackingDefaultRepo`. If no repo resolves, task create/update still succeeds and an activity entry records the skip reason. GitHub API/CLI failures are best-effort only (swallowed with warning), so task updates are never blocked by GitHub availability. -When Fusion does create a tracking issue, it formats the title as `[FN-XXXX] Task title` and sends a short plain-text body prefixed with `Fusion task: FN-XXXX`. The body is a bounded summary snippet (not full task prompt content), and Fusion does not include any hyperlink back to the local dashboard. +When Fusion does create a tracking issue, it formats the title as `[FN-XXXX] Task title` and sends a short plain-text body prefixed with `Fusion task: FN-XXXX`. The body is a bounded summary snippet (not full task prompt content), and Fusion does not include any hyperlink back to the local dashboard. Manual unlink requests (`githubTracking.issue: null`) do not recreate an issue in that same PATCH request, and disable updates do not create issues. When a tracked task later moves to `in-progress` or `done`, Fusion posts one short lifecycle comment on the linked tracking issue. These comments include the Fusion task ID as plain text (`Fusion task: FN-XXXX`) and never link back to the Fusion app. No comment is posted for any other transition. diff --git a/docs/task-management.md b/docs/task-management.md index 0e4bd98ae..36ce4cfdb 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -381,7 +381,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. -When task creation runs with tracking enabled, Fusion attempts issue creation during task creation flows (including quick create, planning output, and subtask creation paths that create tasks). Creation is best-effort and non-blocking: task creation still succeeds even if repo resolution fails or GitHub calls fail. +When task creation runs with tracking enabled, Fusion attempts issue creation during task creation flows (including quick create, planning output, and subtask creation paths that create tasks). Fusion also attempts issue creation on existing-task edits that update `githubTracking` (for example enabling tracking or setting a resolvable repo override) when the resulting task is enabled and unlinked. Creation is best-effort and non-blocking: task updates and task creation still succeed even if repo resolution fails or GitHub calls fail. Tracking behavior is controlled per task: @@ -389,7 +389,8 @@ Tracking behavior is controlled per task: - `task.githubTracking.repoOverride` optionally forces a specific target repo (`owner/repo`). - In the dashboard **Task Detail** modal, eligible existing tasks (`triage`, `todo`, `in-progress`, `in-review`) always show GitHub tracking controls so tracking can be enabled, disabled, or retargeted without reopening the task in a creation flow. - Clearing the Task Detail repo override stores `null`, which reverts repo resolution to project/global defaults. -- Explicit task-level enablement is honored even when project/global GitHub tracking defaults are unset. If `enabled: true` and the repo resolves at task scope (for example via `repoOverride`), Fusion still attempts create-time tracking-issue creation. +- Explicit task-level enablement is honored even when project/global GitHub tracking defaults are unset. If `enabled: true` and the repo resolves at task scope (for example via `repoOverride`), Fusion attempts tracking-issue creation on both create-time and eligible edit-time flows. +- Explicit manual unlink (`githubTracking.issue: null`) does not recreate a tracking issue in that same update request, and disabling tracking does not create new issues. Repository resolution order: diff --git a/packages/dashboard/src/__tests__/github-tracking-documentation.test.ts b/packages/dashboard/src/__tests__/github-tracking-documentation.test.ts index fc1e39d0b..7cda71c6d 100644 --- a/packages/dashboard/src/__tests__/github-tracking-documentation.test.ts +++ b/packages/dashboard/src/__tests__/github-tracking-documentation.test.ts @@ -17,6 +17,7 @@ describe("github tracking documentation contract", () => { expect(taskManagement).toContain("## GitHub Tracking Issues"); expect(taskManagement).toContain("They are **not** the same as imported source issues (`issueInfo` / `sourceIssue`)"); expect(taskManagement).toContain("task creation flows (including quick create, planning output, and subtask creation paths that create tasks)"); + expect(taskManagement).toContain("Fusion also attempts issue creation on existing-task edits that update `githubTracking`"); expect(taskManagement).toContain("task.githubTracking.enabled"); expect(taskManagement).toContain("task.githubTracking.repoOverride"); expect(taskManagement).toContain("Repository resolution order"); @@ -24,6 +25,7 @@ describe("github tracking documentation contract", () => { expect(taskManagement).toContain("2. Project default: `githubTrackingDefaultRepo`"); expect(taskManagement).toContain("3. Global default: `githubTrackingDefaultRepo`"); expect(taskManagement).toContain("Creation is best-effort and non-blocking"); + expect(taskManagement).toContain("Explicit manual unlink (`githubTracking.issue: null`) does not recreate a tracking issue in that same update request"); expect(taskManagement).toContain("Title: `[FN-XXXX] Task title`"); expect(taskManagement).toContain("Body prefix: `Fusion task: FN-XXXX`"); }); diff --git a/packages/dashboard/src/__tests__/routes-tasks-ops.test.ts b/packages/dashboard/src/__tests__/routes-tasks-ops.test.ts index 93cc24445..f61e2182c 100644 --- a/packages/dashboard/src/__tests__/routes-tasks-ops.test.ts +++ b/packages/dashboard/src/__tests__/routes-tasks-ops.test.ts @@ -204,6 +204,8 @@ function createMockStore(overrides: Partial = {}): TaskStore { deleteTaskDocument: vi.fn().mockResolvedValue(undefined), updatePrInfo: vi.fn().mockResolvedValue(undefined), updateIssueInfo: vi.fn().mockResolvedValue(undefined), + linkGithubIssue: vi.fn().mockResolvedValue(undefined), + recordActivity: vi.fn().mockResolvedValue(undefined), getRootDir: vi.fn().mockReturnValue("/fake/root"), listWorkflowSteps: vi.fn().mockResolvedValue([]), createWorkflowStep: vi.fn(), @@ -1679,6 +1681,97 @@ describe("PATCH /tasks/:id", () => { }); }); + it("creates and links a tracking issue when enabling tracking on an existing task with resolvable repo", async () => { + const createIssueSpy = vi.spyOn(GitHubClient.prototype, "createIssue").mockResolvedValue({ + owner: "runfusion", + repo: "fusion", + number: 73, + htmlUrl: "https://github.com/runfusion/fusion/issues/73", + createdAt: "2026-01-01T00:00:00.000Z", + }); + (store.getSettings as ReturnType).mockResolvedValue({ githubAuthMode: "token", githubAuthToken: "tok" }); + (store.updateTask as ReturnType).mockResolvedValue({ + ...FAKE_TASK_DETAIL, + id: "KB-001", + githubTracking: { enabled: true, repoOverride: "runfusion/fusion" }, + }); + (store.getTask as ReturnType).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: { + enabled: true, + repoOverride: "runfusion/fusion", + }, + }), { + "Content-Type": "application/json", + }); + + expect(res.status).toBe(200); + expect(createIssueSpy).toHaveBeenCalledWith(expect.objectContaining({ owner: "runfusion", repo: "fusion" })); + expect(store.linkGithubIssue).toHaveBeenCalledWith("KB-001", expect.objectContaining({ owner: "runfusion", repo: "fusion", number: 73 })); + createIssueSpy.mockRestore(); + }); + + it("does not recreate tracking issue during explicit manual unlink patch", async () => { + const createIssueSpy = vi.spyOn(GitHubClient.prototype, "createIssue").mockResolvedValue({ + owner: "runfusion", + repo: "fusion", + number: 99, + htmlUrl: "https://github.com/runfusion/fusion/issues/99", + createdAt: "2026-01-01T00:00:00.000Z", + }); + (store.updateTask as ReturnType).mockResolvedValue({ ...FAKE_TASK_DETAIL }); + + const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({ + githubTracking: { issue: null }, + }), { + "Content-Type": "application/json", + }); + + expect(res.status).toBe(200); + expect(createIssueSpy).not.toHaveBeenCalled(); + expect(store.getTask).not.toHaveBeenCalled(); + createIssueSpy.mockRestore(); + }); + + it("does not create tracking issue when disabling tracking", async () => { + const createIssueSpy = vi.spyOn(GitHubClient.prototype, "createIssue").mockResolvedValue({ + owner: "runfusion", + repo: "fusion", + number: 100, + htmlUrl: "https://github.com/runfusion/fusion/issues/100", + createdAt: "2026-01-01T00:00:00.000Z", + }); + (store.updateTask as ReturnType).mockResolvedValue({ + ...FAKE_TASK_DETAIL, + id: "KB-001", + githubTracking: { enabled: false, repoOverride: "runfusion/fusion" }, + }); + (store.getTask as ReturnType).mockResolvedValue({ + ...FAKE_TASK_DETAIL, + id: "KB-001", + githubTracking: { enabled: false, repoOverride: "runfusion/fusion" }, + }); + + const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({ + githubTracking: { enabled: false }, + }), { + "Content-Type": "application/json", + }); + + expect(res.status).toBe(200); + expect(createIssueSpy).not.toHaveBeenCalled(); + createIssueSpy.mockRestore(); + }); + it("returns 400 for invalid githubTracking repo override format", async () => { const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({ githubTracking: { diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index ceac8de8c..cbbd511d6 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -1804,6 +1804,22 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork } const task = await scopedStore.updateTask(req.params.id, updates); + + const manualUnlinkRequested = + hasBodyField("githubTracking") && + validatedGithubTracking !== null && + typeof validatedGithubTracking === "object" && + validatedGithubTracking.issue === null; + + if (hasBodyField("githubTracking") && !manualUnlinkRequested) { + await maybeCreateTaskTrackingIssue(scopedStore, task, options?.githubToken); + const refreshedTask = await scopedStore.getTask(req.params.id, { + activityLogLimit: TASK_DETAIL_ACTIVITY_LOG_LIMIT, + }); + res.json(trimTaskDetailActivityLog(refreshedTask)); + return; + } + res.json(task); } catch (err: unknown) { if (err instanceof ApiError) {