Files
fusion/plugins/fusion-plugin-linear-import/src/routes.ts
gsxdsm dfb6e5270d 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>
2026-07-02 14:31:16 -07:00

150 lines
7.0 KiB
TypeScript

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