From 8b22dd2ffef829f9c16f23eefb21d0f48c62a021 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 26 Jun 2026 22:26:51 -0700 Subject: [PATCH] FN-7090: link imported GitHub issues as tracked tasks Link GitHub issue imports to existing source issues when tracking defaults are enabled. - Resolve project/global GitHub tracking defaults before CLI, extension, and dashboard issue imports. - Mark imported issue tasks as tracking-enabled so the post-create hook adopts the source issue instead of creating duplicates. - Cover tracked and untracked import behavior across CLI tools, task commands, and dashboard GitHub routes. - Document the import tracking behavior and add a minor changeset for the published CLI. Files changed: .changeset/fn-7090-import-github-tracked.md | 7 ++ docs/cli-reference.md | 2 + docs/settings-reference.md | 2 +- .../__tests__/extension-github-tracking.test.ts | 63 +++++++++++ packages/cli/src/__tests__/extension.test.ts | 125 ++++++++++++++++++++- .../task-command-github-import-tracking.test.ts | 119 ++++++++++++++++++++ packages/cli/src/commands/__tests__/task.test.ts | 64 ++++++++++- packages/cli/src/commands/task.ts | 30 ++++- packages/cli/src/extension.ts | 27 +++++ .../dashboard/src/__tests__/routes-github.test.ts | 74 ++++++++++++ .../dashboard/src/routes/register-git-github.ts | 21 +++- 11 files changed, 524 insertions(+), 10 deletions(-) Fusion-Task-Id: FN-7090 Fusion-Task-Lineage: 1e5510c3-7a98-4ff2-a109-e465ff58b9c2 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7090-import-github-tracked.md | 7 + docs/cli-reference.md | 2 + docs/settings-reference.md | 2 +- .../extension-github-tracking.test.ts | 63 +++++++++ packages/cli/src/__tests__/extension.test.ts | 125 +++++++++++++++++- ...ask-command-github-import-tracking.test.ts | 119 +++++++++++++++++ .../cli/src/commands/__tests__/task.test.ts | 64 ++++++++- packages/cli/src/commands/task.ts | 30 ++++- packages/cli/src/extension.ts | 27 ++++ .../src/__tests__/routes-github.test.ts | 74 +++++++++++ .../src/routes/register-git-github.ts | 21 ++- 11 files changed, 524 insertions(+), 10 deletions(-) create mode 100644 .changeset/fn-7090-import-github-tracked.md create mode 100644 packages/cli/src/__tests__/task-command-github-import-tracking.test.ts diff --git a/.changeset/fn-7090-import-github-tracked.md b/.changeset/fn-7090-import-github-tracked.md new file mode 100644 index 0000000000..0fcbca3008 --- /dev/null +++ b/.changeset/fn-7090-import-github-tracked.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Imported GitHub issues are now linked as tracked tasks when GitHub tracking is on. +category: feature +dev: At import (CLI tools, `fn task import`, dashboard routes) the created task is set `githubTracking.enabled` when `resolveTaskGithubTracking` resolves enabled; the post-create hook adopts the source issue (source_issue_linked) so no duplicate tracking issue is opened. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 4023e81709..e4aa158b25 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -613,6 +613,8 @@ Flags: Default behavior: PR title/body are AI-generated unless both `--title` and `--body` are provided. Use `--no-ai` to suppress AI generation. +`fn task import` creates Fusion tasks from GitHub issues. If project or global GitHub tracking defaults are enabled, imported issue tasks are marked as tracked and the tracking hook links the source issue itself instead of opening a duplicate Fusion tracking issue. + ```bash fn pr create FN-001 fn pr create FN-001 --draft --reviewer octocat --reviewer hubot --base main diff --git a/docs/settings-reference.md b/docs/settings-reference.md index c6e4c89e17..32e0e22def 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -557,7 +557,7 @@ Default notes: | `githubCommentOnDone` | `boolean` | `false` | When enabled, tasks imported from GitHub issues post a completion comment to the source issue when the task moves to `done`. | | `githubCommentTemplate` | `string` | `undefined` | Optional issue comment template used by `githubCommentOnDone`. Supports `{taskId}` and `{taskTitle}` placeholders. If unset, Fusion uses a default completion message. | | `githubCloseSourceIssueOnDone` | `boolean` | `false` | When enabled, source-imported GitHub issues are automatically closed with `state_reason: completed` when the Fusion task moves to `done`. A startup reconciliation sweep also closes missed open source issues on boot. | -| `githubTrackingEnabledByDefault` | `boolean` | `false` | Project-level default for enabling issue tracking on new tasks. When this is false, the Quick Entry GitHub toggle is disabled until tracking is enabled in Settings. | +| `githubTrackingEnabledByDefault` | `boolean` | `false` | Project-level default for enabling issue tracking on new tasks. When this is false, the Quick Entry GitHub toggle is disabled until tracking is enabled in Settings. When this or the global new-task default enables tracking, imported GitHub issues are marked as tracked tasks and adopt the source issue instead of opening a duplicate tracking issue. | | `githubTrackingDefaultRepo` | `string` | `undefined` | Project default issue-tracking repo (`owner/repo`) used before global fallback for tracked task creation (precedence: task override → project default → global default). In Settings UI this is a detected-remote dropdown with a Custom fallback for manual entry. This key is dual-scope: project saves go through `PUT /api/settings` (Settings → General → GitHub Tracking) while global saves go through `PUT /api/settings/global` (Settings → Global General). | | `githubTrackingDedupEnabled` | `boolean` | `true` | When enabled, tracking issue creation searches open and closed repo issues for likely duplicates before opening a new issue (gh CLI search first, with REST search fallback). Set `false` to skip dedup and always create a new issue when tracking is enabled. Dashboard location: **Settings → Project → General → GitHub Tracking**. | | `githubAuthMode` | `"gh-cli" \| "token"` | `"gh-cli"` | Project GitHub auth strategy used by tracking lifecycle integration. `"gh-cli"` requires an installed/authenticated `gh` CLI. `"token"` requires a non-empty `githubAuthToken` (or `GITHUB_TOKEN` env fallback). Tracking lifecycle auth is strict per selected mode (no cross-fallback). | diff --git a/packages/cli/src/__tests__/extension-github-tracking.test.ts b/packages/cli/src/__tests__/extension-github-tracking.test.ts index e76a918d9b..d25d2f4336 100644 --- a/packages/cli/src/__tests__/extension-github-tracking.test.ts +++ b/packages/cli/src/__tests__/extension-github-tracking.test.ts @@ -3,6 +3,7 @@ import { mkdtemp, mkdir, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { TaskStore, setTaskCreatedHook } from "@fusion/core"; +import { runGhJsonAsync } from "@fusion/core/gh-cli"; const hookSpy = vi.hoisted(() => vi.fn(async () => {})); const registerGithubTrackingHookMock = vi.hoisted(() => vi.fn(() => { @@ -19,6 +20,13 @@ vi.mock("@fusion/dashboard", () => ({ registerGithubTrackingHook: registerGithubTrackingHookMock, })); +vi.mock("@fusion/core/gh-cli", () => ({ + isGhAvailable: vi.fn(() => true), + isGhAuthenticated: vi.fn(() => true), + runGhJsonAsync: vi.fn(), + getGhErrorMessage: vi.fn((error: unknown) => (error instanceof Error ? error.message : String(error))), +})); + vi.mock("@fusion/engine", () => ({ createFnAgent: vi.fn(), fetchWebContent: vi.fn(), @@ -97,4 +105,59 @@ describe("extension github tracking hook wiring", () => { await rm(repoRoot, { recursive: true, force: true }); } }); + + it("fn_task_import_github_issue creates a tracked source issue task when tracking defaults are on", async () => { + const repoRoot = await mkdtemp(join(tmpdir(), "fn-7090-extension-gh-import-")); + const cwd = join(repoRoot, ".worktrees", "feature"); + try { + await mkdir(join(repoRoot, ".fusion"), { recursive: true }); + + const extension = await loadExtension(); + const tools = new Map(); + extension({ + registerTool: (def: any) => tools.set(def.name, def), + registerCommand: vi.fn(), + registerShortcut: vi.fn(), + registerFlag: vi.fn(), + on: vi.fn(), + } as any); + + const taskStore = new TaskStore(repoRoot, undefined, { inMemoryDb: false }); + await taskStore.init(); + await taskStore.updateSettings({ githubTrackingEnabledByDefault: true }); + vi.mocked(runGhJsonAsync).mockResolvedValueOnce({ + number: 123, + title: "Imported issue", + body: "Imported issue body", + html_url: "https://github.com/upstream/repo/issues/123", + } as never); + + const result = await tools.get("fn_task_import_github_issue").execute( + "import-1", + { owner: "upstream", repo: "repo", issueNumber: 123 }, + undefined, + undefined, + { cwd }, + ); + + const persisted = await taskStore.getTask(result.details.taskId); + expect(persisted?.githubTracking?.enabled).toBe(true); + expect(persisted?.sourceIssue).toEqual(expect.objectContaining({ + provider: "github", + repository: "upstream/repo", + issueNumber: 123, + })); + expect(hookSpy).toHaveBeenCalledWith( + expect.objectContaining({ + id: result.details.taskId, + githubTracking: { enabled: true }, + sourceIssue: expect.objectContaining({ issueNumber: 123 }), + }), + expect.anything(), + ); + taskStore.close(); + } finally { + await rm(repoRoot, { recursive: true, force: true }); + } + }); }); diff --git a/packages/cli/src/__tests__/extension.test.ts b/packages/cli/src/__tests__/extension.test.ts index 1d28b7e073..7b9ffb6bef 100644 --- a/packages/cli/src/__tests__/extension.test.ts +++ b/packages/cli/src/__tests__/extension.test.ts @@ -100,13 +100,32 @@ async function seedAgent( function linearWorkflowIr(name: string): WorkflowIr { return { - version: "v1", + version: "v2", name, + columns: [{ id: "todo", name: "Todo", traits: [] }], nodes: [ - { id: "start", kind: "start" }, - { id: "lint", kind: "gate", config: { name: "Lint", scriptName: "lint" } }, - { id: "spec", kind: "prompt", config: { name: "Spec", prompt: "check" } }, - { id: "end", kind: "end" }, + { id: "start", kind: "start", column: "todo" }, + { + id: "lint", + kind: "optional-group", + column: "todo", + config: { + name: "Lint", + defaultOn: true, + template: { nodes: [{ id: "lint-step", kind: "gate", config: { name: "Lint", scriptName: "lint" } }], edges: [] }, + }, + }, + { + id: "spec", + kind: "optional-group", + column: "todo", + config: { + name: "Spec", + defaultOn: true, + template: { nodes: [{ id: "spec-step", kind: "prompt", config: { name: "Spec", prompt: "check" } }], edges: [] }, + }, + }, + { id: "end", kind: "end", column: "todo" }, ], edges: [ { from: "start", to: "lint", condition: "success" }, @@ -2412,6 +2431,7 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega const tasks = await store.listTasks({ includeArchived: true }); expect(tasks).toHaveLength(2); const issueOneTask = tasks.find((task) => task.sourceIssue?.issueNumber === 1); + expect(issueOneTask?.githubTracking?.enabled).toBeUndefined(); expect(issueOneTask?.sourceIssue).toEqual({ provider: "github", repository: "acme/demo", @@ -2423,6 +2443,101 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega issueUrl: "https://github.com/acme/demo/issues/1", issueNumber: 1, }); + await store.close(); + }); + + it("fn_task_import_github marks imported issues as tracked when tracking defaults are on", async () => { + const store = new TaskStore(tmpDir); + await store.init(); + await store.updateSettings({ githubTrackingEnabledByDefault: true }); + await store.close(); + + const tool = api.tools.get("fn_task_import_github")!; + vi.mocked(runGhJsonAsync).mockResolvedValueOnce([ + { + number: 7, + title: "Tracked issue", + body: "Tracked issue body", + html_url: "https://github.com/acme/demo/issues/7", + }, + ] as never); + + await tool.execute("gh-tracked-bulk", { ownerRepo: "acme/demo" }, undefined, undefined, makeCtx(tmpDir)); + + const verifyStore = new TaskStore(tmpDir); + await verifyStore.init(); + const tasks = await verifyStore.listTasks({ includeArchived: true }); + const imported = tasks.find((task) => task.sourceIssue?.issueNumber === 7); + expect(imported?.githubTracking?.enabled).toBe(true); + expect(imported?.sourceIssue).toEqual(expect.objectContaining({ + provider: "github", + repository: "acme/demo", + issueNumber: 7, + })); + await verifyStore.close(); + }); + + it("fn_task_import_github_issue leaves imported issues unforced when tracking defaults are off", async () => { + const tool = api.tools.get("fn_task_import_github_issue")!; + vi.mocked(runGhJsonAsync).mockResolvedValueOnce({ + number: 6, + title: "Single untracked issue", + body: "Single issue body", + html_url: "https://github.com/acme/demo/issues/6", + } as never); + + const result = await tool.execute( + "gh-untracked-single", + { owner: "acme", repo: "demo", issueNumber: 6 }, + undefined, + undefined, + makeCtx(tmpDir), + ); + + const verifyStore = new TaskStore(tmpDir); + await verifyStore.init(); + const imported = await verifyStore.getTask(result.details.taskId); + expect(imported?.githubTracking?.enabled).toBeUndefined(); + expect(imported?.sourceIssue).toEqual(expect.objectContaining({ + provider: "github", + repository: "acme/demo", + issueNumber: 6, + })); + await verifyStore.close(); + }); + + it("fn_task_import_github_issue marks imported issues as tracked when tracking defaults are on", async () => { + const store = new TaskStore(tmpDir); + await store.init(); + await store.updateSettings({ githubTrackingEnabledByDefault: true }); + await store.close(); + + const tool = api.tools.get("fn_task_import_github_issue")!; + vi.mocked(runGhJsonAsync).mockResolvedValueOnce({ + number: 8, + title: "Single tracked issue", + body: "Single issue body", + html_url: "https://github.com/acme/demo/issues/8", + } as never); + + const result = await tool.execute( + "gh-tracked-single", + { owner: "acme", repo: "demo", issueNumber: 8 }, + undefined, + undefined, + makeCtx(tmpDir), + ); + + const verifyStore = new TaskStore(tmpDir); + await verifyStore.init(); + const imported = await verifyStore.getTask(result.details.taskId); + expect(imported?.githubTracking?.enabled).toBe(true); + expect(imported?.sourceIssue).toEqual(expect.objectContaining({ + provider: "github", + repository: "acme/demo", + issueNumber: 8, + })); + await verifyStore.close(); }); it("fn_task_import_github skips issues already imported via sourceIssue even when description was edited", async () => { diff --git a/packages/cli/src/__tests__/task-command-github-import-tracking.test.ts b/packages/cli/src/__tests__/task-command-github-import-tracking.test.ts new file mode 100644 index 0000000000..ebf466f2de --- /dev/null +++ b/packages/cli/src/__tests__/task-command-github-import-tracking.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const taskStoreCtorMock = vi.hoisted(() => vi.fn()); +const runGhJsonAsyncMock = vi.hoisted(() => vi.fn()); +const resolveProjectMock = vi.hoisted(() => vi.fn()); + +vi.mock("@fusion/core", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + TaskStore: taskStoreCtorMock, + }; +}); + +vi.mock("@fusion/core/gh-cli", () => ({ + isGhAvailable: vi.fn(() => true), + isGhAuthenticated: vi.fn(() => true), + runGhJsonAsync: runGhJsonAsyncMock, + getGhErrorMessage: vi.fn((error: unknown) => (error instanceof Error ? error.message : String(error))), +})); + +vi.mock("../project-context.js", () => ({ + resolveProject: resolveProjectMock, +})); + +vi.mock("@fusion/dashboard", () => ({ + registerGithubTrackingHook: vi.fn(), +})); + +vi.mock("@fusion/engine", () => ({ + createFnAgent: vi.fn(), + runAiMerge: vi.fn(), + landWorkspaceTask: vi.fn(), +})); + +vi.mock("@fusion/dashboard/planning", () => ({ + createSession: vi.fn(), + submitResponse: vi.fn(), + RateLimitError: class RateLimitError extends Error {}, + SessionNotFoundError: class SessionNotFoundError extends Error {}, + InvalidSessionStateError: class InvalidSessionStateError extends Error {}, +})); + +import { runTaskImportFromGitHub } from "../commands/task.js"; + +describe("fn task import GitHub tracking defaults", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + resolveProjectMock.mockRejectedValue(new Error("No project context")); + runGhJsonAsyncMock.mockResolvedValue([ + { + number: 1, + title: "Imported Issue", + body: "Imported issue body", + html_url: "https://github.com/owner/repo/issues/1", + labels: [], + }, + ]); + }); + + function mockStore(options: { projectSettings?: Record; globalSettings?: Record } = {}) { + const createTask = vi.fn().mockImplementation((input) => Promise.resolve({ + id: "FN-001", + title: input.title, + description: input.description, + column: "triage", + })); + taskStoreCtorMock.mockImplementation(function () { + return { + init: vi.fn().mockResolvedValue(undefined), + listTasks: vi.fn().mockResolvedValue([]), + createTask, + getSettings: vi.fn().mockResolvedValue(options.projectSettings ?? {}), + getGlobalSettingsStore: vi.fn().mockReturnValue({ + getSettings: vi.fn().mockResolvedValue(options.globalSettings ?? {}), + }), + }; + }); + return { createTask }; + } + + it("sets githubTracking.enabled for fn task import when project tracking defaults are on", async () => { + const { createTask } = mockStore({ projectSettings: { githubTrackingEnabledByDefault: true } }); + + await runTaskImportFromGitHub("owner/repo", { limit: 1 }); + + expect(createTask).toHaveBeenCalledWith(expect.objectContaining({ + githubTracking: { enabled: true }, + sourceIssue: expect.objectContaining({ + provider: "github", + repository: "owner/repo", + issueNumber: 1, + }), + })); + }); + + it("sets githubTracking.enabled for fn task import when global tracking defaults are on", async () => { + const { createTask } = mockStore({ globalSettings: { githubTrackingDefaultEnabledForNewTasks: true } }); + + await runTaskImportFromGitHub("owner/repo", { limit: 1 }); + + expect(createTask).toHaveBeenCalledWith(expect.objectContaining({ + githubTracking: { enabled: true }, + sourceIssue: expect.objectContaining({ issueNumber: 1 }), + })); + }); + + it("does not force githubTracking for fn task import when tracking defaults are off", async () => { + const { createTask } = mockStore(); + + await runTaskImportFromGitHub("owner/repo", { limit: 1 }); + + expect(createTask).toHaveBeenCalledWith(expect.not.objectContaining({ + githubTracking: expect.anything(), + })); + }); +}); diff --git a/packages/cli/src/commands/__tests__/task.test.ts b/packages/cli/src/commands/__tests__/task.test.ts index bbaa34bfa6..76a4ee4377 100644 --- a/packages/cli/src/commands/__tests__/task.test.ts +++ b/packages/cli/src/commands/__tests__/task.test.ts @@ -1275,7 +1275,12 @@ describe("project-aware task command behavior", () => { projectPath: "/test", projectName: "demo-project", isRegistered: true, - store: { listTasks, createTask } as unknown as TaskStore, + store: { + listTasks, + createTask, + getSettings: vi.fn().mockResolvedValue({}), + getGlobalSettingsStore: vi.fn().mockReturnValue({ getSettings: vi.fn().mockResolvedValue({}) }), + } as unknown as TaskStore, }); vi.mocked(runGhJsonAsync).mockResolvedValueOnce([ @@ -1304,7 +1309,12 @@ describe("project-aware task command behavior", () => { projectPath: "/test", projectName: "demo-project", isRegistered: true, - store: { listTasks, createTask } as unknown as TaskStore, + store: { + listTasks, + createTask, + getSettings: vi.fn().mockResolvedValue({}), + getGlobalSettingsStore: vi.fn().mockReturnValue({ getSettings: vi.fn().mockResolvedValue({}) }), + } as unknown as TaskStore, }); vi.mocked(runGhJsonAsync).mockResolvedValueOnce([ @@ -1539,6 +1549,8 @@ describe("runTaskImportGitHubInteractive", () => { init: vi.fn(), createTask: mockCreateTask, listTasks: mockListTasks, + getSettings: vi.fn().mockResolvedValue({}), + getGlobalSettingsStore: vi.fn().mockReturnValue({ getSettings: vi.fn().mockResolvedValue({}) }), })); }); @@ -1603,6 +1615,30 @@ describe("runTaskImportGitHubInteractive", () => { }); }); + it("marks interactive imports as tracked when tracking defaults are on", async () => { + (TaskStore as unknown as ReturnType).mockImplementation(() => ({ + init: vi.fn(), + createTask: mockCreateTask, + listTasks: mockListTasks, + getSettings: vi.fn().mockResolvedValue({ githubTrackingEnabledByDefault: true }), + getGlobalSettingsStore: vi.fn().mockReturnValue({ getSettings: vi.fn().mockResolvedValue({}) }), + })); + vi.mocked(runGhJsonAsync).mockResolvedValueOnce([ + mockIssue(1, "Tracked Issue", "Description 1"), + ] as never); + vi.mocked(createInterface).mockReturnValueOnce({ + question: vi.fn().mockResolvedValueOnce("all"), + close: vi.fn(), + } as any); + + await runTaskImportGitHubInteractive("owner/repo"); + + expect(mockCreateTask).toHaveBeenCalledWith(expect.objectContaining({ + githubTracking: { enabled: true }, + sourceIssue: expect.objectContaining({ provider: "github", repository: "owner/repo", issueNumber: 1 }), + })); + }); + it('imports all issues when "all" is selected', async () => { vi.mocked(runGhJsonAsync).mockResolvedValueOnce([ mockIssue(1, "First Issue", "Description 1"), @@ -1873,6 +1909,8 @@ describe("runTaskImportFromGitHub", () => { init: vi.fn(), createTask: mockCreateTask, listTasks: mockListTasks, + getSettings: vi.fn().mockResolvedValue({}), + getGlobalSettingsStore: vi.fn().mockReturnValue({ getSettings: vi.fn().mockResolvedValue({}) }), })); }); @@ -1920,6 +1958,28 @@ describe("runTaskImportFromGitHub", () => { expect(successLine).toBeDefined(); }); + it("marks non-interactive imports as tracked when tracking defaults are on", async () => { + (TaskStore as unknown as ReturnType).mockImplementation(() => ({ + init: vi.fn(), + createTask: mockCreateTask, + listTasks: mockListTasks, + getSettings: vi.fn().mockResolvedValue({}), + getGlobalSettingsStore: vi.fn().mockReturnValue({ + getSettings: vi.fn().mockResolvedValue({ githubTrackingDefaultEnabledForNewTasks: true }), + }), + })); + vi.mocked(runGhJsonAsync).mockResolvedValueOnce([ + mockIssue(1, "Tracked Issue", "Description 1"), + ] as never); + + await runTaskImportFromGitHub("owner/repo"); + + expect(mockCreateTask).toHaveBeenCalledWith(expect.objectContaining({ + githubTracking: { enabled: true }, + sourceIssue: expect.objectContaining({ provider: "github", repository: "owner/repo", issueNumber: 1 }), + })); + }); + it("skips already imported issues", async () => { // Setup existing task with source URL mockListTasks.mockResolvedValueOnce([ diff --git a/packages/cli/src/commands/task.ts b/packages/cli/src/commands/task.ts index 3fca040855..b69c26e582 100644 --- a/packages/cli/src/commands/task.ts +++ b/packages/cli/src/commands/task.ts @@ -1,4 +1,4 @@ -import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, buildAutoPauseClearPatch, buildManualRetryResetPatch, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, isWorkspaceTask, reconcileDeterministicDuplicate, runDeterministicDuplicateGuard, type Settings, type Column, type ColumnId, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch, type TaskDependencyMutation } from "@fusion/core"; +import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, buildAutoPauseClearPatch, buildManualRetryResetPatch, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, isWorkspaceTask, reconcileDeterministicDuplicate, resolveTaskGithubTracking, runDeterministicDuplicateGuard, type Settings, type Column, type ColumnId, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch, type TaskDependencyMutation } from "@fusion/core"; import { runAiMerge, landWorkspaceTask } from "@fusion/engine"; import { createInterface } from "node:readline/promises"; import type { PlanningQuestion, PlanningSummary } from "@fusion/core"; @@ -1293,6 +1293,19 @@ export async function runTaskImportGitHubInteractive( console.log(); + const projectSettings = await store.getSettings(); + const globalSettings = await store.getGlobalSettingsStore().getSettings(); + const resolvedTracking = resolveTaskGithubTracking( + { githubTracking: undefined }, + projectSettings, + globalSettings, + ); + /* + FNXC:GithubImportTracking 2026-06-26-00:00: + CLI issue imports mark created tasks as tracking-enabled only when defaults resolve on. The task-created hook then links the GitHub source issue through source_issue_linked and avoids a duplicate tracking issue. + */ + const importedIssueGithubTracking = resolvedTracking.enabled ? { enabled: true as const } : undefined; + let created = 0; let skipped = 0; @@ -1327,6 +1340,7 @@ export async function runTaskImportGitHubInteractive( sourceType: "github_import", sourceMetadata: source.sourceMetadata, }, + ...(importedIssueGithubTracking ? { githubTracking: importedIssueGithubTracking } : {}), }); const label = task.title || task.description.slice(0, 60) + (task.description.length > 60 ? "…" : ""); @@ -1451,6 +1465,19 @@ export async function runTaskImportFromGitHub( return; } + const projectSettings = await store.getSettings(); + const globalSettings = await store.getGlobalSettingsStore().getSettings(); + const resolvedTracking = resolveTaskGithubTracking( + { githubTracking: undefined }, + projectSettings, + globalSettings, + ); + /* + FNXC:GithubImportTracking 2026-06-26-00:00: + Non-interactive fn task import uses the same tracking default resolution as the extension tools so imported source issues are adopted instead of duplicated when tracking is on. + */ + const importedIssueGithubTracking = resolvedTracking.enabled ? { enabled: true as const } : undefined; + let created = 0; let skipped = 0; @@ -1483,6 +1510,7 @@ export async function runTaskImportFromGitHub( sourceType: "github_import", sourceMetadata: source.sourceMetadata, }, + ...(importedIssueGithubTracking ? { githubTracking: importedIssueGithubTracking } : {}), }); const label = task.title || task.description.slice(0, 60) + (task.description.length > 60 ? "…" : ""); diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index 3ee502c512..9a15481f5d 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -1421,6 +1421,18 @@ export default function kbExtension(pi: ExtensionAPI) { const store = await getStore(ctx.cwd); const existingTasks = await store.listTasks({ slim: false }); + const projectSettings = await store.getSettings(); + const globalSettings = await store.getGlobalSettingsStore().getSettings(); + const resolvedTracking = resolveTaskGithubTracking( + { githubTracking: undefined }, + projectSettings, + globalSettings, + ); + /* + FNXC:GithubImportTracking 2026-06-26-00:00: + Imported GitHub issues must become tracked tasks when tracking/linking defaults resolve on. Setting only enabled lets the post-create hook adopt the imported source issue via source_issue_linked instead of opening a duplicate Fusion tracking issue. + */ + const importedIssueGithubTracking = resolvedTracking.enabled ? { enabled: true as const } : undefined; const createdTasks: Array<{ id: string; title: string }> = []; for (const issue of issues) { @@ -1445,6 +1457,7 @@ export default function kbExtension(pi: ExtensionAPI) { sourceType: "github_import", sourceMetadata: source.sourceMetadata, }, + ...(importedIssueGithubTracking ? { githubTracking: importedIssueGithubTracking } : {}), }); await store.logEntry(task.id, "Imported from GitHub", sourceUrl); @@ -1526,6 +1539,19 @@ export default function kbExtension(pi: ExtensionAPI) { const body = issue.body?.trim() || "(no description)"; const description = `${body}\n\nSource: ${sourceUrl}`; + const projectSettings = await store.getSettings(); + const globalSettings = await store.getGlobalSettingsStore().getSettings(); + const resolvedTracking = resolveTaskGithubTracking( + { githubTracking: undefined }, + projectSettings, + globalSettings, + ); + /* + FNXC:GithubImportTracking 2026-06-26-00:00: + Single-issue imports follow the same source-issue adoption contract as bulk imports: mark tracking enabled only when defaults resolve on, then let the post-create hook link the source issue without creating a duplicate. + */ + const importedIssueGithubTracking = resolvedTracking.enabled ? { enabled: true as const } : undefined; + const source = buildGitHubIssueSource(owner, repo, issue); const task = await store.createTask({ title: title || undefined, @@ -1537,6 +1563,7 @@ export default function kbExtension(pi: ExtensionAPI) { sourceType: "github_import", sourceMetadata: source.sourceMetadata, }, + ...(importedIssueGithubTracking ? { githubTracking: importedIssueGithubTracking } : {}), }); await store.logEntry(task.id, "Imported from GitHub", sourceUrl); diff --git a/packages/dashboard/src/__tests__/routes-github.test.ts b/packages/dashboard/src/__tests__/routes-github.test.ts index b288806ef3..9efa172627 100644 --- a/packages/dashboard/src/__tests__/routes-github.test.ts +++ b/packages/dashboard/src/__tests__/routes-github.test.ts @@ -626,6 +626,34 @@ describe("POST /github/issues/import", () => { }); }); + it("marks a single imported issue as tracked when tracking defaults are on", async () => { + (store.getSettings as ReturnType).mockResolvedValueOnce({ githubTrackingEnabledByDefault: true }); + getIssueSpy.mockResolvedValueOnce(mockGitHubIssue); + + const res = await REQUEST(buildApp(), "POST", "/api/github/issues/import", JSON.stringify({ owner: "owner", repo: "repo", issueNumber: 1 }), { + "Content-Type": "application/json", + }); + + expect(res.status).toBe(201); + expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({ + githubTracking: { enabled: true }, + sourceIssue: expect.objectContaining({ provider: "github", repository: "owner/repo", issueNumber: 1 }), + })); + }); + + it("leaves a single imported issue unforced when tracking defaults are off", async () => { + getIssueSpy.mockResolvedValueOnce(mockGitHubIssue); + + const res = await REQUEST(buildApp(), "POST", "/api/github/issues/import", JSON.stringify({ owner: "owner", repo: "repo", issueNumber: 1 }), { + "Content-Type": "application/json", + }); + + expect(res.status).toBe(201); + expect(store.createTask).toHaveBeenCalledWith(expect.not.objectContaining({ + githubTracking: expect.anything(), + })); + }); + it("logs the import action", async () => { getIssueSpy.mockResolvedValueOnce(mockGitHubIssue); @@ -864,6 +892,52 @@ describe("POST /github/issues/batch-import", () => { })); }); + it("marks batch imported issues as tracked when global tracking defaults are on", async () => { + const globalSettingsStore = { getSettings: vi.fn().mockResolvedValue({ githubTrackingDefaultEnabledForNewTasks: true }) }; + (store.getGlobalSettingsStore as ReturnType).mockReturnValueOnce(globalSettingsStore); + const throttledSpy = vi.spyOn(GitHubClient.prototype, "fetchThrottled") + .mockResolvedValueOnce({ + success: true, + data: mockGitHubIssue(1, "Tracked Batch Issue"), + } as Awaited>); + + const res = await REQUEST( + buildApp(), + "POST", + "/api/github/issues/batch-import", + JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 1 }), + { "Content-Type": "application/json" } + ); + + expect(res.status).toBe(200); + expect(throttledSpy).toHaveBeenCalledTimes(1); + expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({ + githubTracking: { enabled: true }, + sourceIssue: expect.objectContaining({ provider: "github", repository: "owner/repo", issueNumber: 1 }), + })); + }); + + it("leaves batch imported issues unforced when tracking defaults are off", async () => { + vi.spyOn(GitHubClient.prototype, "fetchThrottled") + .mockResolvedValueOnce({ + success: true, + data: mockGitHubIssue(1, "Untracked Batch Issue"), + } as Awaited>); + + const res = await REQUEST( + buildApp(), + "POST", + "/api/github/issues/batch-import", + JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 1 }), + { "Content-Type": "application/json" } + ); + + expect(res.status).toBe(200); + expect(store.createTask).toHaveBeenCalledWith(expect.not.objectContaining({ + githubTracking: expect.anything(), + })); + }); + it("skips already-imported issues", async () => { // Mock issue 1 fetch fetchSpy.mockResolvedValue({ diff --git a/packages/dashboard/src/routes/register-git-github.ts b/packages/dashboard/src/routes/register-git-github.ts index bb332f7e76..7116990c56 100644 --- a/packages/dashboard/src/routes/register-git-github.ts +++ b/packages/dashboard/src/routes/register-git-github.ts @@ -17,7 +17,7 @@ import type { Task, TaskStore, } from "@fusion/core"; -import { classifyGhError, getCurrentRepo, isGhAuthenticated, loadWorkspaceConfig } from "@fusion/core"; +import { classifyGhError, getCurrentRepo, isGhAuthenticated, loadWorkspaceConfig, resolveTaskGithubTracking } from "@fusion/core"; import { dropAutostashHandle, generateSyntheticRunId, @@ -2133,6 +2133,21 @@ function isIssueAlreadyImported( && sourceIssue.issueNumber === issueNumber); } +async function resolveImportedIssueGithubTracking(store: TaskStore): Promise<{ enabled: true } | undefined> { + const projectSettings = await store.getSettings(); + const globalSettings = await store.getGlobalSettingsStore().getSettings(); + const resolvedTracking = resolveTaskGithubTracking( + { githubTracking: undefined }, + projectSettings, + globalSettings, + ); + /* + FNXC:GithubImportTracking 2026-06-26-00:00: + Dashboard GitHub issue imports must mark tasks tracking-enabled only when project/global defaults resolve on. The post-create hook uses the GitHub sourceIssue to link source_issue_linked and prevents duplicate Fusion-created tracking issues. + */ + return resolvedTracking.enabled ? { enabled: true } : undefined; +} + export function getDefaultGitHubRepo(store: TaskStore): { owner: string; repo: string } | null { const envRepo = process.env.GITHUB_REPOSITORY; if (envRepo) { @@ -3991,6 +4006,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void { const body = issue.body?.trim() || "(no description)"; const description = `${body}\n\nSource: ${sourceUrl}`; + const importedIssueGithubTracking = await resolveImportedIssueGithubTracking(scopedStore); const source = buildGitHubIssueSource(owner, repo, issue); const task = await scopedStore.createTask({ title: title || undefined, @@ -4002,6 +4018,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void { sourceType: "github_import", sourceMetadata: source.sourceMetadata, }, + ...(importedIssueGithubTracking ? { githubTracking: importedIssueGithubTracking } : {}), }); // Log the import action @@ -4062,6 +4079,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void { // Get existing tasks to check for duplicates const existingTasks = await scopedStore.listTasks({ slim: false, includeArchived: false }); + const importedIssueGithubTracking = await resolveImportedIssueGithubTracking(scopedStore); // Process issues sequentially with throttling const results: Array<{ @@ -4137,6 +4155,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void { sourceType: "github_import", sourceMetadata: source.sourceMetadata, }, + ...(importedIssueGithubTracking ? { githubTracking: importedIssueGithubTracking } : {}), }); // Log the import action