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:
7
.changeset/fn-7443-linear-import-plugin.md
Normal file
7
.changeset/fn-7443-linear-import-plugin.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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/");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
75
plugins/fusion-plugin-linear-import/README.md
Normal file
75
plugins/fusion-plugin-linear-import/README.md
Normal 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)
|
||||
48
plugins/fusion-plugin-linear-import/manifest.json
Normal file
48
plugins/fusion-plugin-linear-import/manifest.json
Normal 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."
|
||||
}
|
||||
]
|
||||
}
|
||||
38
plugins/fusion-plugin-linear-import/package.json
Normal file
38
plugins/fusion-plugin-linear-import/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
11
plugins/fusion-plugin-linear-import/scripts/copy-css.mjs
Normal file
11
plugins/fusion-plugin-linear-import/scripts/copy-css.mjs
Normal 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);
|
||||
}
|
||||
167
plugins/fusion-plugin-linear-import/src/LinearImportView.css
Normal file
167
plugins/fusion-plugin-linear-import/src/LinearImportView.css
Normal 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;
|
||||
}
|
||||
}
|
||||
263
plugins/fusion-plugin-linear-import/src/LinearImportView.tsx
Normal file
263
plugins/fusion-plugin-linear-import/src/LinearImportView.tsx
Normal 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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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" }));
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
13
plugins/fusion-plugin-linear-import/src/dashboard-interop.d.ts
vendored
Normal file
13
plugins/fusion-plugin-linear-import/src/dashboard-interop.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
10
plugins/fusion-plugin-linear-import/src/dashboard-view.tsx
Normal file
10
plugins/fusion-plugin-linear-import/src/dashboard-view.tsx
Normal 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 };
|
||||
154
plugins/fusion-plugin-linear-import/src/import-linear.ts
Normal file
154
plugins/fusion-plugin-linear-import/src/import-linear.ts
Normal 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 };
|
||||
}
|
||||
45
plugins/fusion-plugin-linear-import/src/index.ts
Normal file
45
plugins/fusion-plugin-linear-import/src/index.ts
Normal 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";
|
||||
247
plugins/fusion-plugin-linear-import/src/linear-client.ts
Normal file
247
plugins/fusion-plugin-linear-import/src/linear-client.ts
Normal 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" };
|
||||
}
|
||||
149
plugins/fusion-plugin-linear-import/src/routes.ts
Normal file
149
plugins/fusion-plugin-linear-import/src/routes.ts
Normal 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." },
|
||||
];
|
||||
68
plugins/fusion-plugin-linear-import/src/settings.ts
Normal file
68
plugins/fusion-plugin-linear-import/src/settings.ts
Normal 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);
|
||||
}
|
||||
138
plugins/fusion-plugin-linear-import/src/tools.ts
Normal file
138
plugins/fusion-plugin-linear-import/src/tools.ts
Normal 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];
|
||||
14
plugins/fusion-plugin-linear-import/tsconfig.json
Normal file
14
plugins/fusion-plugin-linear-import/tsconfig.json
Normal 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__/**"]
|
||||
}
|
||||
40
plugins/fusion-plugin-linear-import/vitest.config.ts
Normal file
40
plugins/fusion-plugin-linear-import/vitest.config.ts
Normal 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
40
pnpm-lock.yaml
generated
@@ -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':
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user