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:
gsxdsm
2026-04-09 19:04:12 -07:00
parent 414bcad702
commit a3163f9935
24 changed files with 3078 additions and 1 deletions

View File

@@ -0,0 +1,137 @@
# CI Status Plugin
Polls CI status for branches and provides a custom API to query results. Useful for tracking the build status of feature branches created for Fusion tasks.
## Features
- **Branch Tracking**: Automatically tracks branches when tasks move to in-progress
- **Periodic Polling**: Polls CI status at configurable intervals
- **Custom API**: Provides REST endpoints to query branch status
- **Automatic Cleanup**: Stops tracking branches when tasks are completed
## Installation
### Option 1: Copy to plugins directory
```bash
cp -r fusion-plugin-ci-status ~/.fusion/plugins/
```
### Option 2: Install via CLI
```bash
fn plugin install /path/to/fusion-plugin-ci-status
```
## Configuration
### Settings Reference
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `ciUrl` | string | Yes | — | Base URL for CI API |
| `pollIntervalMs` | number | No | `30000` | How often to poll CI status (milliseconds) |
| `branchPrefix` | string | No | `fusion/` | Only poll branches with this prefix |
## API Endpoints
The plugin provides the following REST endpoints at `/api/plugins/fusion-plugin-ci-status`:
### GET /status
Returns status of all tracked branches.
```json
{
"branches": [
{
"branch": "fusion/fn-001",
"status": "pending",
"lastChecked": "2024-01-01T00:00:00.000Z",
"url": "https://ci.example.com/builds/123"
}
]
}
```
### GET /status/:branch
Returns status of a specific branch.
```json
{
"branch": "fusion/fn-001",
"status": "success",
"lastChecked": "2024-01-01T00:00:00.000Z",
"url": "https://ci.example.com/builds/123"
}
```
If the branch is not found, returns a 404 error.
### POST /refresh
Triggers an immediate CI status refresh for all tracked branches.
```json
{
"branches": [...],
"refreshed": true
}
```
## How It Works
1. **Branch Creation**: When a task moves to "in-progress", the plugin creates a branch name using the configured prefix (default: `fusion/`) combined with the task ID (lowercased)
2. **CI Polling**: At the configured interval, the plugin polls the CI API to get status updates for all tracked branches
3. **CI Integration**: The plugin sends a POST request to `{ciUrl}/status` with the list of branch names
4. **Branch Cleanup**: When a task moves to "done" or "archived", the branch is removed from tracking
## CI API Integration
The plugin expects your CI system to expose a `/status` endpoint that accepts:
```json
{
"branches": ["fusion/fn-001", "fusion/fn-002"]
}
```
And returns:
```json
{
"statuses": [
{
"branch": "fusion/fn-001",
"status": "success",
"url": "https://ci.example.com/builds/123"
}
]
}
```
### Supported Status Values
- `pending` — Initial state when branch is first tracked
- `running` — CI is building
- `success` — CI passed
- `failed` — CI failed
- `cancelled` — CI was cancelled
## Development
```bash
# Install dependencies
pnpm install
# Run tests
pnpm test
# Build
pnpm build
```
## License
MIT

View File

@@ -0,0 +1,21 @@
{
"name": "@fusion-plugin-examples/ci-status",
"version": "0.1.0",
"type": "module",
"description": "Polls CI status for branches and provides a custom API to query results",
"keywords": ["fusion-plugin"],
"exports": {
".": {
"types": "./src/index.ts",
"import": "./dist/index.js"
}
},
"private": true,
"scripts": {
"build": "tsc",
"test": "vitest run"
},
"dependencies": {
"@fusion/plugin-sdk": "workspace:*"
}
}

View File

@@ -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);
});
});
});
});

View 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;

View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"types": ["node", "vitest/globals"]
},
"include": ["src/**/*"]
}

View File

@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
pool: "threads",
},
});