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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-02 14:31:16 -07:00
parent 3fac409cf9
commit dfb6e5270d
39 changed files with 2128 additions and 1 deletions

View File

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

View File

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

View File

@@ -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: <url>`, 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.

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = `<!doctype html>
<html lang="en">
<head>
@@ -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 });
}

View File

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

View File

@@ -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<string, ComponentType<{ context?: PluginDashboardViewContext }>>;
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 {

View File

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

View File

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

View File

@@ -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/");
}
});
});

View File

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

View File

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

View File

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

View File

@@ -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: <Linear URL>`, 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: <https://developers.linear.app/>
- API docs URL: <https://developers.linear.app/docs/graphql/working-with-the-graphql-api>
- GraphQL API endpoint: <https://api.linear.app/graphql>
- 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)

View File

@@ -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."
}
]
}

View File

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

View File

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

View File

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

View File

@@ -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<T> {
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<string, string> {
return context?.projectId ? { projectId: context.projectId } : {};
}
async function postPluginRoute<T>(path: string, body: Record<string, unknown>, context?: PluginDashboardViewContext): Promise<RouteResponse<T>> {
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<T>;
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<RouteResponse<unknown>> {
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<unknown>;
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 (
<span className={`linear-import-view__status linear-import-view__status--${className}`} aria-live="polite">
<Icon aria-hidden="true" />
{message ?? (state === "authenticated" ? "Linear connected" : state === "loading" ? "Checking Linear" : state === "missing" ? "API key needed" : "Linear unavailable")}
</span>
);
}
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<StatusState>("loading");
const [statusMessage, setStatusMessage] = useState<string>();
const [query, setQuery] = useState("");
const [teamKey, setTeamKey] = useState("");
const [state, setState] = useState("active");
const [assigneeId, setAssigneeId] = useState("");
const [issues, setIssues] = useState<LinearIssue[]>([]);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [previewId, setPreviewId] = useState<string>();
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<LinearIssue[]>("/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 (
<section className="linear-import-view" aria-labelledby="linear-import-heading">
<header className="linear-import-view__header">
<div>
<p className="linear-import-view__eyebrow">Bundled plugin</p>
<h1 id="linear-import-heading" className="linear-import-view__title">Linear Import</h1>
<p className="linear-import-view__subtitle">Browse Linear issues and import selected issues into Fusion triage.</p>
</div>
<StatusBadge state={status} message={statusMessage} />
</header>
{status === "missing" ? (
<div className="card linear-import-view__message linear-import-view__message--info" role="status">
Configure the Linear Import plugin in Plugin Manager settings with a Linear API key, then return here to browse issues.
</div>
) : null}
<form className="card linear-import-view__filters" onSubmit={(event) => { event.preventDefault(); void browse(); }}>
<label className="linear-import-view__field">
<span>Search</span>
<input className="input" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Title, description, or ENG-123" />
</label>
<label className="linear-import-view__field">
<span>Team key or ID</span>
<input className="input" value={teamKey} onChange={(event) => setTeamKey(event.target.value)} placeholder="ENG" />
</label>
<label className="linear-import-view__field">
<span>State</span>
<select className="input" value={state} onChange={(event) => setState(event.target.value)}>
<option value="active">Active</option>
<option value="backlog">Backlog</option>
<option value="started">Started</option>
<option value="unstarted">Unstarted</option>
<option value="completed">Completed</option>
<option value="canceled">Canceled</option>
<option value="all">All</option>
</select>
</label>
<label className="linear-import-view__field">
<span>Assignee ID</span>
<input className="input" value={assigneeId} onChange={(event) => setAssigneeId(event.target.value)} placeholder="Optional Linear user UUID" />
</label>
<div className="linear-import-view__actions">
<button className="btn" type="submit" disabled={!canSearch} aria-disabled={!canSearch}>{loading ? "Loading…" : "Browse issues"}</button>
<button className="btn" type="button" disabled={selectedIssues.length === 0 || loading || status !== "authenticated"} aria-disabled={selectedIssues.length === 0 || loading || status !== "authenticated"} onClick={() => void importSelected()}>Import selected</button>
</div>
</form>
{message ? <div className={`linear-import-view__message linear-import-view__message--${message.type}`} role="status">{message.text}</div> : null}
<div className="linear-import-view__content">
<section className="card" aria-label="Linear issue results">
<h2>Issues</h2>
{loading ? <p className="linear-import-view__empty"><Loader2 aria-hidden="true" /> Loading Linear issues…</p> : null}
{!loading && issues.length === 0 ? <p className="linear-import-view__empty">No Linear issues loaded yet. Browse to see matching issues.</p> : null}
<div className="linear-import-view__issue-list">
{issues.map((issue) => (
<article key={issue.id} className="card linear-import-view__issue">
<input aria-label={`Select ${issue.identifier}`} type="checkbox" checked={selectedIds.has(issue.id)} onChange={() => toggleIssue(issue.id)} />
<div className="linear-import-view__issue-main">
<h3 className="linear-import-view__issue-title"><span className="linear-import-view__identifier">{issue.identifier}</span><span>{issue.title}</span></h3>
<p className="linear-import-view__meta">{issueSummary(issue)}</p>
<div className="linear-import-view__badges" aria-label="Issue metadata">
{issue.team?.key ? <span className="linear-import-view__badge">{issue.team.key}</span> : null}
{issue.state?.name ? <span className="linear-import-view__badge">{issue.state.name}</span> : null}
</div>
<div className="linear-import-view__issue-actions">
<button className="btn" type="button" onClick={() => setPreviewId(issue.id)}>Preview</button>
<button className="btn" type="button" disabled={loading || status !== "authenticated"} aria-disabled={loading || status !== "authenticated"} onClick={() => void importOne(issue)}>Import</button>
</div>
</div>
</article>
))}
</div>
</section>
<aside className="card linear-import-view__preview" aria-label="Linear issue preview">
<h2>Preview</h2>
{previewIssue ? (
<>
<h3>{previewIssue.identifier}: {previewIssue.title}</h3>
<p className="linear-import-view__meta">{issueSummary(previewIssue)}</p>
<pre className="linear-import-view__preview-body">{previewIssue.description?.trim() || "(no description)"}</pre>
<a href={previewIssue.url} target="_blank" rel="noreferrer">Open in Linear</a>
<button className="btn" type="button" disabled={loading || status !== "authenticated"} aria-disabled={loading || status !== "authenticated"} onClick={() => void importOne(previewIssue)}>Import previewed issue</button>
</>
) : (
<p className="linear-import-view__empty">Select or browse an issue to preview its description before importing.</p>
)}
</aside>
</div>
</section>
);
}
export default LinearImportView;

View File

@@ -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(<LinearImportView context={{ projectId: "proj-1" } as any} />);
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(<LinearImportView context={{ projectId: "proj-1" } as any} />);
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(<LinearImportView context={{ projectId: "proj-1" } as any} />);
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(<LinearImportView context={{ projectId: "proj-1" } as any} />);
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(<LinearImportView context={{ projectId: "proj-1" } as any} />);
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(<LinearImportView context={{ projectId: "proj-1" } as any} />);
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(<LinearImportView context={{ projectId: "proj-1" } as any} />);
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(<LinearImportView context={{ projectId: "proj-1" } as any} />);
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();
});
});

View File

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

View File

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

View File

@@ -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<string, unknown> = { 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");
});
});

View File

@@ -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<string, unknown> = { 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");
});
});

View File

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

View File

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

View File

@@ -0,0 +1,154 @@
import type { Task, TaskCreateInput, TaskSourceIssue } from "@fusion/core";
import type { LinearIssue } from "./linear-client.js";
export interface LinearSourceMetadata extends Record<string, unknown> {
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<LinearIssue, "id" | "identifier" | "url">): 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<string, unknown> {
return task.source?.sourceMetadata ?? {};
}
export function taskMatchesLinearIssue(task: Task, issue: Pick<LinearIssue, "id" | "identifier" | "url">): 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<string, unknown>) => Promise<Task[]> }, issue: Pick<LinearIssue, "id" | "identifier" | "url">): Promise<Task | null> {
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<string, unknown>) => Promise<Task[]>; createTask?: (input: TaskCreateInput) => Promise<Task> }, issue: LinearIssue): Promise<ImportLinearIssueResult> {
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 };
}

View File

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

View File

@@ -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<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
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<string, unknown> | undefined {
const and: Record<string, unknown>[] = [];
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<T>(query: string, variables: Record<string, unknown>): Promise<T> {
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<LinearIssueListResult> {
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<LinearIssue> {
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" };
}

View File

@@ -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<string, unknown>;
params?: Record<string, unknown>;
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function readBody(req: unknown): Record<string, unknown> {
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<string, unknown>): 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<string, unknown>): 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<PluginRouteResponse> {
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<PluginRouteResponse> {
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<PluginRouteResponse> {
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<PluginRouteResponse> {
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<PluginRouteResponse> {
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." },
];

View File

@@ -0,0 +1,68 @@
import type { PluginSettingSchema } from "@fusion/plugin-sdk";
export const LINEAR_PLUGIN_ID = "fusion-plugin-linear-import";
export const linearSettingsSchema: Record<string, PluginSettingSchema> = {
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<string, unknown>): 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<string, unknown>): boolean {
return Boolean(resolveLinearSettings(settings).apiKey);
}

View File

@@ -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<string, unknown>, 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<string, unknown>, 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];

View File

@@ -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__/**"]
}

View File

@@ -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"],
},
},
],
},
});

40
pnpm-lock.yaml generated
View File

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

View File

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