feat(FN-1114): merge fusion/fn-1114 (auto-resolved)
- feat(FN-1114): complete all steps — plugin examples, docs, and scaffold command
This commit is contained in:
@@ -0,0 +1,324 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import plugin from "../index.js";
|
||||
|
||||
// ── Mock Context ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface MockLogger {
|
||||
info: ReturnType<typeof vi.fn>;
|
||||
warn: ReturnType<typeof vi.fn>;
|
||||
error: ReturnType<typeof vi.fn>;
|
||||
debug: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
interface MockContext {
|
||||
pluginId: string;
|
||||
settings: Record<string, unknown>;
|
||||
logger: MockLogger;
|
||||
emitEvent: ReturnType<typeof vi.fn>;
|
||||
taskStore: {
|
||||
getTask: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
}
|
||||
|
||||
function createMockContext(overrides: Partial<MockContext> = {}): MockContext {
|
||||
return {
|
||||
pluginId: "fusion-plugin-ci-status",
|
||||
settings: {
|
||||
ciUrl: "https://ci.example.com/api",
|
||||
pollIntervalMs: 60000,
|
||||
branchPrefix: "fusion/",
|
||||
},
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
emitEvent: vi.fn(),
|
||||
taskStore: {
|
||||
getTask: vi.fn(),
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Mock Request/Response ──────────────────────────────────────────────────────
|
||||
|
||||
function createMockRequest(overrides: Partial<{ params: Record<string, string>; method: string; url: string }> = {}): {
|
||||
params: Record<string, string>;
|
||||
method: string;
|
||||
url: string;
|
||||
} {
|
||||
return {
|
||||
params: {},
|
||||
method: "GET",
|
||||
url: "/status",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createMockResponse() {
|
||||
const json = vi.fn();
|
||||
const status = vi.fn().mockReturnThis();
|
||||
return { json, status };
|
||||
}
|
||||
|
||||
// ── Test Suite ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ci-status plugin", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("plugin export", () => {
|
||||
it("should export a valid FusionPlugin with correct manifest fields", () => {
|
||||
expect(plugin.manifest.id).toBe("fusion-plugin-ci-status");
|
||||
expect(plugin.manifest.name).toBe("CI Status Plugin");
|
||||
expect(plugin.manifest.version).toBe("0.1.0");
|
||||
expect(plugin.state).toBe("installed");
|
||||
});
|
||||
|
||||
it("should have routes array with 3 routes", () => {
|
||||
expect(plugin.routes).toBeDefined();
|
||||
expect(plugin.routes!.length).toBe(3);
|
||||
});
|
||||
|
||||
it("should have GET /status route", () => {
|
||||
const route = plugin.routes!.find(
|
||||
(r) => r.method === "GET" && r.path === "/status",
|
||||
);
|
||||
expect(route).toBeDefined();
|
||||
expect(route!.description).toBe("Get status of all tracked branches");
|
||||
});
|
||||
|
||||
it("should have GET /status/:branch route", () => {
|
||||
const route = plugin.routes!.find(
|
||||
(r) => r.method === "GET" && r.path === "/status/:branch",
|
||||
);
|
||||
expect(route).toBeDefined();
|
||||
expect(route!.description).toBe("Get status of a specific branch");
|
||||
});
|
||||
|
||||
it("should have POST /refresh route", () => {
|
||||
const route = plugin.routes!.find(
|
||||
(r) => r.method === "POST" && r.path === "/refresh",
|
||||
);
|
||||
expect(route).toBeDefined();
|
||||
expect(route!.description).toBe("Trigger an immediate CI status refresh");
|
||||
});
|
||||
|
||||
it("should have onLoad, onUnload, and onTaskMoved hooks", () => {
|
||||
expect(plugin.hooks.onLoad).toBeDefined();
|
||||
expect(plugin.hooks.onUnload).toBeDefined();
|
||||
expect(plugin.hooks.onTaskMoved).toBeDefined();
|
||||
});
|
||||
|
||||
it("should have settings schema defined", () => {
|
||||
expect(plugin.manifest.settingsSchema).toBeDefined();
|
||||
expect(plugin.manifest.settingsSchema!.ciUrl).toBeDefined();
|
||||
expect(plugin.manifest.settingsSchema!.pollIntervalMs).toBeDefined();
|
||||
expect(plugin.manifest.settingsSchema!.branchPrefix).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hooks.onLoad", () => {
|
||||
it("should log startup message", async () => {
|
||||
const ctx = createMockContext();
|
||||
await plugin.hooks.onLoad?.(ctx as any);
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith("CI Status plugin loaded");
|
||||
});
|
||||
|
||||
it("should start an interval for polling", async () => {
|
||||
const ctx = createMockContext();
|
||||
await plugin.hooks.onLoad?.(ctx as any);
|
||||
|
||||
// The interval should be set
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining("CI polling started"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hooks.onUnload", () => {
|
||||
it("should log shutdown message", async () => {
|
||||
const ctx = createMockContext();
|
||||
await plugin.hooks.onLoad?.(ctx as any);
|
||||
vi.clearAllMocks();
|
||||
|
||||
await plugin.hooks.onUnload?.();
|
||||
|
||||
// Should log that plugin is shutting down (logged as part of onUnload)
|
||||
expect(ctx.logger.info).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hooks.onTaskMoved", () => {
|
||||
const mockTask = {
|
||||
id: "FN-001",
|
||||
title: "Test Task",
|
||||
description: "A test task",
|
||||
column: "in-progress" as const,
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
size: "M" as const,
|
||||
reviewLevel: "full" as const,
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
it("should track branch when task moves to in-progress", async () => {
|
||||
const ctx = createMockContext();
|
||||
await plugin.hooks.onTaskMoved?.(
|
||||
mockTask as any,
|
||||
"todo",
|
||||
"in-progress",
|
||||
ctx as any,
|
||||
);
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Tracking branch for task FN-001"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should stop tracking branch when task moves to done", async () => {
|
||||
const ctx = createMockContext();
|
||||
|
||||
// First move to in-progress
|
||||
await plugin.hooks.onTaskMoved?.(
|
||||
mockTask as any,
|
||||
"todo",
|
||||
"in-progress",
|
||||
ctx as any,
|
||||
);
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Then move to done
|
||||
await plugin.hooks.onTaskMoved?.(
|
||||
mockTask as any,
|
||||
"in-progress",
|
||||
"done",
|
||||
ctx as any,
|
||||
);
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Stopped tracking branch for task FN-001"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should use configured branch prefix", async () => {
|
||||
const ctx = createMockContext({
|
||||
settings: {
|
||||
ciUrl: "https://ci.example.com/api",
|
||||
pollIntervalMs: 60000,
|
||||
branchPrefix: "custom/",
|
||||
},
|
||||
});
|
||||
|
||||
await plugin.hooks.onTaskMoved?.(
|
||||
mockTask as any,
|
||||
"todo",
|
||||
"in-progress",
|
||||
ctx as any,
|
||||
);
|
||||
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining("custom/fn-001"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("route handlers", () => {
|
||||
describe("GET /status", () => {
|
||||
it("should return branches array", async () => {
|
||||
const ctx = createMockContext();
|
||||
const req = createMockRequest();
|
||||
const res = createMockResponse();
|
||||
|
||||
const route = plugin.routes!.find(
|
||||
(r) => r.method === "GET" && r.path === "/status",
|
||||
)!;
|
||||
|
||||
const result = await route.handler(req as any, ctx as any);
|
||||
|
||||
expect(result).toHaveProperty("branches");
|
||||
expect(Array.isArray(result.branches)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /status/:branch", () => {
|
||||
it("should return specific branch data", async () => {
|
||||
const ctx = createMockContext();
|
||||
const req = createMockRequest({
|
||||
params: { branch: "fusion/fn-001" },
|
||||
});
|
||||
|
||||
// First add the branch via onTaskMoved
|
||||
await plugin.hooks.onTaskMoved?.(
|
||||
{
|
||||
id: "FN-001",
|
||||
title: "Test Task",
|
||||
description: "A test task",
|
||||
column: "in-progress" as const,
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
size: "M" as const,
|
||||
reviewLevel: "full" as const,
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
} as any,
|
||||
"todo",
|
||||
"in-progress",
|
||||
ctx as any,
|
||||
);
|
||||
|
||||
const route = plugin.routes!.find(
|
||||
(r) => r.method === "GET" && r.path === "/status/:branch",
|
||||
)!;
|
||||
|
||||
const result = await route.handler(req as any, ctx as any);
|
||||
|
||||
expect(result).toHaveProperty("branch", "fusion/fn-001");
|
||||
expect(result).toHaveProperty("status", "pending");
|
||||
});
|
||||
|
||||
it("should throw 404 for unknown branch", async () => {
|
||||
const ctx = createMockContext();
|
||||
const req = createMockRequest({
|
||||
params: { branch: "unknown/branch" },
|
||||
});
|
||||
|
||||
const route = plugin.routes!.find(
|
||||
(r) => r.method === "GET" && r.path === "/status/:branch",
|
||||
)!;
|
||||
|
||||
await expect(
|
||||
route.handler(req as any, ctx as any),
|
||||
).rejects.toThrow("Branch not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /refresh", () => {
|
||||
it("should trigger refresh and return branches", async () => {
|
||||
const ctx = createMockContext();
|
||||
const req = createMockRequest({ method: "POST" });
|
||||
const res = createMockResponse();
|
||||
|
||||
const route = plugin.routes!.find(
|
||||
(r) => r.method === "POST" && r.path === "/refresh",
|
||||
)!;
|
||||
|
||||
const result = await route.handler(req as any, ctx as any);
|
||||
|
||||
expect(result).toHaveProperty("refreshed", true);
|
||||
expect(result).toHaveProperty("branches");
|
||||
expect(Array.isArray(result.branches)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
240
plugins/examples/fusion-plugin-ci-status/src/index.ts
Normal file
240
plugins/examples/fusion-plugin-ci-status/src/index.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { definePlugin } from "@fusion/plugin-sdk";
|
||||
import type {
|
||||
FusionPlugin,
|
||||
PluginContext,
|
||||
PluginSettingSchema,
|
||||
PluginRouteDefinition,
|
||||
} from "@fusion/plugin-sdk";
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface BranchStatus {
|
||||
branch: string;
|
||||
status: string;
|
||||
lastChecked: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
// ── Settings Schema ─────────────────────────────────────────────────────────────
|
||||
|
||||
const settingsSchema: Record<string, PluginSettingSchema> = {
|
||||
ciUrl: {
|
||||
type: "string",
|
||||
label: "CI API URL",
|
||||
description: "Base URL for CI API",
|
||||
required: true,
|
||||
},
|
||||
pollIntervalMs: {
|
||||
type: "number",
|
||||
label: "Poll Interval (ms)",
|
||||
description: "How often to poll CI status",
|
||||
defaultValue: 30000,
|
||||
},
|
||||
branchPrefix: {
|
||||
type: "string",
|
||||
label: "Branch Prefix",
|
||||
description: "Only poll branches with this prefix",
|
||||
defaultValue: "fusion/",
|
||||
},
|
||||
};
|
||||
|
||||
// ── Module-Level State ─────────────────────────────────────────────────────────
|
||||
|
||||
const branchStatuses = new Map<string, BranchStatus>();
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// ── CI Polling Logic ───────────────────────────────────────────────────────────
|
||||
|
||||
async function pollCIStatus(
|
||||
ciUrl: string,
|
||||
logger: PluginContext["logger"],
|
||||
): Promise<void> {
|
||||
const branchesToPoll = Array.from(branchStatuses.keys());
|
||||
|
||||
if (branchesToPoll.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Polling CI status for ${branchesToPoll.length} branches`);
|
||||
|
||||
try {
|
||||
// Try to fetch from the configured CI URL
|
||||
const response = await fetch(`${ciUrl}/status`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ branches: branchesToPoll }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as {
|
||||
statuses?: Array<{ branch: string; status: string; url?: string }>;
|
||||
};
|
||||
if (data.statuses) {
|
||||
for (const s of data.statuses) {
|
||||
branchStatuses.set(s.branch, {
|
||||
branch: s.branch,
|
||||
status: s.status,
|
||||
lastChecked: new Date().toISOString(),
|
||||
url: s.url,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// CI polling is best-effort; log and continue
|
||||
logger.warn(
|
||||
`CI polling failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Route Handlers ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface MockRequest {
|
||||
params: Record<string, string>;
|
||||
method: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface MockResponse {
|
||||
json: ReturnType<typeof vi.fn>;
|
||||
status: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function getStatusAllHandler(
|
||||
_req: MockRequest,
|
||||
_ctx: PluginContext,
|
||||
): { branches: BranchStatus[] } {
|
||||
const branches = Array.from(branchStatuses.values());
|
||||
return { branches };
|
||||
}
|
||||
|
||||
function getStatusBranchHandler(
|
||||
req: MockRequest,
|
||||
_ctx: PluginContext,
|
||||
): { branch: string; status: string; lastChecked: string; url?: string } {
|
||||
const branch = req.params.branch;
|
||||
const status = branchStatuses.get(branch);
|
||||
|
||||
if (!status) {
|
||||
const error = new Error("Branch not found");
|
||||
(error as any).statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
function postRefreshHandler(
|
||||
_req: MockRequest,
|
||||
ctx: PluginContext,
|
||||
): { branches: BranchStatus[]; refreshed: boolean } {
|
||||
const ciUrl = ctx.settings.ciUrl as string;
|
||||
|
||||
if (ciUrl) {
|
||||
// Trigger immediate poll
|
||||
pollCIStatus(ciUrl, ctx.logger).catch(() => {
|
||||
// Best-effort polling
|
||||
});
|
||||
}
|
||||
|
||||
const branches = Array.from(branchStatuses.values());
|
||||
return { branches, refreshed: true };
|
||||
}
|
||||
|
||||
// ── Plugin Routes ─────────────────────────────────────────────────────────────
|
||||
|
||||
const routes: PluginRouteDefinition[] = [
|
||||
{
|
||||
method: "GET",
|
||||
path: "/status",
|
||||
handler: getStatusAllHandler as any,
|
||||
description: "Get status of all tracked branches",
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/status/:branch",
|
||||
handler: getStatusBranchHandler as any,
|
||||
description: "Get status of a specific branch",
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/refresh",
|
||||
handler: postRefreshHandler as any,
|
||||
description: "Trigger an immediate CI status refresh",
|
||||
},
|
||||
];
|
||||
|
||||
// ── Plugin Definition ───────────────────────────────────────────────────────────
|
||||
|
||||
const plugin: FusionPlugin = definePlugin({
|
||||
manifest: {
|
||||
id: "fusion-plugin-ci-status",
|
||||
name: "CI Status Plugin",
|
||||
version: "0.1.0",
|
||||
description:
|
||||
"Polls CI status for branches and provides a custom API to query results",
|
||||
settingsSchema,
|
||||
},
|
||||
state: "installed",
|
||||
routes,
|
||||
hooks: {
|
||||
onLoad: (ctx) => {
|
||||
ctx.logger.info("CI Status plugin loaded");
|
||||
|
||||
const pollIntervalMs = (ctx.settings.pollIntervalMs as number) || 30000;
|
||||
|
||||
// Start polling
|
||||
pollInterval = setInterval(() => {
|
||||
const ciUrl = ctx.settings.ciUrl as string | undefined;
|
||||
if (ciUrl) {
|
||||
pollCIStatus(ciUrl, ctx.logger).catch(() => {
|
||||
// Best-effort polling
|
||||
});
|
||||
}
|
||||
}, pollIntervalMs);
|
||||
|
||||
ctx.logger.info(
|
||||
`CI polling started with interval ${pollIntervalMs}ms`,
|
||||
);
|
||||
},
|
||||
|
||||
onUnload: () => {
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval);
|
||||
pollInterval = null;
|
||||
}
|
||||
// Clear branch statuses on unload
|
||||
branchStatuses.clear();
|
||||
},
|
||||
|
||||
onTaskMoved: (task, fromColumn, toColumn, ctx) => {
|
||||
// Track branches for tasks that move to in-progress
|
||||
if (toColumn === "in-progress") {
|
||||
const branchPrefix = (ctx.settings.branchPrefix as string) || "fusion/";
|
||||
const branchName = `${branchPrefix}${task.id.toLowerCase()}`;
|
||||
|
||||
if (!branchStatuses.has(branchName)) {
|
||||
branchStatuses.set(branchName, {
|
||||
branch: branchName,
|
||||
status: "pending",
|
||||
lastChecked: new Date().toISOString(),
|
||||
});
|
||||
ctx.logger.info(`Tracking branch for task ${task.id}: ${branchName}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from tracking when task is done
|
||||
if (toColumn === "done" || toColumn === "archived") {
|
||||
const branchPrefix = (ctx.settings.branchPrefix as string) || "fusion/";
|
||||
const branchName = `${branchPrefix}${task.id.toLowerCase()}`;
|
||||
if (branchStatuses.has(branchName)) {
|
||||
branchStatuses.delete(branchName);
|
||||
ctx.logger.info(`Stopped tracking branch for task ${task.id}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
Reference in New Issue
Block a user