From dfb6e5270de2fb84844b0680691e23c9883f7227 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 2 Jul 2026 14:31:16 -0700 Subject: [PATCH] FN-7443: add bundled Linear import plugin Add a plugin-owned Linear importer that creates Fusion tasks from Linear issues. - Add the fusion-plugin-linear-import package with settings, Linear GraphQL client, import routes, tools, and dashboard UI. - Bundle and register the Linear import plugin in the CLI and dashboard plugin view registry. - Document bundled plugin authoring details and cover duplicate detection, routes, tools, UI, and packaging with tests. Files changed: .changeset/fn-7443-linear-import-plugin.md | 7 + docs/PLUGIN_AUTHORING.md | 7 + docs/task-management.md | 2 + packages/cli/src/__tests__/bundle-output.test.ts | 22 ++ .../__tests__/bundled-plugin-install.test.ts | 29 +++ packages/cli/src/plugins/bundled-plugin-install.ts | 1 + .../cli/src/plugins/staged-bundled-plugin-ids.ts | 1 + packages/cli/tsup.config.ts | 8 + .../__tests__/registerBundledPluginViews.test.tsx | 9 +- .../app/plugins/registerBundledPluginViews.ts | 18 ++ .../app/types/plugin-dashboard-views.d.ts | 9 + .../src/__tests__/routes-plugin-registry.test.ts | 5 + .../runtime-plugin-alias-regression.test.ts | 12 + packages/dashboard/src/registry-manifest.json | 10 + packages/dashboard/vite.config.ts | 8 + packages/dashboard/vitest.config.ts | 8 + plugins/fusion-plugin-linear-import/README.md | 75 ++++++ plugins/fusion-plugin-linear-import/manifest.json | 48 ++++ plugins/fusion-plugin-linear-import/package.json | 38 +++ .../scripts/copy-css.mjs | 11 + .../src/LinearImportView.css | 167 +++++++++++++ .../src/LinearImportView.tsx | 263 +++++++++++++++++++++ .../src/__tests__/LinearImportView.test.tsx | 124 ++++++++++ .../src/__tests__/import-linear.test.ts | 78 ++++++ .../src/__tests__/linear-client.test.ts | 81 +++++++ .../src/__tests__/routes.test.ts | 90 +++++++ .../src/__tests__/tools.test.ts | 79 +++++++ .../src/dashboard-interop.d.ts | 13 + .../src/dashboard-view.tsx | 10 + .../src/import-linear.ts | 154 ++++++++++++ plugins/fusion-plugin-linear-import/src/index.ts | 45 ++++ .../src/linear-client.ts | 247 +++++++++++++++++++ plugins/fusion-plugin-linear-import/src/routes.ts | 149 ++++++++++++ .../fusion-plugin-linear-import/src/settings.ts | 68 ++++++ plugins/fusion-plugin-linear-import/src/tools.ts | 138 +++++++++++ plugins/fusion-plugin-linear-import/tsconfig.json | 14 ++ .../fusion-plugin-linear-import/vitest.config.ts | 40 ++++ pnpm-lock.yaml | 40 ++++ pnpm-workspace.yaml | 1 + 39 files changed, 2128 insertions(+), 1 deletion(-) Fusion-Task-Id: FN-7443 Fusion-Task-Lineage: a016a9d4-84a4-4a0b-b9b6-b9a2886da49a Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7443-linear-import-plugin.md | 7 + docs/PLUGIN_AUTHORING.md | 7 + docs/task-management.md | 2 + .../cli/src/__tests__/bundle-output.test.ts | 22 ++ .../__tests__/bundled-plugin-install.test.ts | 29 ++ .../cli/src/plugins/bundled-plugin-install.ts | 1 + .../src/plugins/staged-bundled-plugin-ids.ts | 1 + packages/cli/tsup.config.ts | 8 + .../registerBundledPluginViews.test.tsx | 9 +- .../app/plugins/registerBundledPluginViews.ts | 18 ++ .../app/types/plugin-dashboard-views.d.ts | 9 + .../__tests__/routes-plugin-registry.test.ts | 5 + .../runtime-plugin-alias-regression.test.ts | 12 + packages/dashboard/src/registry-manifest.json | 10 + packages/dashboard/vite.config.ts | 8 + packages/dashboard/vitest.config.ts | 8 + plugins/fusion-plugin-linear-import/README.md | 75 +++++ .../fusion-plugin-linear-import/manifest.json | 48 ++++ .../fusion-plugin-linear-import/package.json | 38 +++ .../scripts/copy-css.mjs | 11 + .../src/LinearImportView.css | 167 +++++++++++ .../src/LinearImportView.tsx | 263 ++++++++++++++++++ .../src/__tests__/LinearImportView.test.tsx | 124 +++++++++ .../src/__tests__/import-linear.test.ts | 78 ++++++ .../src/__tests__/linear-client.test.ts | 81 ++++++ .../src/__tests__/routes.test.ts | 90 ++++++ .../src/__tests__/tools.test.ts | 79 ++++++ .../src/dashboard-interop.d.ts | 13 + .../src/dashboard-view.tsx | 10 + .../src/import-linear.ts | 154 ++++++++++ .../fusion-plugin-linear-import/src/index.ts | 45 +++ .../src/linear-client.ts | 247 ++++++++++++++++ .../fusion-plugin-linear-import/src/routes.ts | 149 ++++++++++ .../src/settings.ts | 68 +++++ .../fusion-plugin-linear-import/src/tools.ts | 138 +++++++++ .../fusion-plugin-linear-import/tsconfig.json | 14 + .../vitest.config.ts | 40 +++ pnpm-lock.yaml | 40 +++ pnpm-workspace.yaml | 1 + 39 files changed, 2128 insertions(+), 1 deletion(-) create mode 100644 .changeset/fn-7443-linear-import-plugin.md create mode 100644 plugins/fusion-plugin-linear-import/README.md create mode 100644 plugins/fusion-plugin-linear-import/manifest.json create mode 100644 plugins/fusion-plugin-linear-import/package.json create mode 100644 plugins/fusion-plugin-linear-import/scripts/copy-css.mjs create mode 100644 plugins/fusion-plugin-linear-import/src/LinearImportView.css create mode 100644 plugins/fusion-plugin-linear-import/src/LinearImportView.tsx create mode 100644 plugins/fusion-plugin-linear-import/src/__tests__/LinearImportView.test.tsx create mode 100644 plugins/fusion-plugin-linear-import/src/__tests__/import-linear.test.ts create mode 100644 plugins/fusion-plugin-linear-import/src/__tests__/linear-client.test.ts create mode 100644 plugins/fusion-plugin-linear-import/src/__tests__/routes.test.ts create mode 100644 plugins/fusion-plugin-linear-import/src/__tests__/tools.test.ts create mode 100644 plugins/fusion-plugin-linear-import/src/dashboard-interop.d.ts create mode 100644 plugins/fusion-plugin-linear-import/src/dashboard-view.tsx create mode 100644 plugins/fusion-plugin-linear-import/src/import-linear.ts create mode 100644 plugins/fusion-plugin-linear-import/src/index.ts create mode 100644 plugins/fusion-plugin-linear-import/src/linear-client.ts create mode 100644 plugins/fusion-plugin-linear-import/src/routes.ts create mode 100644 plugins/fusion-plugin-linear-import/src/settings.ts create mode 100644 plugins/fusion-plugin-linear-import/src/tools.ts create mode 100644 plugins/fusion-plugin-linear-import/tsconfig.json create mode 100644 plugins/fusion-plugin-linear-import/vitest.config.ts diff --git a/.changeset/fn-7443-linear-import-plugin.md b/.changeset/fn-7443-linear-import-plugin.md new file mode 100644 index 0000000000..07795470ab --- /dev/null +++ b/.changeset/fn-7443-linear-import-plugin.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add a bundled Linear import plugin for creating tasks from Linear issues. +category: feature +dev: Ships fusion-plugin-linear-import with plugin settings, routes, tools, dashboard view, and bundled-plugin packaging. diff --git a/docs/PLUGIN_AUTHORING.md b/docs/PLUGIN_AUTHORING.md index f243c1865b..a802ad9e85 100644 --- a/docs/PLUGIN_AUTHORING.md +++ b/docs/PLUGIN_AUTHORING.md @@ -1309,6 +1309,13 @@ Standalone roadmap planning plugin extracted from dashboard host code. - Demonstrates: top-level navigation registration through `dashboardViews` (`viewId: "roadmaps"`) and host static view registration - Demonstrates: AI suggestion flows that consume `ctx.createAiSession` through plugin route handlers +### [Linear Import Plugin](../../plugins/fusion-plugin-linear-import/) + +Bundled integration plugin that imports Linear issues into Fusion tasks without adding host-owned Linear routes or settings. + +- Demonstrates: password plugin settings, plugin-scoped HTTP routes under `/api/plugins/fusion-plugin-linear-import/*`, agent tools, top-level `dashboardViews`, host static dashboard view registration, and task creation through `PluginContext.taskStore`. +- Demonstrates: external SaaS API evidence in plugin docs, bounded GraphQL pagination, duplicate detection with durable source provenance, and safe error responses that do not leak API keys. + ### [Droid Runtime Plugin](../../plugins/fusion-plugin-droid-runtime/) Reference runtime plugin that migrates a CLI-backed provider into the plugin system. diff --git a/docs/task-management.md b/docs/task-management.md index 970d0ef869..9b6413e1fe 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -680,6 +680,8 @@ GitLab imports are HTTP API only and do not require or invoke `glab`. Operators Duplicate detection checks existing non-archived task provenance and source URLs before creating another GitLab-imported task. GitLab linked tracking display, comments/notes, auto-close/reopen, Command Center signals/analytics, research/search support, and any GitLab-star prompt remain out of scope until the later GitLab parity tasks mapped in [GitLab Parity Inventory](./gitlab-parity-inventory.md). +Linear issue import is available through the bundled **Linear Import** plugin, not the core GitHub/GitLab Import Tasks implementation. Operators enable the plugin from Plugin Manager, configure the plugin-owned Linear API key, then use the plugin dashboard view or plugin tools to browse and import issues. Imported Linear tasks are created in `triage`, include the Linear body (or `(no description)`) plus `Source: `, and persist `sourceIssue.provider: "linear"` plus `source.sourceMetadata.provider: "linear"` with stable issue id, identifier, URL, team, state, assignee, and timestamps where available. Duplicate detection checks existing non-archived Linear provenance by issue id, identifier, and source URL before task creation and reports the existing task id when a duplicate is found. + Import issues: - GitHub-imported tasks retain typed source issue metadata (`sourceIssue.provider/repository/externalIssueId/issueNumber/url`), which executor and merger flows use to include `Ref: owner/repo#N` in commit bodies. diff --git a/packages/cli/src/__tests__/bundle-output.test.ts b/packages/cli/src/__tests__/bundle-output.test.ts index b0ee34f08b..092d810458 100644 --- a/packages/cli/src/__tests__/bundle-output.test.ts +++ b/packages/cli/src/__tests__/bundle-output.test.ts @@ -200,6 +200,28 @@ describe("CLI bundle output", () => { expect(stagedPkg.exports?.["."]?.import).toBe("./bundled.js"); }); + it("dist/plugins/fusion-plugin-linear-import/ is staged as bundled runtime output", () => { + const stagedRoot = join(cliRoot, "dist", "plugins", "fusion-plugin-linear-import"); + const manifestPath = join(stagedRoot, "manifest.json"); + const packageJsonPath = join(stagedRoot, "package.json"); + + expect(existsSync(manifestPath)).toBe(true); + const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as { id?: string; name?: string; dashboardViews?: unknown[] }; + expect(manifest.id).toBe("fusion-plugin-linear-import"); + expect(typeof manifest.name).toBe("string"); + expect(manifest.dashboardViews?.[0]).toMatchObject({ viewId: "linear-import" }); + + expect(existsSync(join(stagedRoot, "bundled.js"))).toBe(true); + expect(existsSync(join(stagedRoot, "src"))).toBe(false); + + const stagedPkg = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as { + exports?: { "."?: { import?: string } }; + dependencies?: Record; + }; + expect(stagedPkg.exports?.["."]?.import).toBe("./bundled.js"); + expect(stagedPkg.dependencies?.["@fusion/core"]).toBeUndefined(); + }); + it("dist/plugins/fusion-plugin-whatsapp-chat/ is staged with a valid manifest", () => { const stagedRoot = join(cliRoot, "dist", "plugins", "fusion-plugin-whatsapp-chat"); const manifestPath = join(stagedRoot, "manifest.json"); diff --git a/packages/cli/src/plugins/__tests__/bundled-plugin-install.test.ts b/packages/cli/src/plugins/__tests__/bundled-plugin-install.test.ts index a597259559..1c00b173f0 100644 --- a/packages/cli/src/plugins/__tests__/bundled-plugin-install.test.ts +++ b/packages/cli/src/plugins/__tests__/bundled-plugin-install.test.ts @@ -50,6 +50,7 @@ const ROADMAP_PLUGIN_ID = "fusion-plugin-roadmap"; const REPORTS_PLUGIN_ID = "fusion-plugin-reports"; const CLI_PRINTING_PRESS_PLUGIN_ID = "fusion-plugin-cli-printing-press"; const COMPOUND_ENGINEERING_PLUGIN_ID = "fusion-plugin-compound-engineering"; +const LINEAR_IMPORT_PLUGIN_ID = "fusion-plugin-linear-import"; function makeManifest(overrides?: Partial<{ id: string; version: string; name: string }>) { return { @@ -346,6 +347,10 @@ describe("ensureBundledDependencyGraphPluginInstalled", () => { it("includes compound engineering plugin in bundled plugin ids", () => { expect(BUNDLED_PLUGIN_IDS).toContain(COMPOUND_ENGINEERING_PLUGIN_ID); }); + + it("includes Linear import plugin in bundled plugin ids", () => { + expect(BUNDLED_PLUGIN_IDS).toContain(LINEAR_IMPORT_PLUGIN_ID); + }); it("fresh install: registers and loads the plugin when not in DB", async () => { setupBundleExists(); const store = makePluginStore(); @@ -558,6 +563,30 @@ describe("ensureBundledDependencyGraphPluginInstalled", () => { ); }); + it("registers Linear import plugin via generic bundled installer", async () => { + const manifest = makeManifest({ id: LINEAR_IMPORT_PLUGIN_ID, name: "Linear Import" }); + mockExistsSync.mockImplementation((p: string) => { + if (p.endsWith("manifest.json") && p.includes(LINEAR_IMPORT_PLUGIN_ID)) return true; + if (p.endsWith("/bundled.js") && p.includes(LINEAR_IMPORT_PLUGIN_ID)) return true; + return false; + }); + mockReadFile.mockResolvedValue(JSON.stringify(manifest)); + mockValidatePluginManifest.mockReturnValue({ valid: true, errors: [] }); + + const store = makePluginStore(); + const loader = makePluginLoader(); + const result = await ensureBundledPluginInstalled( + store as unknown as import("@fusion/core").PluginStore, + loader as unknown as import("@fusion/core").PluginLoader, + LINEAR_IMPORT_PLUGIN_ID, + ); + + expect(result).toBe("installed"); + expect(store.registerPlugin).toHaveBeenCalledWith( + expect.objectContaining({ manifest: expect.objectContaining({ id: LINEAR_IMPORT_PLUGIN_ID }) }), + ); + }); + it("registers roadmap plugin via generic bundled installer", async () => { const manifest = makeManifest({ id: ROADMAP_PLUGIN_ID, name: "Roadmaps" }); mockExistsSync.mockImplementation((p: string) => { diff --git a/packages/cli/src/plugins/bundled-plugin-install.ts b/packages/cli/src/plugins/bundled-plugin-install.ts index 6a9b5af702..a4c2c224fd 100644 --- a/packages/cli/src/plugins/bundled-plugin-install.ts +++ b/packages/cli/src/plugins/bundled-plugin-install.ts @@ -18,6 +18,7 @@ export const BUNDLED_PLUGIN_IDS = [ "fusion-plugin-cursor-runtime", "fusion-plugin-cli-printing-press", "fusion-plugin-compound-engineering", + "fusion-plugin-linear-import", ] as const; export type BundledPluginId = (typeof BUNDLED_PLUGIN_IDS)[number]; diff --git a/packages/cli/src/plugins/staged-bundled-plugin-ids.ts b/packages/cli/src/plugins/staged-bundled-plugin-ids.ts index cc9e1c62b9..f490560b1e 100644 --- a/packages/cli/src/plugins/staged-bundled-plugin-ids.ts +++ b/packages/cli/src/plugins/staged-bundled-plugin-ids.ts @@ -19,4 +19,5 @@ export const ALL_STAGED_BUNDLED_IDS = [ "fusion-plugin-whatsapp-chat", "fusion-plugin-reports", "fusion-plugin-cli-printing-press", + "fusion-plugin-linear-import", ] as const; diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index f10b068199..fa069df656 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -37,6 +37,8 @@ const cliPrintingPressPluginSrc = join(__dirname, "..", "..", "plugins", "fusion const cliPrintingPressPluginDest = join(__dirname, "dist", "plugins", "fusion-plugin-cli-printing-press"); const compoundEngineeringPluginSrc = join(__dirname, "..", "..", "plugins", "fusion-plugin-compound-engineering"); const compoundEngineeringPluginDest = join(__dirname, "dist", "plugins", "fusion-plugin-compound-engineering"); +const linearImportPluginSrc = join(__dirname, "..", "..", "plugins", "fusion-plugin-linear-import"); +const linearImportPluginDest = join(__dirname, "dist", "plugins", "fusion-plugin-linear-import"); const dashboardClientStub = ` @@ -392,6 +394,12 @@ const cliBuildConfig = { destDir: compoundEngineeringPluginDest, }); + await bundlePluginEntry({ + pluginId: "fusion-plugin-linear-import", + srcDir: linearImportPluginSrc, + destDir: linearImportPluginDest, + }); + if (existsSync(reportsPluginDest)) { rmSync(reportsPluginDest, { recursive: true, force: true }); } diff --git a/packages/dashboard/app/plugins/__tests__/registerBundledPluginViews.test.tsx b/packages/dashboard/app/plugins/__tests__/registerBundledPluginViews.test.tsx index d81bc59734..f112bf88de 100644 --- a/packages/dashboard/app/plugins/__tests__/registerBundledPluginViews.test.tsx +++ b/packages/dashboard/app/plugins/__tests__/registerBundledPluginViews.test.tsx @@ -10,6 +10,7 @@ const MockDependencyGraphDashboardView = () => createElement("div", { "data-test const MockCompoundEngineeringDashboardView = () => createElement("div", { "data-testid": "ce-view" }); const MockCliPrintingPressWizardView = () => createElement("div", { "data-testid": "cli-printing-press-view" }); const MockCliPrintingPressManageView = () => createElement("div", { "data-testid": "cli-printing-press-manage-view" }); +const MockLinearImportView = () => createElement("div", { "data-testid": "linear-import-view" }); vi.mock("@fusion-plugin-examples/dependency-graph/dashboard-view", () => ({ DependencyGraphDashboardView: (...args: unknown[]) => MockDependencyGraphDashboardView(...args), @@ -27,6 +28,10 @@ vi.mock("@fusion-plugin-examples/cli-printing-press/manage-view", () => ({ CliPrintingPressManageView: (...args: unknown[]) => MockCliPrintingPressManageView(...args), })); +vi.mock("@fusion-plugin-examples/linear-import/dashboard-view", () => ({ + LinearImportDashboardView: (...args: unknown[]) => MockLinearImportView(...args), +})); + // The dashboard statically registers bundled views client-side, so these views can // render even when engine-side PluginLoader startup failed and the persisted // installation row is in an error state. @@ -36,7 +41,7 @@ describe("registerBundledPluginViews", () => { __test_resetBundledPluginViewRegistration(); }); - it("registers dependency graph, compound engineering, and cli printing press bundled views", () => { + it("registers dependency graph, compound engineering, cli printing press, and Linear bundled views", () => { registerBundledPluginViews(); // This registration is independent of engine-side plugin load success; the @@ -49,6 +54,7 @@ describe("registerBundledPluginViews", () => { expect(getPluginViewComponent("fusion-plugin-roadmap", "roadmaps")).toBeNull(); expect(getPluginViewComponent("fusion-plugin-cli-printing-press", "wizard")).toBeTruthy(); expect(getPluginViewComponent("fusion-plugin-cli-printing-press", "manage")).toBeTruthy(); + expect(getPluginViewComponent("fusion-plugin-linear-import", "linear-import")).toBeTruthy(); }); it("is idempotent when called more than once", () => { @@ -69,6 +75,7 @@ describe("registerBundledPluginViews", () => { expect(isPluginViewRegistered("fusion-plugin-roadmap", "roadmaps")).toBe(false); expect(isPluginViewRegistered("fusion-plugin-cli-printing-press", "wizard")).toBe(true); expect(isPluginViewRegistered("fusion-plugin-cli-printing-press", "manage")).toBe(true); + expect(isPluginViewRegistered("fusion-plugin-linear-import", "linear-import")).toBe(true); // Unknown plugin/view should not be registered expect(isPluginViewRegistered("unknown-plugin", "unknown")).toBe(false); }); diff --git a/packages/dashboard/app/plugins/registerBundledPluginViews.ts b/packages/dashboard/app/plugins/registerBundledPluginViews.ts index 1f0ebb2e30..be0cd8e48c 100644 --- a/packages/dashboard/app/plugins/registerBundledPluginViews.ts +++ b/packages/dashboard/app/plugins/registerBundledPluginViews.ts @@ -69,6 +69,18 @@ async function loadCliPrintingPressManageView(): Promise<{ default: PluginViewCo } } +async function loadLinearImportView(): Promise<{ default: PluginViewComponent }> { + const moduleId = "@fusion-plugin-examples/linear-import/dashboard-view"; + const exportName = "LinearImportDashboardView"; + const mod = await import("@fusion-plugin-examples/linear-import/dashboard-view") as unknown as Record>; + const component = mod[exportName]; + if (!component) { + console.warn(`[plugin-views] Missing export ${exportName} from ${moduleId}`); + return { default: createMissingPluginView(moduleId, exportName) }; + } + return { default: component as PluginViewComponent }; +} + export function registerBundledPluginViews(): void { if (registered) return; registered = true; @@ -96,6 +108,12 @@ export function registerBundledPluginViews(): void { "manage", lazy(loadCliPrintingPressManageView), ); + + registerPluginView( + "fusion-plugin-linear-import", + "linear-import", + lazy(loadLinearImportView), + ); } export function __test_resetBundledPluginViewRegistration(): void { diff --git a/packages/dashboard/app/types/plugin-dashboard-views.d.ts b/packages/dashboard/app/types/plugin-dashboard-views.d.ts index 589e00a1e9..c0575f8c87 100644 --- a/packages/dashboard/app/types/plugin-dashboard-views.d.ts +++ b/packages/dashboard/app/types/plugin-dashboard-views.d.ts @@ -6,3 +6,12 @@ declare module "@fusion-plugin-examples/dependency-graph/dashboard-view" { export default DependencyGraphDashboardView; export { DependencyGraphDashboardView }; } + +declare module "@fusion-plugin-examples/linear-import/dashboard-view" { + import type { ComponentType } from "react"; + import type { PluginDashboardViewContext } from "@fusion/core"; + + const LinearImportDashboardView: ComponentType<{ context?: PluginDashboardViewContext }>; + export default LinearImportDashboardView; + export { LinearImportDashboardView }; +} diff --git a/packages/dashboard/src/__tests__/routes-plugin-registry.test.ts b/packages/dashboard/src/__tests__/routes-plugin-registry.test.ts index 7495ac0277..9d0891b07d 100644 --- a/packages/dashboard/src/__tests__/routes-plugin-registry.test.ts +++ b/packages/dashboard/src/__tests__/routes-plugin-registry.test.ts @@ -83,6 +83,11 @@ describe("GET /api/plugins/registry", () => { expect((res.body as { plugins: Array<{ id: string }> }).plugins.map((plugin) => plugin.id)).toEqual([ "fusion-plugin-whatsapp-chat", ]); + + const linearRes = await performGet(buildApp(pluginStore), "/api/plugins/registry?q=linear"); + expect((linearRes.body as { plugins: Array<{ id: string }> }).plugins.map((plugin) => plugin.id)).toEqual([ + "fusion-plugin-linear-import", + ]); }); it("filters by category", async () => { diff --git a/packages/dashboard/src/__tests__/runtime-plugin-alias-regression.test.ts b/packages/dashboard/src/__tests__/runtime-plugin-alias-regression.test.ts index bfd7aa2d61..0e61673a68 100644 --- a/packages/dashboard/src/__tests__/runtime-plugin-alias-regression.test.ts +++ b/packages/dashboard/src/__tests__/runtime-plugin-alias-regression.test.ts @@ -52,6 +52,18 @@ describe("FN-3298 regression: dashboard vitest runtime plugins resolve from sour '"../../plugins/fusion-plugin-compound-engineering/src/index.ts"', ); expect(config).not.toContain("fusion-plugin-compound-engineering/dist/"); + expect( + config, + `${configFile} must alias the linear-import dashboard-view to src`, + ).toContain('"@fusion-plugin-examples/linear-import/dashboard-view": resolve('); + expect(config).toContain( + '"../../plugins/fusion-plugin-linear-import/src/dashboard-view.tsx"', + ); + expect(config).toContain('"@fusion-plugin-examples/linear-import": resolve('); + expect(config).toContain( + '"../../plugins/fusion-plugin-linear-import/src/index.ts"', + ); + expect(config).not.toContain("fusion-plugin-linear-import/dist/"); } }); }); diff --git a/packages/dashboard/src/registry-manifest.json b/packages/dashboard/src/registry-manifest.json index 3079a4fe64..2e1349978e 100644 --- a/packages/dashboard/src/registry-manifest.json +++ b/packages/dashboard/src/registry-manifest.json @@ -100,6 +100,16 @@ "path": "./plugins/fusion-plugin-roadmap", "tags": ["planning", "roadmap"] }, + { + "id": "fusion-plugin-linear-import", + "name": "Linear Import", + "description": "Browse Linear issues and import selected issues as Fusion triage tasks through plugin-owned settings.", + "version": "0.1.0", + "author": "Fusion", + "category": "integration", + "path": "./plugins/fusion-plugin-linear-import", + "tags": ["linear", "import", "issues", "dashboard"] + }, { "id": "fusion-plugin-agent-browser", "name": "Agent Browser", diff --git a/packages/dashboard/vite.config.ts b/packages/dashboard/vite.config.ts index 81e9a41a7f..8418be9ce3 100644 --- a/packages/dashboard/vite.config.ts +++ b/packages/dashboard/vite.config.ts @@ -146,6 +146,14 @@ export default defineConfig({ __dirname, "../../plugins/fusion-plugin-dependency-graph/src/index.ts", ), + "@fusion-plugin-examples/linear-import/dashboard-view": resolve( + __dirname, + "../../plugins/fusion-plugin-linear-import/src/dashboard-view.tsx", + ), + "@fusion-plugin-examples/linear-import": resolve( + __dirname, + "../../plugins/fusion-plugin-linear-import/src/index.ts", + ), }, }, optimizeDeps: { diff --git a/packages/dashboard/vitest.config.ts b/packages/dashboard/vitest.config.ts index 1d482dd210..e9427bacac 100644 --- a/packages/dashboard/vitest.config.ts +++ b/packages/dashboard/vitest.config.ts @@ -510,6 +510,14 @@ export default defineConfig({ __dirname, "../../plugins/fusion-plugin-compound-engineering/src/index.ts", ), + "@fusion-plugin-examples/linear-import/dashboard-view": resolve( + __dirname, + "../../plugins/fusion-plugin-linear-import/src/dashboard-view.tsx", + ), + "@fusion-plugin-examples/linear-import": resolve( + __dirname, + "../../plugins/fusion-plugin-linear-import/src/index.ts", + ), }, }, test: { diff --git a/plugins/fusion-plugin-linear-import/README.md b/plugins/fusion-plugin-linear-import/README.md new file mode 100644 index 0000000000..35550bd02e --- /dev/null +++ b/plugins/fusion-plugin-linear-import/README.md @@ -0,0 +1,75 @@ +# Linear Import Plugin + +`fusion-plugin-linear-import` is a bundled Fusion integration plugin that imports Linear issues into Fusion tasks. It is intentionally implemented through plugin settings, plugin routes, plugin tools, and a plugin dashboard view — not through core Linear settings or host-owned `/api/linear/*` routes. + +## Setup + +1. Install or enable **Linear Import** from Settings → Plugins / Plugin Manager. +2. Open the plugin settings and enter a Linear personal API key. +3. Optionally set a default team key/ID, issue state filter, and assignee ID. +4. Open the **Linear Import** plugin dashboard view to browse and import issues. + +The API key is a plugin `password` setting. Fusion uses it only for HTTPS GraphQL requests to Linear and does not include it in route responses, tool results, task descriptions, task documents, or logs. + +## Supported filters + +The dashboard view, routes, and tools share the same filters: + +- `query` — matches issue title, description, or identifier. +- `teamKey` / `teamId` — Linear team key or UUID. +- `state` — `active`, `backlog`, `started`, `unstarted`, `completed`, `canceled`, or `all`. +- `assigneeId` — Linear user UUID. +- `limit` — bounded to 1–100 issues. +- `after` — optional Linear pagination cursor for browse calls. + +## Routes + +All routes are plugin-scoped under `/api/plugins/fusion-plugin-linear-import/*`: + +- `GET /status` — checks whether the plugin has a usable API key. +- `POST /issues` — lists/searches issues. +- `POST /issues/detail` — fetches one issue by UUID or identifier. +- `POST /issues/import` — imports one issue into `triage`. +- `POST /issues/import-batch` — imports up to 25 selected issues. + +Dashboard requests include `projectId` when the host provides one so the plugin uses the project-scoped plugin settings and task store. + +## Agent tools + +The plugin registers plugin tools (not built-in `fn_*` tools): + +- `linear_import_browse_issues` +- `linear_import_issue` +- `linear_import_issues` + +Tool results summarize imported/skipped issues and include safe issue/task details only. + +## Import behavior and duplicate handling + +Imported tasks are created in `triage`. The task description contains the Linear issue body or `(no description)`, followed by `Source: `, the Linear identifier, team, and state. Task provenance stores: + +- `sourceIssue.provider: "linear"` +- stable Linear issue id as `sourceIssue.externalIssueId` +- source URL +- `source.sourceType: "api"` +- `source.sourceMetadata.provider: "linear"` +- Linear issue id, identifier, URL, team, state, assignee, and timestamps where available + +Duplicate detection checks non-archived tasks by Linear issue id, Linear identifier, and source URL before creating a task. Duplicate route/tool responses identify the existing Fusion task id when available. + +## Limitations and non-goals + +- No Linear CLI or binary dependency is required or installed. +- No host-owned `/api/linear/*` routes or core Linear settings are added. +- Imports are read-only with respect to Linear; the plugin does not comment on, close, reopen, or update Linear issues. +- Linear workspace/team permissions are determined by the configured API key. + +## External Integration Evidence + +- Canonical upstream repo URL: `upstream-pending-verification` (Linear is consumed as a SaaS HTTP/GraphQL API; no official client repository is required) +- Docs / homepage URL: +- API docs URL: +- GraphQL API endpoint: +- Release / download URL: `upstream-pending-verification` (no downloadable binary is added) +- Binary / CLI name: `none` (HTTP/GraphQL API integration only) +- Checksum: `upstream-pending-verification` (no downloaded binary is added) diff --git a/plugins/fusion-plugin-linear-import/manifest.json b/plugins/fusion-plugin-linear-import/manifest.json new file mode 100644 index 0000000000..c344e57b90 --- /dev/null +++ b/plugins/fusion-plugin-linear-import/manifest.json @@ -0,0 +1,48 @@ +{ + "id": "fusion-plugin-linear-import", + "name": "Linear Import", + "version": "0.1.0", + "description": "Import Linear issues into Fusion tasks through plugin-owned settings, routes, tools, and dashboard view.", + "author": "Fusion", + "fusionVersion": ">=0.1.0", + "settingsSchema": { + "apiKey": { + "type": "password", + "label": "Linear API key", + "description": "Personal Linear API key used only by this plugin to browse and import issues.", + "required": true, + "group": "Authentication" + }, + "defaultTeamKey": { + "type": "string", + "label": "Default team key or ID", + "description": "Optional Linear team key or UUID to prefill issue searches.", + "group": "Defaults" + }, + "defaultStateFilter": { + "type": "enum", + "label": "Default issue state filter", + "description": "Initial issue state filter for browse and import tools.", + "enumValues": ["active", "backlog", "started", "unstarted", "completed", "canceled", "all"], + "defaultValue": "active", + "group": "Defaults" + }, + "defaultAssigneeId": { + "type": "string", + "label": "Default assignee ID", + "description": "Optional Linear user UUID used as the default assignee filter.", + "group": "Defaults" + } + }, + "dashboardViews": [ + { + "viewId": "linear-import", + "label": "Linear Import", + "componentPath": "./dashboard-view", + "icon": "ListPlus", + "placement": "more", + "order": 55, + "description": "Browse Linear issues and import selected issues as Fusion tasks." + } + ] +} diff --git a/plugins/fusion-plugin-linear-import/package.json b/plugins/fusion-plugin-linear-import/package.json new file mode 100644 index 0000000000..632ae25b63 --- /dev/null +++ b/plugins/fusion-plugin-linear-import/package.json @@ -0,0 +1,38 @@ +{ + "name": "@fusion-plugin-examples/linear-import", + "version": "0.1.0", + "type": "module", + "description": "Linear issue import plugin for Fusion", + "private": true, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./dashboard-view": { + "types": "./dist/dashboard-view.d.ts", + "import": "./dist/dashboard-view.js" + } + }, + "scripts": { + "build": "tsc && node scripts/copy-css.mjs", + "test": "vitest run --silent=passed-only --reporter=dot", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@fusion/core": "workspace:*", + "@fusion/plugin-sdk": "workspace:*", + "lucide-react": "^0.542.0" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^25.5.2", + "@types/react": "^19.0.0", + "react": "19.2.4", + "react-dom": "19.2.4", + "typescript": "^5.7.0", + "vitest": "^4.1.0" + } +} diff --git a/plugins/fusion-plugin-linear-import/scripts/copy-css.mjs b/plugins/fusion-plugin-linear-import/scripts/copy-css.mjs new file mode 100644 index 0000000000..cbdce13259 --- /dev/null +++ b/plugins/fusion-plugin-linear-import/scripts/copy-css.mjs @@ -0,0 +1,11 @@ +import { cpSync, existsSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const src = join(root, "src", "LinearImportView.css"); +const dest = join(root, "dist", "LinearImportView.css"); +if (existsSync(src)) { + mkdirSync(dirname(dest), { recursive: true }); + cpSync(src, dest); +} diff --git a/plugins/fusion-plugin-linear-import/src/LinearImportView.css b/plugins/fusion-plugin-linear-import/src/LinearImportView.css new file mode 100644 index 0000000000..3416501220 --- /dev/null +++ b/plugins/fusion-plugin-linear-import/src/LinearImportView.css @@ -0,0 +1,167 @@ +.linear-import-view { + display: flex; + flex-direction: column; + gap: var(--space-lg); + padding: var(--space-lg); + min-height: 0; +} + +.linear-import-view__header { + display: flex; + justify-content: space-between; + gap: var(--space-md); + align-items: flex-start; +} + +.linear-import-view__eyebrow, +.linear-import-view__meta, +.linear-import-view__empty, +.linear-import-view__helper { + color: var(--text-muted); +} + +.linear-import-view__title { + margin: 0; +} + +.linear-import-view__subtitle { + margin: var(--space-xs) 0 0; + color: var(--text-muted); +} + +.linear-import-view__status { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + border: var(--border-width) solid var(--border-color); + border-radius: var(--radius-pill); + padding: var(--space-xs) var(--space-sm); + font-size: var(--font-size-sm); + white-space: nowrap; +} + +.linear-import-view__status--auth { color: var(--color-success); border-color: color-mix(in srgb, var(--color-success) 40%, var(--border-color)); } +.linear-import-view__status--warning { color: var(--color-warning); border-color: color-mix(in srgb, var(--color-warning) 40%, var(--border-color)); } +.linear-import-view__status--error { color: var(--color-error); border-color: color-mix(in srgb, var(--color-error) 40%, var(--border-color)); } +.linear-import-view__status--info { color: var(--color-info); border-color: color-mix(in srgb, var(--color-info) 40%, var(--border-color)); } + +.linear-import-view__filters { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--space-md); + align-items: end; +} + +.linear-import-view__field { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.linear-import-view__actions, +.linear-import-view__issue-actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-sm); +} + +.linear-import-view__content { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(18rem, 0.75fr); + gap: var(--space-lg); + min-height: 0; +} + +.linear-import-view__issue-list { + display: flex; + flex-direction: column; + gap: var(--space-sm); + max-height: 55vh; + overflow: auto; +} + +.linear-import-view__issue { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: var(--space-sm); + align-items: start; + text-align: left; + width: 100%; +} + +.linear-import-view__issue-main { + min-width: 0; +} + +.linear-import-view__issue-title { + display: flex; + flex-wrap: wrap; + gap: var(--space-xs); + align-items: baseline; + margin: 0; +} + +.linear-import-view__identifier { + color: var(--color-info); + font-weight: var(--font-weight-semibold, 600); +} + +.linear-import-view__badges { + display: flex; + flex-wrap: wrap; + gap: var(--space-xs); + margin-top: var(--space-xs); +} + +.linear-import-view__badge { + border: var(--border-width) solid var(--border-color); + border-radius: var(--radius-pill); + padding: 0 var(--space-xs); + color: var(--text-muted); + font-size: var(--font-size-sm); +} + +.linear-import-view__preview { + display: flex; + flex-direction: column; + gap: var(--space-md); + min-width: 0; +} + +.linear-import-view__preview-body { + white-space: pre-wrap; + overflow: auto; + max-height: 45vh; + color: var(--text-primary); +} + +.linear-import-view__message { + border-radius: var(--radius-md); + padding: var(--space-md); + border: var(--border-width) solid var(--border-color); +} + +.linear-import-view__message--error { color: var(--color-error); border-color: color-mix(in srgb, var(--color-error) 45%, var(--border-color)); } +.linear-import-view__message--success { color: var(--color-success); border-color: color-mix(in srgb, var(--color-success) 45%, var(--border-color)); } +.linear-import-view__message--info { color: var(--color-info); border-color: color-mix(in srgb, var(--color-info) 45%, var(--border-color)); } + +@media (max-width: 48rem) { + .linear-import-view { + padding: var(--space-md); + } + + .linear-import-view__header, + .linear-import-view__content { + grid-template-columns: 1fr; + display: grid; + } + + .linear-import-view__filters { + grid-template-columns: 1fr; + } + + .linear-import-view__issue-list, + .linear-import-view__preview-body { + max-height: none; + } +} diff --git a/plugins/fusion-plugin-linear-import/src/LinearImportView.tsx b/plugins/fusion-plugin-linear-import/src/LinearImportView.tsx new file mode 100644 index 0000000000..fc3991b576 --- /dev/null +++ b/plugins/fusion-plugin-linear-import/src/LinearImportView.tsx @@ -0,0 +1,263 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { AlertCircle, CheckCircle2, Loader2 } from "lucide-react"; +import type { PluginDashboardViewContext } from "@fusion/dashboard/app/plugins/types"; +import type { LinearIssue } from "./linear-client.js"; +import "./LinearImportView.css"; + +type StatusState = "loading" | "authenticated" | "missing" | "error"; + +interface RouteResponse { + ok?: boolean; + error?: string; + code?: string; + authenticated?: boolean; + configured?: boolean; + issues?: LinearIssue[]; + issue?: LinearIssue; + imported?: boolean | number; + duplicate?: boolean; + duplicates?: number; + taskId?: string; + results?: Array<{ imported: boolean; duplicate: boolean; taskId?: string; issue: { title: string } }>; + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null }; + value?: T; +} + +const PLUGIN_BASE = "/api/plugins/fusion-plugin-linear-import"; + +function projectPayload(context?: PluginDashboardViewContext): Record { + return context?.projectId ? { projectId: context.projectId } : {}; +} + +async function postPluginRoute(path: string, body: Record, context?: PluginDashboardViewContext): Promise> { + const response = await fetch(`${PLUGIN_BASE}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...projectPayload(context), ...body }), + }); + const json = await response.json().catch(() => ({})) as RouteResponse; + if (!response.ok || json.ok === false) { + throw new Error(json.error ?? `Linear Import request failed with status ${response.status}.`); + } + return json; +} + +async function getStatus(context?: PluginDashboardViewContext): Promise> { + const params = new URLSearchParams(projectPayload(context)); + const suffix = params.toString() ? `?${params.toString()}` : ""; + const response = await fetch(`${PLUGIN_BASE}/status${suffix}`); + const json = await response.json().catch(() => ({})) as RouteResponse; + if (!response.ok || json.ok === false) { + throw new Error(json.error ?? `Linear status failed with status ${response.status}.`); + } + return json; +} + +function StatusBadge({ state, message }: { state: StatusState; message?: string }) { + const className = state === "authenticated" ? "auth" : state === "missing" ? "warning" : state === "error" ? "error" : "info"; + const Icon = state === "authenticated" ? CheckCircle2 : state === "loading" ? Loader2 : AlertCircle; + return ( + + + ); +} + +function issueSummary(issue: LinearIssue): string { + return [issue.team?.key, issue.state?.name, issue.assignee?.name].filter(Boolean).join(" · ") || "No Linear metadata"; +} + +export function LinearImportView({ context }: { context?: PluginDashboardViewContext }) { + const [status, setStatus] = useState("loading"); + const [statusMessage, setStatusMessage] = useState(); + const [query, setQuery] = useState(""); + const [teamKey, setTeamKey] = useState(""); + const [state, setState] = useState("active"); + const [assigneeId, setAssigneeId] = useState(""); + const [issues, setIssues] = useState([]); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [previewId, setPreviewId] = useState(); + const [loading, setLoading] = useState(false); + const [message, setMessage] = useState<{ type: "error" | "success" | "info"; text: string }>(); + + useEffect(() => { + let cancelled = false; + setStatus("loading"); + getStatus(context) + .then((result) => { + if (cancelled) return; + if (result.authenticated) { + setStatus("authenticated"); + setStatusMessage("Linear connected"); + } else { + setStatus("missing"); + setStatusMessage("Add a Linear API key in Plugin Manager settings"); + } + }) + .catch((error) => { + if (cancelled) return; + setStatus("error"); + setStatusMessage(error instanceof Error ? error.message : "Linear status check failed"); + }); + return () => { cancelled = true; }; + }, [context?.projectId]); + + const canSearch = status === "authenticated" && !loading; + const selectedIssues = useMemo(() => issues.filter((issue) => selectedIds.has(issue.id)), [issues, selectedIds]); + const previewIssue = useMemo(() => issues.find((issue) => issue.id === previewId) ?? selectedIssues[0] ?? issues[0], [issues, previewId, selectedIssues]); + + const browse = useCallback(async () => { + if (!canSearch) return; + setLoading(true); + setMessage(undefined); + try { + const result = await postPluginRoute("/issues", { query, teamKey, state, assigneeId, limit: 50 }, context); + const nextIssues = result.issues ?? []; + setIssues(nextIssues); + setSelectedIds(new Set()); + setPreviewId(nextIssues[0]?.id); + setMessage(nextIssues.length === 0 ? { type: "info", text: "No Linear issues matched the filters." } : undefined); + } catch (error) { + setMessage({ type: "error", text: error instanceof Error ? error.message : "Unable to browse Linear issues." }); + } finally { + setLoading(false); + } + }, [assigneeId, canSearch, context?.projectId, query, state, teamKey]); + + const importOne = useCallback(async (issue: LinearIssue | undefined) => { + if (!issue || status !== "authenticated") return; + setLoading(true); + setMessage(undefined); + try { + const result = await postPluginRoute("/issues/import", { issueId: issue.id }, context); + const text = result.duplicate + ? `Skipped duplicate ${issue.identifier}; existing task ${result.taskId}.` + : `Imported ${issue.identifier} as task ${result.taskId}.`; + setMessage({ type: result.duplicate ? "info" : "success", text }); + } catch (error) { + setMessage({ type: "error", text: error instanceof Error ? error.message : "Unable to import Linear issue." }); + } finally { + setLoading(false); + } + }, [context?.projectId, status]); + + const importSelected = useCallback(async () => { + if (selectedIssues.length === 0 || status !== "authenticated") return; + setLoading(true); + setMessage(undefined); + try { + const result = await postPluginRoute("/issues/import-batch", { issueIds: selectedIssues.map((issue) => issue.id) }, context); + setMessage({ type: "success", text: `Batch import complete: ${result.imported ?? 0} imported, ${result.duplicates ?? 0} duplicates skipped.` }); + } catch (error) { + setMessage({ type: "error", text: error instanceof Error ? error.message : "Unable to import selected Linear issues." }); + } finally { + setLoading(false); + } + }, [context?.projectId, selectedIssues, status]); + + const toggleIssue = (issueId: string) => { + setSelectedIds((current) => { + const next = new Set(current); + if (next.has(issueId)) next.delete(issueId); + else next.add(issueId); + return next; + }); + }; + + return ( +
+
+
+

Bundled plugin

+

Linear Import

+

Browse Linear issues and import selected issues into Fusion triage.

+
+ +
+ + {status === "missing" ? ( +
+ Configure the Linear Import plugin in Plugin Manager settings with a Linear API key, then return here to browse issues. +
+ ) : null} + +
{ event.preventDefault(); void browse(); }}> + + + + +
+ + +
+
+ + {message ?
{message.text}
: null} + +
+
+

Issues

+ {loading ?

: null} + {!loading && issues.length === 0 ?

No Linear issues loaded yet. Browse to see matching issues.

: null} +
+ {issues.map((issue) => ( +
+ toggleIssue(issue.id)} /> +
+

{issue.identifier}{issue.title}

+

{issueSummary(issue)}

+
+ {issue.team?.key ? {issue.team.key} : null} + {issue.state?.name ? {issue.state.name} : null} +
+
+ + +
+
+
+ ))} +
+
+ + +
+
+ ); +} + +export default LinearImportView; diff --git a/plugins/fusion-plugin-linear-import/src/__tests__/LinearImportView.test.tsx b/plugins/fusion-plugin-linear-import/src/__tests__/LinearImportView.test.tsx new file mode 100644 index 0000000000..859f4cad69 --- /dev/null +++ b/plugins/fusion-plugin-linear-import/src/__tests__/LinearImportView.test.tsx @@ -0,0 +1,124 @@ +import "@testing-library/jest-dom/vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { LinearImportView } from "../LinearImportView.js"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); +} + +const issue = { + id: "iss-1", + identifier: "ENG-1", + title: "Fix plugin import", + description: "Linear body", + url: "https://linear.app/acme/issue/ENG-1/fix-plugin-import", + state: { name: "Todo", type: "unstarted" }, + team: { id: "team-1", key: "ENG", name: "Engineering" }, + assignee: { id: "user-1", name: "Ada" }, + labels: [], +}; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe("LinearImportView", () => { + it("renders missing-auth setup state", async () => { + vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ ok: true, authenticated: false, configured: false }))); + render(); + expect(await screen.findByText(/Add a Linear API key/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Browse issues/i })).toBeDisabled(); + }); + + it("renders desktop filters and calls plugin route with projectId", async () => { + const fetch = vi.fn() + .mockResolvedValueOnce(jsonResponse({ ok: true, authenticated: true })) + .mockResolvedValueOnce(jsonResponse({ ok: true, issues: [issue], pageInfo: { hasNextPage: false, endCursor: null } })); + vi.stubGlobal("fetch", fetch); + render(); + await screen.findByText("Linear connected"); + await userEvent.type(screen.getByLabelText(/Search/i), "plugin"); + await userEvent.type(screen.getByLabelText(/Team key/i), "ENG"); + await userEvent.click(screen.getByRole("button", { name: /Browse issues/i })); + await screen.findByText("Fix plugin import"); + expect(fetch.mock.calls[0][0]).toBe("/api/plugins/fusion-plugin-linear-import/status?projectId=proj-1"); + expect(fetch.mock.calls[1][0]).toBe("/api/plugins/fusion-plugin-linear-import/issues"); + expect(JSON.parse(String(fetch.mock.calls[1][1].body))).toEqual(expect.objectContaining({ projectId: "proj-1", query: "plugin", teamKey: "ENG" })); + }); + + it("renders mobile/narrow controls without hidden focus traps", async () => { + Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 390 }); + fireEvent(window, new Event("resize")); + vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ ok: true, authenticated: true }))); + render(); + await screen.findByText("Linear connected"); + expect(screen.getByLabelText(/Search/i)).toBeVisible(); + expect(screen.getByLabelText(/Team key/i)).toBeVisible(); + expect(screen.getByRole("button", { name: /Browse issues/i })).toBeVisible(); + }); + + it("shows empty results", async () => { + vi.stubGlobal("fetch", vi.fn() + .mockResolvedValueOnce(jsonResponse({ ok: true, authenticated: true })) + .mockResolvedValueOnce(jsonResponse({ ok: true, issues: [], pageInfo: { hasNextPage: false, endCursor: null } }))); + render(); + await screen.findByText("Linear connected"); + await userEvent.click(screen.getByRole("button", { name: /Browse issues/i })); + expect(await screen.findByText(/No Linear issues matched/i)).toBeInTheDocument(); + }); + + it("previews issue descriptions and imports one issue", async () => { + const fetch = vi.fn() + .mockResolvedValueOnce(jsonResponse({ ok: true, authenticated: true })) + .mockResolvedValueOnce(jsonResponse({ ok: true, issues: [issue], pageInfo: { hasNextPage: false, endCursor: null } })) + .mockResolvedValueOnce(jsonResponse({ ok: true, imported: true, duplicate: false, taskId: "FN-8" }, 201)); + vi.stubGlobal("fetch", fetch); + render(); + await screen.findByText("Linear connected"); + await userEvent.click(screen.getByRole("button", { name: /Browse issues/i })); + expect(await screen.findByText("Linear body")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: /Import previewed issue/i })); + expect(await screen.findByText(/Imported ENG-1 as task FN-8/i)).toBeInTheDocument(); + }); + + it("shows duplicate import response", async () => { + vi.stubGlobal("fetch", vi.fn() + .mockResolvedValueOnce(jsonResponse({ ok: true, authenticated: true })) + .mockResolvedValueOnce(jsonResponse({ ok: true, issues: [issue], pageInfo: { hasNextPage: false, endCursor: null } })) + .mockResolvedValueOnce(jsonResponse({ ok: true, imported: false, duplicate: true, taskId: "FN-2" }))); + render(); + await screen.findByText("Linear connected"); + await userEvent.click(screen.getByRole("button", { name: /Browse issues/i })); + await screen.findByText("Fix plugin import"); + await userEvent.click(screen.getByRole("button", { name: /Import previewed issue/i })); + expect(await screen.findByText(/existing task FN-2/i)).toBeInTheDocument(); + }); + + it("shows GraphQL error responses", async () => { + vi.stubGlobal("fetch", vi.fn() + .mockResolvedValueOnce(jsonResponse({ ok: true, authenticated: true })) + .mockResolvedValueOnce(jsonResponse({ ok: false, error: "Linear GraphQL error: broken", code: "graphql_error" }, 400))); + render(); + await screen.findByText("Linear connected"); + await userEvent.click(screen.getByRole("button", { name: /Browse issues/i })); + expect(await screen.findByText(/Linear GraphQL error: broken/i)).toBeInTheDocument(); + }); + + it("imports selected issues", async () => { + vi.stubGlobal("fetch", vi.fn() + .mockResolvedValueOnce(jsonResponse({ ok: true, authenticated: true })) + .mockResolvedValueOnce(jsonResponse({ ok: true, issues: [issue], pageInfo: { hasNextPage: false, endCursor: null } })) + .mockResolvedValueOnce(jsonResponse({ ok: true, imported: 1, duplicates: 0, results: [] }))); + render(); + await screen.findByText("Linear connected"); + await userEvent.click(screen.getByRole("button", { name: /Browse issues/i })); + await screen.findByText("Fix plugin import"); + await userEvent.click(screen.getByLabelText(/Select ENG-1/i)); + await waitFor(() => expect(screen.getByRole("button", { name: /Import selected/i })).toBeEnabled()); + await userEvent.click(screen.getByRole("button", { name: /Import selected/i })); + expect(await screen.findByText(/1 imported, 0 duplicates/i)).toBeInTheDocument(); + }); +}); diff --git a/plugins/fusion-plugin-linear-import/src/__tests__/import-linear.test.ts b/plugins/fusion-plugin-linear-import/src/__tests__/import-linear.test.ts new file mode 100644 index 0000000000..714aa8a4ca --- /dev/null +++ b/plugins/fusion-plugin-linear-import/src/__tests__/import-linear.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Task } from "@fusion/core"; +import { buildLinearImportPreview, buildLinearTaskCreateInput, findExistingLinearTask, importLinearIssue, taskMatchesLinearIssue } from "../import-linear.js"; +import type { LinearIssue } from "../linear-client.js"; +import { hasLinearApiKey, linearSettingsSchema, resolveLinearSettings } from "../settings.js"; + +const issue: LinearIssue = { + id: "lin-issue-1", + identifier: "ENG-42", + title: "Fix import", + description: "Detailed markdown", + url: "https://linear.app/acme/issue/ENG-42/fix-import", + state: { name: "Todo", type: "unstarted" }, + team: { id: "team-1", key: "ENG", name: "Engineering" }, + assignee: { id: "user-1", name: "Ada" }, + creator: null, + labels: [{ name: "bug" }], + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", +}; + +function task(overrides: Partial): Task { + return { id: "FN-1", title: "Existing", description: "", status: "pending", column: "todo", createdAt: "", updatedAt: "", steps: [], dependencies: [], log: [], ...overrides } as Task; +} + +describe("linear settings", () => { + it("defines required password setting and defaults", () => { + expect(linearSettingsSchema.apiKey.type).toBe("password"); + expect(linearSettingsSchema.apiKey.required).toBe(true); + expect(resolveLinearSettings({ defaultStateFilter: "bogus" }).defaultStateFilter).toBe("active"); + expect(resolveLinearSettings({ apiKey: " token ", defaultStateFilter: "completed" })).toEqual(expect.objectContaining({ apiKey: "token", defaultStateFilter: "completed" })); + expect(hasLinearApiKey({ apiKey: " " })).toBe(false); + }); +}); + +describe("Linear import normalization", () => { + it("normalizes populated issue descriptions and provenance", () => { + const preview = buildLinearImportPreview(issue); + expect(preview.title).toBe("[ENG-42] Fix import"); + expect(preview.description).toContain("Detailed markdown"); + expect(preview.description).toContain("Source: https://linear.app/acme/issue/ENG-42/fix-import"); + expect(preview.sourceIssue).toEqual(expect.objectContaining({ provider: "linear", repository: "ENG", externalIssueId: "lin-issue-1", issueNumber: 42 })); + expect(preview.sourceMetadata).toEqual(expect.objectContaining({ provider: "linear", issueId: "lin-issue-1", identifier: "ENG-42", teamKey: "ENG" })); + }); + + it("normalizes empty descriptions", () => { + const preview = buildLinearImportPreview({ ...issue, description: null }); + expect(preview.description).toContain("(no description)"); + }); + + it("builds triage task create input with durable metadata", () => { + const input = buildLinearTaskCreateInput(issue); + expect(input.column).toBe("triage"); + expect(input.source?.sourceType).toBe("api"); + expect(input.source?.sourceMetadata).toEqual(expect.objectContaining({ provider: "linear", issueId: "lin-issue-1" })); + }); + + it("detects duplicates by issue id, identifier, and source URL", async () => { + const byIssueId = task({ sourceIssue: { provider: "linear", repository: "ENG", externalIssueId: issue.id, issueNumber: 42 } }); + expect(taskMatchesLinearIssue(byIssueId, issue)).toBe(true); + expect(taskMatchesLinearIssue(task({ source: { sourceType: "api", sourceMetadata: { provider: "linear", identifier: "ENG-42" } } }), issue)).toBe(true); + expect(taskMatchesLinearIssue(task({ description: `Imported\nSource: ${issue.url}` }), issue)).toBe(true); + + const existing = await findExistingLinearTask({ listTasks: vi.fn(async () => [byIssueId]) }, issue); + expect(existing?.id).toBe("FN-1"); + }); + + it("skips duplicate creation and imports new issues", async () => { + const existingStore = { listTasks: vi.fn(async () => [task({ id: "FN-2", source: { sourceType: "api", sourceMetadata: { provider: "linear", issueId: issue.id } } })]), createTask: vi.fn() }; + await expect(importLinearIssue(existingStore, issue)).resolves.toEqual(expect.objectContaining({ imported: false, duplicate: true, taskId: "FN-2" })); + expect(existingStore.createTask).not.toHaveBeenCalled(); + + const created = task({ id: "FN-3" }); + const newStore = { listTasks: vi.fn(async () => []), createTask: vi.fn(async () => created) }; + await expect(importLinearIssue(newStore, issue)).resolves.toEqual(expect.objectContaining({ imported: true, duplicate: false, taskId: "FN-3" })); + expect(newStore.createTask).toHaveBeenCalledWith(expect.objectContaining({ title: "[ENG-42] Fix import" })); + }); +}); diff --git a/plugins/fusion-plugin-linear-import/src/__tests__/linear-client.test.ts b/plugins/fusion-plugin-linear-import/src/__tests__/linear-client.test.ts new file mode 100644 index 0000000000..3a105fd385 --- /dev/null +++ b/plugins/fusion-plugin-linear-import/src/__tests__/linear-client.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from "vitest"; +import { buildLinearIssueFilter, clampLinearLimit, LinearClient, LinearApiError, LINEAR_GRAPHQL_ENDPOINT } from "../linear-client.js"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); +} + +describe("LinearClient", () => { + it("constructs auth headers without leaking the token in errors", async () => { + const fetchImpl = vi.fn(async () => jsonResponse({ errors: [{ message: "Bad token secret-linear-token" }] }, 401)) as unknown as typeof fetch; + const client = new LinearClient("secret-linear-token", fetchImpl); + await expect(client.listIssues()).rejects.toMatchObject({ message: "Linear API key is missing, invalid, or expired." }); + expect(fetchImpl).toHaveBeenCalledWith(LINEAR_GRAPHQL_ENDPOINT, expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ Authorization: "secret-linear-token" }), + })); + await client.listIssues().catch((error) => { + expect(String(error.message)).not.toContain("secret-linear-token"); + }); + }); + + it("passes bounded GraphQL variables and state/team filters", async () => { + const fetchImpl = vi.fn(async () => jsonResponse({ + data: { + issues: { + nodes: [{ id: "iss-1", identifier: "ENG-1", title: "Bug", url: "https://linear.app/acme/issue/ENG-1/bug", labels: { nodes: [] } }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + })) as unknown as typeof fetch; + const client = new LinearClient("token", fetchImpl); + const result = await client.listIssues({ limit: 999, teamKey: "ENG", state: "active", query: "bug", assigneeId: "user-1" }); + expect(result.issues).toHaveLength(1); + const body = JSON.parse(String((fetchImpl as any).mock.calls[0][1].body)); + expect(body.variables.first).toBe(50); + expect(body.variables.filter.and).toEqual(expect.arrayContaining([ + { state: { type: { eq: "active" } } }, + { assignee: { id: { eq: "user-1" } } }, + ])); + }); + + it("follows cursor pagination within bounds", async () => { + const fetchImpl = vi.fn() + .mockResolvedValueOnce(jsonResponse({ data: { issues: { nodes: [{ id: "iss-1", identifier: "ENG-1", title: "One", url: "https://linear.app/acme/issue/ENG-1/one", labels: { nodes: [] } }], pageInfo: { hasNextPage: true, endCursor: "cursor-1" } } } })) + .mockResolvedValueOnce(jsonResponse({ data: { issues: { nodes: [{ id: "iss-2", identifier: "ENG-2", title: "Two", url: "https://linear.app/acme/issue/ENG-2/two", labels: { nodes: [] } }], pageInfo: { hasNextPage: false, endCursor: null } } } })); + const client = new LinearClient("token", fetchImpl as unknown as typeof fetch); + const result = await client.listIssues({ limit: 2 }); + expect(result.issues.map((issue) => issue.identifier)).toEqual(["ENG-1", "ENG-2"]); + const secondBody = JSON.parse(String(fetchImpl.mock.calls[1][1].body)); + expect(secondBody.variables.after).toBe("cursor-1"); + }); + + it("maps GraphQL and rate-limit errors to safe messages", async () => { + const graphqlFetch = vi.fn(async () => jsonResponse({ errors: [{ message: "Variable invalid" }] })) as unknown as typeof fetch; + await expect(new LinearClient("token", graphqlFetch).listIssues()).rejects.toMatchObject({ + message: "Linear GraphQL error: Variable invalid", + code: "graphql_error", + }); + + const rateLimitFetch = vi.fn(async () => jsonResponse({ message: "nope" }, 429)) as unknown as typeof fetch; + await expect(new LinearClient("token", rateLimitFetch).listIssues()).rejects.toMatchObject({ + message: "Linear rate limit exceeded. Try again later.", + }); + }); + + it("validates empty issue detail identifiers", async () => { + await expect(new LinearClient("token", vi.fn() as unknown as typeof fetch).getIssue(" ")).rejects.toBeInstanceOf(LinearApiError); + }); +}); + +describe("Linear query helpers", () => { + it("clamps list limits", () => { + expect(clampLinearLimit(undefined)).toBe(30); + expect(clampLinearLimit(0)).toBe(1); + expect(clampLinearLimit(999)).toBe(100); + }); + + it("omits all-state filters", () => { + expect(buildLinearIssueFilter({ state: "all" })).toBeUndefined(); + }); +}); diff --git a/plugins/fusion-plugin-linear-import/src/__tests__/routes.test.ts b/plugins/fusion-plugin-linear-import/src/__tests__/routes.test.ts new file mode 100644 index 0000000000..7353e6f3eb --- /dev/null +++ b/plugins/fusion-plugin-linear-import/src/__tests__/routes.test.ts @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { PluginContext } from "@fusion/plugin-sdk"; +import { getLinearIssueDetail, getLinearStatus, importBatchLinearIssues, importSingleLinearIssue, listLinearIssues } from "../routes.js"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); +} + +const rawIssue = (id = "iss-1", identifier = "ENG-1") => ({ + id, + identifier, + title: `Title ${identifier}`, + description: "Body", + url: `https://linear.app/acme/issue/${identifier}/title`, + state: { name: "Todo", type: "unstarted" }, + team: { id: "team-1", key: "ENG", name: "Engineering" }, + labels: { nodes: [] }, +}); + +function ctx(settings: Record = { apiKey: "token" }, tasks: any[] = []): PluginContext { + return { + pluginId: "fusion-plugin-linear-import", + settings, + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + emitEvent: vi.fn(), + taskStore: { + listTasks: vi.fn(async () => tasks), + createTask: vi.fn(async (input) => ({ id: "FN-9", ...input })), + }, + } as unknown as PluginContext; +} + +afterEach(() => vi.unstubAllGlobals()); + +describe("linear plugin routes", () => { + it("returns missing auth without calling Linear", async () => { + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + await expect(listLinearIssues({ body: {} }, ctx({}))).resolves.toMatchObject({ status: 401, body: { code: "missing_api_key" } }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("validates auth status", async () => { + vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ data: { issues: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } } }))); + await expect(getLinearStatus({}, ctx({ apiKey: "token", defaultStateFilter: "active" }))).resolves.toMatchObject({ status: 200, body: { authenticated: true } }); + }); + + it("lists empty and populated issues with pagination metadata", async () => { + const fetch = vi.fn(async () => jsonResponse({ data: { issues: { nodes: [rawIssue()], pageInfo: { hasNextPage: true, endCursor: "next" } } } })); + vi.stubGlobal("fetch", fetch); + const result = await listLinearIssues({ body: { teamKey: "ENG", limit: 1 } }, ctx()); + expect(result).toMatchObject({ status: 200, body: { ok: true, pageInfo: { hasNextPage: true, endCursor: "next" } } }); + expect((result.body as any).issues[0].identifier).toBe("ENG-1"); + }); + + it("fetches single issue detail and maps GraphQL errors", async () => { + vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ errors: [{ message: "broken" }] }))); + await expect(getLinearIssueDetail({ body: { issueId: "ENG-1" } }, ctx())).resolves.toMatchObject({ status: 400, body: { code: "graphql_error" } }); + }); + + it("imports one issue and creates a triage task", async () => { + vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ data: { issue: rawIssue() } }))); + const context = ctx(); + const result = await importSingleLinearIssue({ body: { issueId: "ENG-1" } }, context); + expect(result).toMatchObject({ status: 201, body: { imported: true, duplicate: false, taskId: "FN-9" } }); + expect((context.taskStore as any).createTask).toHaveBeenCalledWith(expect.objectContaining({ column: "triage" })); + }); + + it("returns duplicate task id for existing imported issue", async () => { + vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ data: { issue: rawIssue() } }))); + const existing = { id: "FN-2", description: "", source: { sourceType: "api", sourceMetadata: { provider: "linear", issueId: "iss-1" } } }; + const result = await importSingleLinearIssue({ body: { issueId: "ENG-1" } }, ctx({ apiKey: "token" }, [existing])); + expect(result).toMatchObject({ status: 200, body: { imported: false, duplicate: true, taskId: "FN-2" } }); + }); + + it("imports batches and validates bounds", async () => { + await expect(importBatchLinearIssues({ body: { issueIds: [] } }, ctx())).resolves.toMatchObject({ status: 400 }); + vi.stubGlobal("fetch", vi.fn(async (_url, init) => { + const id = JSON.parse(String((init as RequestInit).body)).variables.id; + return jsonResponse({ data: { issue: rawIssue(`iss-${id}`, String(id)) } }); + })); + const result = await importBatchLinearIssues({ body: { issueIds: ["ENG-1", "ENG-2"] } }, ctx()); + expect(result).toMatchObject({ status: 200, body: { imported: 2, duplicates: 0 } }); + }); + + it("does not change GitHub or GitLab route namespaces", () => { + expect(["/status", "/issues", "/issues/detail", "/issues/import", "/issues/import-batch"]).not.toContain("/api/github/issues/import"); + expect(["/status", "/issues"]).not.toContain("/api/gitlab/issues/import"); + }); +}); diff --git a/plugins/fusion-plugin-linear-import/src/__tests__/tools.test.ts b/plugins/fusion-plugin-linear-import/src/__tests__/tools.test.ts new file mode 100644 index 0000000000..995ff8f4a8 --- /dev/null +++ b/plugins/fusion-plugin-linear-import/src/__tests__/tools.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { PluginContext } from "@fusion/plugin-sdk"; +import { browseLinearIssuesTool, importLinearIssuesTool, importLinearIssueTool, linearImportTools } from "../tools.js"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); +} + +const rawIssue = (id = "iss-1", identifier = "ENG-1") => ({ + id, + identifier, + title: `Title ${identifier}`, + description: "Body", + url: `https://linear.app/acme/issue/${identifier}/title`, + state: { name: "Todo", type: "unstarted" }, + team: { id: "team-1", key: "ENG", name: "Engineering" }, + labels: { nodes: [] }, +}); + +function ctx(settings: Record = { apiKey: "token" }, tasks: any[] = []): PluginContext { + return { + pluginId: "fusion-plugin-linear-import", + settings, + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + emitEvent: vi.fn(), + taskStore: { + listTasks: vi.fn(async () => tasks), + createTask: vi.fn(async (input) => ({ id: "FN-10", ...input })), + }, + } as unknown as PluginContext; +} + +afterEach(() => vi.unstubAllGlobals()); + +describe("linear plugin tools", () => { + it("registers explicit browse/import tool names", () => { + expect(linearImportTools.map((tool) => tool.name)).toEqual(["linear_import_browse_issues", "linear_import_issue", "linear_import_issues"]); + }); + + it("reports missing API key safely", async () => { + const result = await browseLinearIssuesTool.execute({}, ctx({})); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("not configured"); + }); + + it("browses empty and populated issue lists", async () => { + vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ data: { issues: { nodes: [rawIssue()], pageInfo: { hasNextPage: false, endCursor: null } } } }))); + const result = await browseLinearIssuesTool.execute({ query: "bug", limit: 5 }, ctx()); + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toContain("ENG-1"); + expect((result.details as any).issues).toHaveLength(1); + }); + + it("imports a single issue and skips duplicates", async () => { + vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ data: { issue: rawIssue() } }))); + const imported = await importLinearIssueTool.execute({ issueId: "ENG-1" }, ctx()); + expect(imported.content[0].text).toContain("Imported Linear issue ENG-1 as task FN-10"); + + const duplicate = await importLinearIssueTool.execute({ issueId: "ENG-1" }, ctx({ apiKey: "token" }, [{ id: "FN-2", description: `Source: ${rawIssue().url}` }])); + expect(duplicate.content[0].text).toContain("existing task FN-2"); + expect(duplicate.details).toMatchObject({ duplicate: true, taskId: "FN-2" }); + }); + + it("imports batches with safe summaries", async () => { + vi.stubGlobal("fetch", vi.fn(async (_url, init) => { + const id = JSON.parse(String((init as RequestInit).body)).variables.id; + return jsonResponse({ data: { issue: rawIssue(`iss-${id}`, String(id)) } }); + })); + const result = await importLinearIssuesTool.execute({ issueIds: ["ENG-1", "ENG-2"] }, ctx()); + expect(result.content[0].text).toContain("2 imported, 0 duplicates"); + }); + + it("maps GraphQL errors without token leakage", async () => { + vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ errors: [{ message: "bad token-secret" }] }))); + const result = await browseLinearIssuesTool.execute({}, ctx({ apiKey: "token-secret" })); + expect(result.isError).toBe(true); + expect(result.content[0].text).not.toContain("token-secret"); + }); +}); diff --git a/plugins/fusion-plugin-linear-import/src/dashboard-interop.d.ts b/plugins/fusion-plugin-linear-import/src/dashboard-interop.d.ts new file mode 100644 index 0000000000..2048760ff1 --- /dev/null +++ b/plugins/fusion-plugin-linear-import/src/dashboard-interop.d.ts @@ -0,0 +1,13 @@ +declare module "@fusion/dashboard/app/plugins/types" { + import type { ReactNode } from "react"; + + export interface PluginDashboardViewContext { + projectId?: string; + tasks?: unknown[]; + workflowSteps?: unknown[]; + openTaskDetail?: (...args: unknown[]) => void; + openFile?: (...args: unknown[]) => void; + renderTaskCard?: (...args: unknown[]) => ReactNode; + addToast?: (message: string, type?: "success" | "error" | "warning" | "info") => void; + } +} diff --git a/plugins/fusion-plugin-linear-import/src/dashboard-view.tsx b/plugins/fusion-plugin-linear-import/src/dashboard-view.tsx new file mode 100644 index 0000000000..a3d5a99ea5 --- /dev/null +++ b/plugins/fusion-plugin-linear-import/src/dashboard-view.tsx @@ -0,0 +1,10 @@ +import type { PluginDashboardViewContext } from "@fusion/dashboard/app/plugins/types"; +import { createElement } from "react"; +import { LinearImportView } from "./LinearImportView.js"; + +export function LinearImportDashboardView({ context }: { context?: PluginDashboardViewContext }) { + return createElement(LinearImportView, { context }); +} + +export default LinearImportDashboardView; +export { LinearImportView }; diff --git a/plugins/fusion-plugin-linear-import/src/import-linear.ts b/plugins/fusion-plugin-linear-import/src/import-linear.ts new file mode 100644 index 0000000000..c5531c6c46 --- /dev/null +++ b/plugins/fusion-plugin-linear-import/src/import-linear.ts @@ -0,0 +1,154 @@ +import type { Task, TaskCreateInput, TaskSourceIssue } from "@fusion/core"; +import type { LinearIssue } from "./linear-client.js"; + +export interface LinearSourceMetadata extends Record { + provider: "linear"; + issueId: string; + identifier: string; + url: string; + teamId?: string; + teamKey?: string; + teamName?: string; + stateName?: string; + stateType?: string; + assigneeId?: string; + assigneeName?: string; + createdAt?: string; + updatedAt?: string; +} + +export interface LinearDuplicateKeySet { + issueId: string; + identifier: string; + url: string; +} + +export interface LinearImportPreview { + title: string; + description: string; + sourceIssue: TaskSourceIssue; + sourceMetadata: LinearSourceMetadata; + duplicateKeys: LinearDuplicateKeySet; +} + +function cleanText(value: string | null | undefined): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function linearRepository(issue: LinearIssue): string { + return issue.team?.key ?? issue.team?.id ?? "linear"; +} + +export function getLinearDuplicateKeys(issue: Pick): LinearDuplicateKeySet { + return { + issueId: issue.id, + identifier: issue.identifier, + url: issue.url, + }; +} + +export function buildLinearSourceMetadata(issue: LinearIssue): LinearSourceMetadata { + return { + provider: "linear", + issueId: issue.id, + identifier: issue.identifier, + url: issue.url, + teamId: issue.team?.id, + teamKey: issue.team?.key, + teamName: issue.team?.name, + stateName: issue.state?.name, + stateType: issue.state?.type, + assigneeId: issue.assignee?.id, + assigneeName: issue.assignee?.name, + createdAt: issue.createdAt, + updatedAt: issue.updatedAt, + }; +} + +export function buildLinearImportPreview(issue: LinearIssue): LinearImportPreview { + const description = cleanText(issue.description) ?? "(no description)"; + const details = [ + description, + "", + `Source: ${issue.url}`, + `Linear: ${issue.identifier}`, + issue.team?.key || issue.team?.name ? `Team: ${issue.team.key ?? issue.team.name}` : undefined, + issue.state?.name ? `State: ${issue.state.name}` : undefined, + ].filter(Boolean).join("\n"); + + /* + FNXC:LinearImport 2026-07-02-00:00: + Imported Linear tasks need provider provenance that survives outside the plugin route response. Store the stable Linear issue id, human identifier, URL, and team/workspace hints in source metadata so duplicate detection works even when two teams reuse similar display identifiers. + */ + return { + title: `[${issue.identifier}] ${issue.title}`, + description: details, + sourceIssue: { + provider: "linear", + repository: linearRepository(issue), + externalIssueId: issue.id, + issueNumber: Number.parseInt(issue.identifier.replace(/^[A-Z]+-/iu, ""), 10) || 0, + url: issue.url, + }, + sourceMetadata: buildLinearSourceMetadata(issue), + duplicateKeys: getLinearDuplicateKeys(issue), + }; +} + +function getTaskSourceMetadata(task: Task): Record { + return task.source?.sourceMetadata ?? {}; +} + +export function taskMatchesLinearIssue(task: Task, issue: Pick): boolean { + const metadata = getTaskSourceMetadata(task); + if (task.sourceIssue?.provider === "linear") { + if (task.sourceIssue.externalIssueId === issue.id) return true; + if (task.sourceIssue.url && task.sourceIssue.url === issue.url) return true; + } + if (task.source?.sourceType === "api" && metadata.provider === "linear") { + if (metadata.issueId === issue.id || metadata.identifier === issue.identifier || metadata.url === issue.url) return true; + } + const description = typeof task.description === "string" ? task.description : ""; + return description.includes(`Source: ${issue.url}`); +} + +export async function findExistingLinearTask(taskStore: { listTasks?: (options?: Record) => Promise }, issue: Pick): Promise { + if (typeof taskStore.listTasks !== "function") return null; + const tasks = await taskStore.listTasks({ includeArchived: false, slim: false }); + return tasks.find((task) => taskMatchesLinearIssue(task, issue)) ?? null; +} + +export function buildLinearTaskCreateInput(issue: LinearIssue): TaskCreateInput { + const preview = buildLinearImportPreview(issue); + return { + title: preview.title, + description: preview.description, + column: "triage", + sourceIssue: preview.sourceIssue, + source: { + sourceType: "api", + sourceMetadata: preview.sourceMetadata, + }, + }; +} + +export interface ImportLinearIssueResult { + imported: boolean; + duplicate: boolean; + taskId?: string; + task?: Task; + issue: LinearImportPreview; +} + +export async function importLinearIssue(taskStore: { listTasks?: (options?: Record) => Promise; createTask?: (input: TaskCreateInput) => Promise }, issue: LinearIssue): Promise { + const preview = buildLinearImportPreview(issue); + const existing = await findExistingLinearTask(taskStore, issue); + if (existing) { + return { imported: false, duplicate: true, taskId: existing.id, task: existing, issue: preview }; + } + if (typeof taskStore.createTask !== "function") { + throw new Error("Plugin task store cannot create tasks in this context."); + } + const task = await taskStore.createTask(buildLinearTaskCreateInput(issue)); + return { imported: true, duplicate: false, taskId: task.id, task, issue: preview }; +} diff --git a/plugins/fusion-plugin-linear-import/src/index.ts b/plugins/fusion-plugin-linear-import/src/index.ts new file mode 100644 index 0000000000..d1e53cdb18 --- /dev/null +++ b/plugins/fusion-plugin-linear-import/src/index.ts @@ -0,0 +1,45 @@ +import { definePlugin } from "@fusion/plugin-sdk"; +import type { FusionPlugin } from "@fusion/plugin-sdk"; +import { linearSettingsSchema, LINEAR_PLUGIN_ID } from "./settings.js"; +import { linearImportRoutes } from "./routes.js"; +import { linearImportTools } from "./tools.js"; + +const dashboardViews = [ + { + viewId: "linear-import", + label: "Linear Import", + componentPath: "./dashboard-view", + icon: "ListPlus", + placement: "more" as const, + order: 55, + description: "Browse Linear issues and import selected issues as Fusion tasks.", + }, +]; + +/* +FNXC:LinearImport 2026-07-02-00:00: +FN-7443 explicitly routes Linear import through the plugin system. Keep this server entry free of React/CSS imports, and expose only plugin-owned settings, routes, tools, and dashboard-view metadata so Fusion does not grow host-owned /api/linear routes or core Linear settings. +*/ +const plugin: FusionPlugin = definePlugin({ + manifest: { + id: LINEAR_PLUGIN_ID, + name: "Linear Import", + version: "0.1.0", + description: "Import Linear issues into Fusion tasks through plugin-owned settings, routes, tools, and dashboard view.", + author: "Fusion", + fusionVersion: ">=0.1.0", + settingsSchema: linearSettingsSchema, + }, + state: "installed", + hooks: {}, + routes: linearImportRoutes, + tools: linearImportTools, + dashboardViews, +}); + +export default plugin; +export { linearSettingsSchema, resolveLinearSettings, hasLinearApiKey, LINEAR_PLUGIN_ID } from "./settings.js"; +export { LinearClient, LinearApiError, linearErrorToResponse, buildLinearIssueFilter, LINEAR_GRAPHQL_ENDPOINT } from "./linear-client.js"; +export { buildLinearImportPreview, buildLinearTaskCreateInput, findExistingLinearTask, importLinearIssue, taskMatchesLinearIssue } from "./import-linear.js"; +export { linearImportRoutes } from "./routes.js"; +export { linearImportTools } from "./tools.js"; diff --git a/plugins/fusion-plugin-linear-import/src/linear-client.ts b/plugins/fusion-plugin-linear-import/src/linear-client.ts new file mode 100644 index 0000000000..52719d73bb --- /dev/null +++ b/plugins/fusion-plugin-linear-import/src/linear-client.ts @@ -0,0 +1,247 @@ +export const LINEAR_GRAPHQL_ENDPOINT = "https://api.linear.app/graphql"; + +const DEFAULT_LIMIT = 30; +const MAX_LIMIT = 100; +const PAGE_SIZE = 50; +const MAX_PAGES = 5; + +export type LinearIssueStateFilter = "active" | "backlog" | "started" | "unstarted" | "completed" | "canceled" | "all"; + +export interface LinearIssue { + id: string; + identifier: string; + title: string; + description?: string | null; + url: string; + state?: { id?: string; name?: string; type?: string } | null; + team?: { id?: string; key?: string; name?: string } | null; + assignee?: { id?: string; name?: string; email?: string } | null; + creator?: { id?: string; name?: string; email?: string } | null; + labels: Array<{ id?: string; name: string }>; + createdAt?: string; + updatedAt?: string; +} + +export interface LinearIssueListOptions { + query?: string; + teamKey?: string; + state?: LinearIssueStateFilter; + assigneeId?: string; + limit?: number; + after?: string; +} + +export interface LinearIssueListResult { + issues: LinearIssue[]; + pageInfo: { hasNextPage: boolean; endCursor?: string | null }; +} + +export class LinearApiError extends Error { + constructor(public readonly status: number, message: string, public readonly code = "linear_api_error") { + super(message); + this.name = "LinearApiError"; + } +} + +export function clampLinearLimit(limit: unknown): number { + if (typeof limit !== "number" || !Number.isFinite(limit)) return DEFAULT_LIMIT; + return Math.max(1, Math.min(MAX_LIMIT, Math.floor(limit))); +} + +function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function redactSensitiveText(message: string, secrets: string[] = []): string { + let redacted = message; + for (const secret of secrets) { + if (secret) redacted = redacted.split(secret).join("[redacted]"); + } + return redacted.replace(/\b(token|key|secret)[-_:=][A-Za-z0-9._-]+/giu, "$1-[redacted]"); +} + +function normalizeLinearErrorMessage(status: number, body: unknown, secrets: string[] = []): string { + if (status === 401) return "Linear API key is missing, invalid, or expired."; + if (status === 403) return "Linear API key does not have access to the requested Linear workspace or team."; + if (status === 429) return "Linear rate limit exceeded. Try again later."; + const raw = asRecord(body); + const errors = Array.isArray(raw.errors) ? raw.errors : []; + const first = asRecord(errors[0]); + const message = asString(first.message) ?? asString(raw.error) ?? asString(raw.message); + return message ? `Linear GraphQL error: ${redactSensitiveText(message, secrets)}` : `Linear API request failed with status ${status}.`; +} + +function mapFetchError(error: unknown): LinearApiError { + if (error instanceof LinearApiError) return error; + return new LinearApiError(0, "Unable to reach Linear API. Check network access and plugin configuration.", "network_error"); +} + +export function buildLinearIssueFilter(options: LinearIssueListOptions): Record | undefined { + const and: Record[] = []; + if (options.teamKey?.trim()) { + and.push({ team: { or: [{ key: { eqIgnoreCase: options.teamKey.trim() } }, { id: { eq: options.teamKey.trim() } }] } }); + } + if (options.assigneeId?.trim()) { + and.push({ assignee: { id: { eq: options.assigneeId.trim() } } }); + } + if (options.state && options.state !== "all") { + and.push({ state: { type: { eq: options.state } } }); + } + if (options.query?.trim()) { + const q = options.query.trim(); + and.push({ or: [{ title: { containsIgnoreCase: q } }, { description: { containsIgnoreCase: q } }, { identifier: { containsIgnoreCase: q } }] }); + } + return and.length > 0 ? { and } : undefined; +} + +export const LINEAR_ISSUES_QUERY = `query FusionLinearImportIssues($first: Int!, $after: String, $filter: IssueFilter) { + issues(first: $first, after: $after, filter: $filter, orderBy: updatedAt) { + nodes { + id + identifier + title + description + url + createdAt + updatedAt + state { id name type } + team { id key name } + assignee { id name email } + creator { id name email } + labels { nodes { id name } } + } + pageInfo { hasNextPage endCursor } + } +}`; + +export const LINEAR_ISSUE_QUERY = `query FusionLinearImportIssue($id: String!) { + issue(id: $id) { + id + identifier + title + description + url + createdAt + updatedAt + state { id name type } + team { id key name } + assignee { id name email } + creator { id name email } + labels { nodes { id name } } + } +}`; + +function normalizeIssue(rawIssue: unknown): LinearIssue | null { + const raw = asRecord(rawIssue); + const id = asString(raw.id); + const identifier = asString(raw.identifier); + const title = asString(raw.title); + const url = asString(raw.url); + if (!id || !identifier || !title || !url) return null; + const labelsRaw = asRecord(raw.labels).nodes; + const labels = Array.isArray(labelsRaw) + ? labelsRaw.flatMap((label) => { + const rawLabel = asRecord(label); + const name = asString(rawLabel.name); + return name ? [{ id: asString(rawLabel.id), name }] : []; + }) + : []; + const state = asRecord(raw.state); + const team = asRecord(raw.team); + const assignee = asRecord(raw.assignee); + const creator = asRecord(raw.creator); + return { + id, + identifier, + title, + description: typeof raw.description === "string" ? raw.description : null, + url, + state: Object.keys(state).length ? { id: asString(state.id), name: asString(state.name), type: asString(state.type) } : null, + team: Object.keys(team).length ? { id: asString(team.id), key: asString(team.key), name: asString(team.name) } : null, + assignee: Object.keys(assignee).length ? { id: asString(assignee.id), name: asString(assignee.name), email: asString(assignee.email) } : null, + creator: Object.keys(creator).length ? { id: asString(creator.id), name: asString(creator.name), email: asString(creator.email) } : null, + labels, + createdAt: asString(raw.createdAt), + updatedAt: asString(raw.updatedAt), + }; +} + +export class LinearClient { + constructor(private readonly apiKey: string, private readonly fetchImpl: typeof fetch = fetch) {} + + private async request(query: string, variables: Record): Promise { + let response: Response; + try { + response = await this.fetchImpl(LINEAR_GRAPHQL_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + Authorization: this.apiKey, + }, + body: JSON.stringify({ query, variables }), + }); + } catch (error) { + throw mapFetchError(error); + } + + const body = await response.json().catch(() => ({})); + if (!response.ok) { + throw new LinearApiError(response.status, normalizeLinearErrorMessage(response.status, body, [this.apiKey])); + } + const errors = Array.isArray(asRecord(body).errors) ? asRecord(body).errors as unknown[] : []; + if (errors.length > 0) { + throw new LinearApiError(400, normalizeLinearErrorMessage(400, body, [this.apiKey]), "graphql_error"); + } + return asRecord(body).data as T; + } + + /* + FNXC:LinearImport 2026-07-02-00:00: + FN-7443 integrates Linear as a plugin-owned SaaS GraphQL HTTP client. Keep pagination bounded and errors credential-safe so plugin routes/tools can expose actionable failures without ever returning the API key. + */ + async listIssues(options: LinearIssueListOptions = {}): Promise { + const limit = clampLinearLimit(options.limit); + const issues: LinearIssue[] = []; + let after = options.after; + let pageInfo: LinearIssueListResult["pageInfo"] = { hasNextPage: false, endCursor: null }; + for (let page = 0; issues.length < limit && page < MAX_PAGES; page += 1) { + const data = await this.request<{ issues?: { nodes?: unknown[]; pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } } }>(LINEAR_ISSUES_QUERY, { + first: Math.min(PAGE_SIZE, limit - issues.length), + after: after ?? null, + filter: buildLinearIssueFilter(options) ?? null, + }); + const connection = data.issues ?? {}; + const nodes = Array.isArray(connection.nodes) ? connection.nodes : []; + issues.push(...nodes.map(normalizeIssue).filter((issue): issue is LinearIssue => Boolean(issue))); + pageInfo = { + hasNextPage: connection.pageInfo?.hasNextPage === true, + endCursor: connection.pageInfo?.endCursor ?? null, + }; + if (!pageInfo.hasNextPage || !pageInfo.endCursor || nodes.length === 0) break; + after = pageInfo.endCursor; + } + return { issues: issues.slice(0, limit), pageInfo }; + } + + async getIssue(idOrIdentifier: string): Promise { + const id = idOrIdentifier.trim(); + if (!id) throw new LinearApiError(400, "Linear issue id or identifier is required.", "validation_error"); + const data = await this.request<{ issue?: unknown }>(LINEAR_ISSUE_QUERY, { id }); + const issue = normalizeIssue(data.issue); + if (!issue) throw new LinearApiError(404, "Linear issue was not found or is inaccessible.", "not_found"); + return issue; + } +} + +export function linearErrorToResponse(error: unknown): { status: number; error: string; code: string } { + if (error instanceof LinearApiError) { + const status = error.status === 0 ? 502 : error.status; + return { status, error: error.message, code: error.code }; + } + return { status: 500, error: "Linear import failed unexpectedly.", code: "unexpected_error" }; +} diff --git a/plugins/fusion-plugin-linear-import/src/routes.ts b/plugins/fusion-plugin-linear-import/src/routes.ts new file mode 100644 index 0000000000..381cfd8a9b --- /dev/null +++ b/plugins/fusion-plugin-linear-import/src/routes.ts @@ -0,0 +1,149 @@ +import type { PluginContext, PluginRouteDefinition, PluginRouteResponse } from "@fusion/plugin-sdk"; +import { LinearClient, linearErrorToResponse, type LinearIssueListOptions } from "./linear-client.js"; +import { importLinearIssue } from "./import-linear.js"; +import { resolveLinearSettings } from "./settings.js"; + +interface RequestLike { + body?: unknown; + query?: Record; + params?: Record; +} + +function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +function readBody(req: unknown): Record { + return asRecord((req as RequestLike).body); +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function readNumber(value: unknown): number | undefined { + if (typeof value === "number") return value; + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +} + +function response(status: number, body: Record): PluginRouteResponse { + return { status, body }; +} + +function requireClient(ctx: PluginContext): LinearClient | PluginRouteResponse { + const settings = resolveLinearSettings(ctx.settings); + if (!settings.apiKey) { + return response(401, { ok: false, authenticated: false, error: "Configure a Linear API key in Plugin Manager settings before importing issues.", code: "missing_api_key" }); + } + return new LinearClient(settings.apiKey); +} + +function readListOptions(ctx: PluginContext, source: Record): LinearIssueListOptions { + const settings = resolveLinearSettings(ctx.settings); + return { + query: readString(source.query), + teamKey: readString(source.teamKey) ?? readString(source.teamId) ?? settings.defaultTeamKey, + state: (readString(source.state) ?? settings.defaultStateFilter) as LinearIssueListOptions["state"], + assigneeId: readString(source.assigneeId) ?? settings.defaultAssigneeId, + limit: readNumber(source.limit), + after: readString(source.after), + }; +} + +function errorResponse(error: unknown): PluginRouteResponse { + const mapped = linearErrorToResponse(error); + return response(mapped.status, { ok: false, error: mapped.error, code: mapped.code }); +} + +export async function getLinearStatus(_req: unknown, ctx: PluginContext): Promise { + const settings = resolveLinearSettings(ctx.settings); + if (!settings.apiKey) { + return response(200, { ok: true, authenticated: false, configured: false, defaultTeamKey: settings.defaultTeamKey ?? null, defaultStateFilter: settings.defaultStateFilter }); + } + try { + const client = new LinearClient(settings.apiKey); + await client.listIssues({ limit: 1, teamKey: settings.defaultTeamKey, state: settings.defaultStateFilter, assigneeId: settings.defaultAssigneeId }); + return response(200, { ok: true, authenticated: true, configured: true, defaultTeamKey: settings.defaultTeamKey ?? null, defaultStateFilter: settings.defaultStateFilter }); + } catch (error) { + const mapped = linearErrorToResponse(error); + return response(mapped.status, { ok: false, authenticated: false, configured: true, error: mapped.error, code: mapped.code }); + } +} + +export async function listLinearIssues(req: unknown, ctx: PluginContext): Promise { + const client = requireClient(ctx); + if (!(client instanceof LinearClient)) return client; + try { + const query = { ...asRecord((req as RequestLike).query), ...readBody(req) }; + const result = await client.listIssues(readListOptions(ctx, query)); + return response(200, { ok: true, issues: result.issues, pageInfo: result.pageInfo }); + } catch (error) { + return errorResponse(error); + } +} + +export async function getLinearIssueDetail(req: unknown, ctx: PluginContext): Promise { + const client = requireClient(ctx); + if (!(client instanceof LinearClient)) return client; + const body = readBody(req); + const issueId = readString(body.issueId) ?? readString(body.id) ?? readString(body.identifier); + if (!issueId) return response(400, { ok: false, error: "issueId or identifier is required.", code: "validation_error" }); + try { + const issue = await client.getIssue(issueId); + return response(200, { ok: true, issue }); + } catch (error) { + return errorResponse(error); + } +} + +export async function importSingleLinearIssue(req: unknown, ctx: PluginContext): Promise { + const client = requireClient(ctx); + if (!(client instanceof LinearClient)) return client; + const body = readBody(req); + const issueId = readString(body.issueId) ?? readString(body.id) ?? readString(body.identifier); + if (!issueId) return response(400, { ok: false, error: "issueId or identifier is required.", code: "validation_error" }); + try { + const issue = await client.getIssue(issueId); + const result = await importLinearIssue(ctx.taskStore, issue); + return response(result.duplicate ? 200 : 201, { ok: true, ...result, task: undefined }); + } catch (error) { + return errorResponse(error); + } +} + +export async function importBatchLinearIssues(req: unknown, ctx: PluginContext): Promise { + const client = requireClient(ctx); + if (!(client instanceof LinearClient)) return client; + const body = readBody(req); + const issueIds = Array.isArray(body.issueIds) ? body.issueIds.map(readString).filter((id): id is string => Boolean(id)) : []; + if (issueIds.length === 0) return response(400, { ok: false, error: "issueIds must include at least one Linear issue id or identifier.", code: "validation_error" }); + if (issueIds.length > 25) return response(400, { ok: false, error: "Batch import is limited to 25 Linear issues at a time.", code: "limit_exceeded" }); + try { + const results = []; + for (const issueId of issueIds) { + const issue = await client.getIssue(issueId); + const result = await importLinearIssue(ctx.taskStore, issue); + results.push({ ...result, task: undefined }); + } + return response(200, { + ok: true, + results, + imported: results.filter((result) => result.imported).length, + duplicates: results.filter((result) => result.duplicate).length, + }); + } catch (error) { + return errorResponse(error); + } +} + +export const linearImportRoutes: PluginRouteDefinition[] = [ + { method: "GET", path: "/status", handler: getLinearStatus, description: "Check Linear import plugin authentication status." }, + { method: "POST", path: "/issues", handler: listLinearIssues, description: "List/search Linear issues using plugin settings." }, + { method: "POST", path: "/issues/detail", handler: getLinearIssueDetail, description: "Fetch one Linear issue by id or identifier." }, + { method: "POST", path: "/issues/import", handler: importSingleLinearIssue, description: "Import one Linear issue as a Fusion task." }, + { method: "POST", path: "/issues/import-batch", handler: importBatchLinearIssues, description: "Import selected Linear issues as Fusion tasks." }, +]; diff --git a/plugins/fusion-plugin-linear-import/src/settings.ts b/plugins/fusion-plugin-linear-import/src/settings.ts new file mode 100644 index 0000000000..3763359d2e --- /dev/null +++ b/plugins/fusion-plugin-linear-import/src/settings.ts @@ -0,0 +1,68 @@ +import type { PluginSettingSchema } from "@fusion/plugin-sdk"; + +export const LINEAR_PLUGIN_ID = "fusion-plugin-linear-import"; + +export const linearSettingsSchema: Record = { + apiKey: { + type: "password", + label: "Linear API key", + description: "Personal Linear API key used only by this plugin to browse and import issues.", + required: true, + group: "Authentication", + }, + defaultTeamKey: { + type: "string", + label: "Default team key or ID", + description: "Optional Linear team key or UUID to prefill issue searches.", + group: "Defaults", + }, + defaultStateFilter: { + type: "enum", + label: "Default issue state filter", + description: "Initial issue state filter for browse and import tools.", + enumValues: ["active", "backlog", "started", "unstarted", "completed", "canceled", "all"], + defaultValue: "active", + group: "Defaults", + }, + defaultAssigneeId: { + type: "string", + label: "Default assignee ID", + description: "Optional Linear user UUID used as the default assignee filter.", + group: "Defaults", + }, +}; + +export type LinearStateFilter = "active" | "backlog" | "started" | "unstarted" | "completed" | "canceled" | "all"; + +export interface LinearPluginSettings { + apiKey?: string; + defaultTeamKey?: string; + defaultStateFilter: LinearStateFilter; + defaultAssigneeId?: string; +} + +function optionalTrimmed(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +export function resolveLinearSettings(settings: Record): LinearPluginSettings { + const rawState = optionalTrimmed(settings.defaultStateFilter); + const defaultStateFilter = rawState && linearSettingsSchema.defaultStateFilter.enumValues?.includes(rawState) + ? rawState as LinearStateFilter + : "active"; + + /* + FNXC:LinearImport 2026-07-02-00:00: + FN-7443 requires Linear credentials to be plugin-owned settings, not host settings. Resolve only sanitized defaults here and keep the password value out of logs, task descriptions, route responses, and tool details. + */ + return { + apiKey: optionalTrimmed(settings.apiKey), + defaultTeamKey: optionalTrimmed(settings.defaultTeamKey), + defaultStateFilter, + defaultAssigneeId: optionalTrimmed(settings.defaultAssigneeId), + }; +} + +export function hasLinearApiKey(settings: Record): boolean { + return Boolean(resolveLinearSettings(settings).apiKey); +} diff --git a/plugins/fusion-plugin-linear-import/src/tools.ts b/plugins/fusion-plugin-linear-import/src/tools.ts new file mode 100644 index 0000000000..fbdc379889 --- /dev/null +++ b/plugins/fusion-plugin-linear-import/src/tools.ts @@ -0,0 +1,138 @@ +import type { PluginContext, PluginToolDefinition, PluginToolResult } from "@fusion/plugin-sdk"; +import { LinearClient, linearErrorToResponse, type LinearIssueListOptions } from "./linear-client.js"; +import { importLinearIssue } from "./import-linear.js"; +import { resolveLinearSettings } from "./settings.js"; + +function textResult(text: string, details?: Record, isError = false): PluginToolResult { + return { content: [{ type: "text", text }], details, isError }; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function readNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function getClient(ctx: PluginContext): LinearClient | PluginToolResult { + const settings = resolveLinearSettings(ctx.settings); + if (!settings.apiKey) { + return textResult("Linear Import is not configured. Add a Linear API key in Plugin Manager settings.", { code: "missing_api_key" }, true); + } + return new LinearClient(settings.apiKey); +} + +function listOptions(params: Record, ctx: PluginContext): LinearIssueListOptions { + const settings = resolveLinearSettings(ctx.settings); + return { + query: readString(params.query), + teamKey: readString(params.teamKey) ?? readString(params.teamId) ?? settings.defaultTeamKey, + state: (readString(params.state) ?? settings.defaultStateFilter) as LinearIssueListOptions["state"], + assigneeId: readString(params.assigneeId) ?? settings.defaultAssigneeId, + limit: readNumber(params.limit), + after: readString(params.after), + }; +} + +function safeErrorResult(error: unknown): PluginToolResult { + const mapped = linearErrorToResponse(error); + return textResult(`Linear import failed: ${mapped.error}`, { code: mapped.code, status: mapped.status }, true); +} + +const browseParams = { + type: "object", + properties: { + query: { type: "string", description: "Optional text search over Linear issue title, description, or identifier." }, + teamKey: { type: "string", description: "Optional Linear team key or UUID." }, + state: { type: "string", enum: ["active", "backlog", "started", "unstarted", "completed", "canceled", "all"], description: "Optional Linear state type filter." }, + assigneeId: { type: "string", description: "Optional Linear assignee user UUID." }, + limit: { type: "number", minimum: 1, maximum: 100, description: "Maximum issues to return." }, + after: { type: "string", description: "Optional Linear pagination cursor." }, + }, + required: [], +}; + +export const browseLinearIssuesTool: PluginToolDefinition = { + name: "linear_import_browse_issues", + description: "Browse/search Linear issues through the Linear Import plugin settings.", + parameters: browseParams, + execute: async (params, ctx) => { + const client = getClient(ctx); + if (!(client instanceof LinearClient)) return client; + try { + const result = await client.listIssues(listOptions(params, ctx)); + const lines = result.issues.length === 0 + ? ["No Linear issues matched the filters."] + : result.issues.map((issue) => `- ${issue.identifier}: ${issue.title} (${issue.url})`); + return textResult(lines.join("\n"), { issues: result.issues, pageInfo: result.pageInfo }); + } catch (error) { + return safeErrorResult(error); + } + }, +}; + +export const importLinearIssueTool: PluginToolDefinition = { + name: "linear_import_issue", + description: "Import one Linear issue into Fusion as a triage task, skipping duplicates.", + parameters: { + type: "object", + properties: { + issueId: { type: "string", description: "Linear issue UUID or human identifier such as ENG-123." }, + }, + required: ["issueId"], + }, + execute: async (params, ctx) => { + const issueId = readString(params.issueId); + if (!issueId) return textResult("issueId is required.", { code: "validation_error" }, true); + const client = getClient(ctx); + if (!(client instanceof LinearClient)) return client; + try { + const issue = await client.getIssue(issueId); + const result = await importLinearIssue(ctx.taskStore, issue); + const message = result.duplicate + ? `Skipped duplicate Linear issue ${issue.identifier}; existing task ${result.taskId}.` + : `Imported Linear issue ${issue.identifier} as task ${result.taskId}.`; + return textResult(message, { imported: result.imported, duplicate: result.duplicate, taskId: result.taskId, issue: result.issue }); + } catch (error) { + return safeErrorResult(error); + } + }, +}; + +export const importLinearIssuesTool: PluginToolDefinition = { + name: "linear_import_issues", + description: "Import multiple Linear issues into Fusion as triage tasks, skipping duplicates.", + parameters: { + type: "object", + properties: { + issueIds: { type: "array", items: { type: "string" }, description: "Linear issue UUIDs or identifiers to import. Maximum 25." }, + }, + required: ["issueIds"], + }, + execute: async (params, ctx) => { + const issueIds = Array.isArray(params.issueIds) ? params.issueIds.map(readString).filter((id): id is string => Boolean(id)) : []; + if (issueIds.length === 0) return textResult("issueIds must include at least one Linear issue id or identifier.", { code: "validation_error" }, true); + if (issueIds.length > 25) return textResult("Batch import is limited to 25 Linear issues at a time.", { code: "limit_exceeded" }, true); + const client = getClient(ctx); + if (!(client instanceof LinearClient)) return client; + try { + const results = []; + for (const issueId of issueIds) { + const issue = await client.getIssue(issueId); + results.push(await importLinearIssue(ctx.taskStore, issue)); + } + const imported = results.filter((result) => result.imported).length; + const duplicates = results.filter((result) => result.duplicate).length; + return textResult(`Linear batch import complete: ${imported} imported, ${duplicates} duplicates skipped.`, { + imported, + duplicates, + results: results.map((result) => ({ imported: result.imported, duplicate: result.duplicate, taskId: result.taskId, issue: result.issue })), + }); + } catch (error) { + return safeErrorResult(error); + } + }, +}; + +export const linearImportTools: PluginToolDefinition[] = [browseLinearIssuesTool, importLinearIssueTool, importLinearIssuesTool]; diff --git a/plugins/fusion-plugin-linear-import/tsconfig.json b/plugins/fusion-plugin-linear-import/tsconfig.json new file mode 100644 index 0000000000..79d8a481c1 --- /dev/null +++ b/plugins/fusion-plugin-linear-import/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "./src", + "jsx": "react-jsx", + "types": ["node", "react"], + "paths": { + "@fusion/dashboard/app/plugins/types": ["./src/dashboard-interop.d.ts"] + } + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"], + "exclude": ["dist", "node_modules", "src/**/__tests__/**"] +} diff --git a/plugins/fusion-plugin-linear-import/vitest.config.ts b/plugins/fusion-plugin-linear-import/vitest.config.ts new file mode 100644 index 0000000000..f4cea46026 --- /dev/null +++ b/plugins/fusion-plugin-linear-import/vitest.config.ts @@ -0,0 +1,40 @@ +import { defineConfig } from "vitest/config"; +import { fileURLToPath } from "node:url"; +import { computeMaxWorkers } from "../../packages/core/src/__test-utils__/vitest-workers"; + +const maxWorkers = computeMaxWorkers(); + +export default defineConfig({ + resolve: { + alias: { + "@fusion/core": fileURLToPath(new URL("../../packages/core/src/index.ts", import.meta.url)), + "@fusion/plugin-sdk": fileURLToPath(new URL("../../packages/plugin-sdk/src/index.ts", import.meta.url)), + "@fusion/dashboard/app/plugins/types": fileURLToPath(new URL("../../packages/dashboard/app/plugins/types.ts", import.meta.url)), + }, + }, + test: { + setupFiles: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-setup.ts", import.meta.url))], + globalSetup: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-teardown.ts", import.meta.url))], + pool: "threads", + maxWorkers, + minWorkers: 1, + projects: [ + { + extends: true, + test: { + name: "linear-import-dashboard", + environment: "jsdom", + include: ["src/**/__tests__/**/*.test.tsx"], + }, + }, + { + extends: true, + test: { + name: "linear-import-node", + environment: "node", + include: ["src/**/__tests__/**/*.test.ts"], + }, + }, + ], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 50a5e6b478..85237ff277 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -978,6 +978,46 @@ importers: specifier: ^4.1.0 version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.8)(happy-dom@20.10.1)(jsdom@29.0.1)(vite@6.4.1(@types/node@25.5.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + plugins/fusion-plugin-linear-import: + dependencies: + '@fusion/core': + specifier: workspace:* + version: link:../../packages/core + '@fusion/plugin-sdk': + specifier: workspace:* + version: link:../../packages/plugin-sdk + lucide-react: + specifier: ^0.542.0 + version: 0.542.0(react@19.2.4) + devDependencies: + '@testing-library/jest-dom': + specifier: ^6.6.3 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) + '@types/node': + specifier: ^25.5.2 + version: 25.5.2 + '@types/react': + specifier: ^19.0.0 + version: 19.2.14 + react: + specifier: 19.2.4 + version: 19.2.4 + react-dom: + specifier: 19.2.4 + version: 19.2.4(react@19.2.4) + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^4.1.0 + version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.8)(happy-dom@20.10.1)(jsdom@29.0.1)(vite@6.4.1(@types/node@25.5.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + plugins/fusion-plugin-openclaw-runtime: dependencies: '@fusion/plugin-sdk': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 79c2758636..6644bf1570 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -29,3 +29,4 @@ packages: - "plugins/fusion-plugin-even-realities-glasses" - "plugins/fusion-plugin-reports" - "plugins/fusion-plugin-compound-engineering" + - "plugins/fusion-plugin-linear-import"