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>
139 lines
6.3 KiB
TypeScript
139 lines
6.3 KiB
TypeScript
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];
|