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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-26 22:26:51 -07:00
parent dbe1e2e39f
commit 8b22dd2ffe
11 changed files with 524 additions and 10 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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). |

View File

@@ -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<string, any>();
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 });
}
});
});

View File

@@ -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 () => {

View File

@@ -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<typeof import("@fusion/core")>();
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<string, unknown>; globalSettings?: Record<string, unknown> } = {}) {
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(),
}));
});
});

View File

@@ -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<typeof vi.fn>).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<typeof vi.fn>).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([

View File

@@ -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 ? "…" : "");

View File

@@ -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);

View File

@@ -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<typeof vi.fn>).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<typeof vi.fn>).mockReturnValueOnce(globalSettingsStore);
const throttledSpy = vi.spyOn(GitHubClient.prototype, "fetchThrottled")
.mockResolvedValueOnce({
success: true,
data: mockGitHubIssue(1, "Tracked Batch Issue"),
} as Awaited<ReturnType<GitHubClient["fetchThrottled"]>>);
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<ReturnType<GitHubClient["fetchThrottled"]>>);
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({

View File

@@ -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