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>
125 lines
6.9 KiB
TypeScript
125 lines
6.9 KiB
TypeScript
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();
|
|
});
|
|
});
|