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>
155 lines
5.4 KiB
TypeScript
155 lines
5.4 KiB
TypeScript
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 };
|
|
}
|