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:
99
plugins/examples/fusion-plugin-auto-label/README.md
Normal file
99
plugins/examples/fusion-plugin-auto-label/README.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# Auto-Label Plugin
|
||||
|
||||
Automatically labels tasks based on their description content using keyword matching. Also provides an AI agent tool for manual text classification.
|
||||
|
||||
## Features
|
||||
|
||||
- **Automatic Classification**: Labels tasks as they're created based on keywords in the description
|
||||
- **Multiple Categories**: Supports bug, feature, documentation, testing, refactor, and performance labels
|
||||
- **AI Agent Tool**: Provides `auto_label_classify` tool for AI agents to classify text
|
||||
- **Event Emission**: Emits `auto-label:classified` events for integration with other plugins
|
||||
|
||||
## Installation
|
||||
|
||||
### Option 1: Copy to plugins directory
|
||||
|
||||
```bash
|
||||
cp -r fusion-plugin-auto-label ~/.fusion/plugins/
|
||||
```
|
||||
|
||||
### Option 2: Install via CLI
|
||||
|
||||
```bash
|
||||
fn plugin install /path/to/fusion-plugin-auto-label
|
||||
```
|
||||
|
||||
## Categories
|
||||
|
||||
The plugin classifies text into the following categories:
|
||||
|
||||
| Category | Keywords |
|
||||
|---------|----------|
|
||||
| `bug` | bug, fix, broken, crash, error |
|
||||
| `feature` | feature, add, new, implement |
|
||||
| `documentation` | docs, documentation, readme, guide |
|
||||
| `testing` | test, testing, spec, coverage |
|
||||
| `refactor` | refactor, cleanup, clean up, reorganize |
|
||||
| `performance` | perf, performance, optimize, slow |
|
||||
|
||||
## How It Works
|
||||
|
||||
### Automatic Classification (onTaskCreated hook)
|
||||
|
||||
When a task is created, the plugin:
|
||||
1. Scans the task description for keywords
|
||||
2. Matches against category rules
|
||||
3. Logs the matched labels
|
||||
4. Emits an `auto-label:classified` event with the task ID and labels
|
||||
|
||||
### AI Agent Tool
|
||||
|
||||
The plugin provides an `auto_label_classify` tool that AI agents can use:
|
||||
|
||||
```javascript
|
||||
{
|
||||
name: "auto_label_classify",
|
||||
description: "Classify a text description into categories...",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
text: { type: "string", description: "The text to classify" }
|
||||
},
|
||||
required: ["text"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example usage:
|
||||
```
|
||||
Use auto_label_classify with text: "Fix the login bug"
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
This plugin has no required settings. It works out of the box with its built-in keyword rules.
|
||||
|
||||
## Events
|
||||
|
||||
The plugin emits the following events:
|
||||
|
||||
| Event | Data | Description |
|
||||
|-------|------|-------------|
|
||||
| `auto-label:classified` | `{ taskId, labels }` | Emitted when a task is classified |
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Run tests
|
||||
pnpm test
|
||||
|
||||
# Build
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
21
plugins/examples/fusion-plugin-auto-label/package.json
Normal file
21
plugins/examples/fusion-plugin-auto-label/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@fusion-plugin-examples/auto-label",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Automatically labels tasks based on description content",
|
||||
"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:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import plugin, { classifyText } 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-auto-label",
|
||||
settings: {},
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
emitEvent: vi.fn(),
|
||||
taskStore: {
|
||||
getTask: vi.fn(),
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Test Suite ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("auto-label plugin", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("plugin export", () => {
|
||||
it("should export a valid FusionPlugin with correct manifest fields", () => {
|
||||
expect(plugin.manifest.id).toBe("fusion-plugin-auto-label");
|
||||
expect(plugin.manifest.name).toBe("Auto-Label Plugin");
|
||||
expect(plugin.manifest.version).toBe("0.1.0");
|
||||
expect(plugin.state).toBe("installed");
|
||||
});
|
||||
|
||||
it("should have tools array with auto_label_classify tool", () => {
|
||||
expect(plugin.tools).toBeDefined();
|
||||
expect(plugin.tools!.length).toBe(1);
|
||||
expect(plugin.tools![0].name).toBe("auto_label_classify");
|
||||
});
|
||||
|
||||
it("should have onLoad and onTaskCreated hooks", () => {
|
||||
expect(plugin.hooks.onLoad).toBeDefined();
|
||||
expect(plugin.hooks.onTaskCreated).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyText function", () => {
|
||||
it('should classify "Fix the login bug" as bug', () => {
|
||||
const labels = classifyText("Fix the login bug");
|
||||
expect(labels).toContain("bug");
|
||||
});
|
||||
|
||||
it('should classify "Add new feature for search" as feature', () => {
|
||||
const labels = classifyText("Add new feature for search");
|
||||
expect(labels).toContain("feature");
|
||||
});
|
||||
|
||||
it("should not return duplicate labels", () => {
|
||||
const labels = classifyText("Fix the bug with the error and crash");
|
||||
// Should contain bug but not multiple times
|
||||
expect(labels.filter((l) => l === "bug").length).toBe(1);
|
||||
});
|
||||
|
||||
it("should return multiple labels when text matches multiple categories", () => {
|
||||
const labels = classifyText(
|
||||
"Refactor and add tests for the broken parser",
|
||||
);
|
||||
expect(labels).toContain("refactor");
|
||||
expect(labels).toContain("testing");
|
||||
expect(labels).toContain("bug");
|
||||
});
|
||||
|
||||
it("should return empty array when no keywords match", () => {
|
||||
const labels = classifyText("Update the color scheme");
|
||||
expect(labels).toEqual([]);
|
||||
});
|
||||
|
||||
it("should match keywords case-insensitively", () => {
|
||||
expect(classifyText("BUG in the code")).toContain("bug");
|
||||
expect(classifyText("ADD new feature")).toContain("feature");
|
||||
expect(classifyText("PERFORMANCE optimization")).toContain("performance");
|
||||
});
|
||||
|
||||
it("should match keywords with word boundaries", () => {
|
||||
// "add" should match in "Add new feature" but not in "address"
|
||||
expect(classifyText("Add new feature")).toContain("feature");
|
||||
expect(classifyText("Add to cart")).toContain("feature");
|
||||
|
||||
// "spec" should match in "Add test spec" but not in "specific"
|
||||
expect(classifyText("Add test spec")).toContain("testing");
|
||||
});
|
||||
|
||||
it("should handle empty string", () => {
|
||||
const labels = classifyText("");
|
||||
expect(labels).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle string with special characters", () => {
|
||||
const labels = classifyText("Fix bug: app crashes on startup (error!)");
|
||||
expect(labels).toContain("bug");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hooks.onLoad", () => {
|
||||
it("should log startup message with category count", async () => {
|
||||
const ctx = createMockContext();
|
||||
await plugin.hooks.onLoad?.(ctx as any);
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Auto-Label plugin loaded"),
|
||||
);
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining("6 category rules"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hooks.onTaskCreated", () => {
|
||||
it("should run without error on a mock task", async () => {
|
||||
const ctx = createMockContext();
|
||||
const mockTask = {
|
||||
id: "FN-001",
|
||||
title: "Test Task",
|
||||
description: "Fix the login bug",
|
||||
column: "triage" 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",
|
||||
};
|
||||
|
||||
await expect(
|
||||
plugin.hooks.onTaskCreated?.(mockTask as any, ctx as any),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("should call emitEvent with labels when task matches categories", async () => {
|
||||
const ctx = createMockContext();
|
||||
const mockTask = {
|
||||
id: "FN-001",
|
||||
title: "Test Task",
|
||||
description: "Fix the login bug",
|
||||
column: "triage" 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",
|
||||
};
|
||||
|
||||
await plugin.hooks.onTaskCreated?.(mockTask as any, ctx as any);
|
||||
|
||||
expect(ctx.emitEvent).toHaveBeenCalledWith("auto-label:classified", {
|
||||
taskId: "FN-001",
|
||||
labels: expect.arrayContaining(["bug"]),
|
||||
});
|
||||
});
|
||||
|
||||
it("should not call emitEvent when task does not match any categories", async () => {
|
||||
const ctx = createMockContext();
|
||||
const mockTask = {
|
||||
id: "FN-002",
|
||||
title: "Test Task",
|
||||
description: "Update the color scheme",
|
||||
column: "triage" 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",
|
||||
};
|
||||
|
||||
await plugin.hooks.onTaskCreated?.(mockTask as any, ctx as any);
|
||||
|
||||
expect(ctx.emitEvent).not.toHaveBeenCalled();
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining("did not match any categories"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should emit multiple labels when task matches multiple categories", async () => {
|
||||
const ctx = createMockContext();
|
||||
const mockTask = {
|
||||
id: "FN-003",
|
||||
title: "Test Task",
|
||||
description: "Refactor the parser and add tests",
|
||||
column: "triage" 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",
|
||||
};
|
||||
|
||||
await plugin.hooks.onTaskCreated?.(mockTask as any, ctx as any);
|
||||
|
||||
expect(ctx.emitEvent).toHaveBeenCalledWith("auto-label:classified", {
|
||||
taskId: "FN-003",
|
||||
labels: expect.arrayContaining(["refactor", "testing"]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool: auto_label_classify", () => {
|
||||
it("should return correct PluginToolResult for valid text", async () => {
|
||||
const ctx = createMockContext();
|
||||
const tool = plugin.tools![0];
|
||||
|
||||
const result = await tool.execute(
|
||||
{ text: "Fix the login bug" },
|
||||
ctx as any,
|
||||
);
|
||||
|
||||
expect(result).toHaveProperty("content");
|
||||
expect(result.content).toHaveLength(1);
|
||||
expect(result.content[0]).toEqual({ type: "text", text: expect.any(String) });
|
||||
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed).toContain("bug");
|
||||
});
|
||||
|
||||
it("should return empty array for non-matching text", async () => {
|
||||
const ctx = createMockContext();
|
||||
const tool = plugin.tools![0];
|
||||
|
||||
const result = await tool.execute(
|
||||
{ text: "Update the color scheme" },
|
||||
ctx as any,
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return empty array for empty text", async () => {
|
||||
const ctx = createMockContext();
|
||||
const tool = plugin.tools![0];
|
||||
|
||||
const result = await tool.execute({ text: "" }, ctx as any);
|
||||
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return empty array for missing text parameter", async () => {
|
||||
const ctx = createMockContext();
|
||||
const tool = plugin.tools![0];
|
||||
|
||||
const result = await tool.execute({}, ctx as any);
|
||||
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return isError false", async () => {
|
||||
const ctx = createMockContext();
|
||||
const tool = plugin.tools![0];
|
||||
|
||||
const result = await tool.execute(
|
||||
{ text: "Some text" },
|
||||
ctx as any,
|
||||
);
|
||||
|
||||
expect(result.isError).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
139
plugins/examples/fusion-plugin-auto-label/src/index.ts
Normal file
139
plugins/examples/fusion-plugin-auto-label/src/index.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { definePlugin } from "@fusion/plugin-sdk";
|
||||
import type {
|
||||
FusionPlugin,
|
||||
PluginContext,
|
||||
PluginToolDefinition,
|
||||
PluginToolResult,
|
||||
} from "@fusion/plugin-sdk";
|
||||
|
||||
// ── Category Rules ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface CategoryRule {
|
||||
keywords: string[];
|
||||
label: string;
|
||||
}
|
||||
|
||||
const CATEGORY_RULES: CategoryRule[] = [
|
||||
{
|
||||
keywords: ["bug", "fix", "broken", "crash", "error"],
|
||||
label: "bug",
|
||||
},
|
||||
{
|
||||
keywords: ["feature", "add", "new", "implement"],
|
||||
label: "feature",
|
||||
},
|
||||
{
|
||||
keywords: ["docs", "documentation", "readme", "guide"],
|
||||
label: "documentation",
|
||||
},
|
||||
{
|
||||
keywords: ["test", "testing", "spec", "coverage"],
|
||||
label: "testing",
|
||||
},
|
||||
{
|
||||
keywords: ["refactor", "cleanup", "clean up", "reorganize"],
|
||||
label: "refactor",
|
||||
},
|
||||
{
|
||||
keywords: ["perf", "performance", "optimize", "slow"],
|
||||
label: "performance",
|
||||
},
|
||||
];
|
||||
|
||||
// ── Text Classification ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Classify text into categories based on keyword matching.
|
||||
* Returns an array of matching category labels.
|
||||
*/
|
||||
export function classifyText(text: string): string[] {
|
||||
const lowerText = text.toLowerCase();
|
||||
const matchedLabels: string[] = [];
|
||||
|
||||
for (const rule of CATEGORY_RULES) {
|
||||
for (const keyword of rule.keywords) {
|
||||
// Match whole word boundaries using word characters
|
||||
const regex = new RegExp(`\\b${keyword}\\b`, "i");
|
||||
if (regex.test(lowerText)) {
|
||||
if (!matchedLabels.includes(rule.label)) {
|
||||
matchedLabels.push(rule.label);
|
||||
}
|
||||
break; // Move to next rule once keyword matches
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matchedLabels;
|
||||
}
|
||||
|
||||
// ── Plugin Tool ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const autoLabelTool: PluginToolDefinition = {
|
||||
name: "auto_label_classify",
|
||||
description:
|
||||
"Classify a text description into categories (bug, feature, documentation, testing, refactor, performance). Returns an array of matching labels.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
text: {
|
||||
type: "string",
|
||||
description: "The text to classify",
|
||||
},
|
||||
},
|
||||
required: ["text"],
|
||||
},
|
||||
execute: async (
|
||||
params: Record<string, unknown>,
|
||||
_ctx: PluginContext,
|
||||
): Promise<PluginToolResult> => {
|
||||
const text = params.text as string;
|
||||
if (!text || typeof text !== "string") {
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify([]) }],
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
|
||||
const labels = classifyText(text);
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(labels) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// ── Plugin Definition ───────────────────────────────────────────────────────────
|
||||
|
||||
const plugin: FusionPlugin = definePlugin({
|
||||
manifest: {
|
||||
id: "fusion-plugin-auto-label",
|
||||
name: "Auto-Label Plugin",
|
||||
version: "0.1.0",
|
||||
description: "Automatically labels tasks based on description content",
|
||||
},
|
||||
state: "installed",
|
||||
tools: [autoLabelTool],
|
||||
hooks: {
|
||||
onLoad: (ctx) => {
|
||||
ctx.logger.info(
|
||||
`Auto-Label plugin loaded with ${CATEGORY_RULES.length} category rules`,
|
||||
);
|
||||
},
|
||||
|
||||
onTaskCreated: (task, ctx) => {
|
||||
const labels = classifyText(task.description || "");
|
||||
if (labels.length > 0) {
|
||||
ctx.logger.info(
|
||||
`Task ${task.id} classified with labels: ${labels.join(", ")}`,
|
||||
);
|
||||
ctx.emitEvent("auto-label:classified", {
|
||||
taskId: task.id,
|
||||
labels,
|
||||
});
|
||||
} else {
|
||||
ctx.logger.info(`Task ${task.id} did not match any categories`);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
9
plugins/examples/fusion-plugin-auto-label/tsconfig.json
Normal file
9
plugins/examples/fusion-plugin-auto-label/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node", "vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
pool: "threads",
|
||||
},
|
||||
});
|
||||
137
plugins/examples/fusion-plugin-ci-status/README.md
Normal file
137
plugins/examples/fusion-plugin-ci-status/README.md
Normal 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
|
||||
21
plugins/examples/fusion-plugin-ci-status/package.json
Normal file
21
plugins/examples/fusion-plugin-ci-status/package.json
Normal 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:*"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
9
plugins/examples/fusion-plugin-ci-status/tsconfig.json
Normal file
9
plugins/examples/fusion-plugin-ci-status/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node", "vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
pool: "threads",
|
||||
},
|
||||
});
|
||||
100
plugins/examples/fusion-plugin-notification/README.md
Normal file
100
plugins/examples/fusion-plugin-notification/README.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# Notification Plugin
|
||||
|
||||
Sends webhook notifications on Fusion task lifecycle events (task completed, task moved, errors).
|
||||
|
||||
## Features
|
||||
|
||||
- **Webhook Notifications**: Send notifications to Slack, Discord, or generic HTTP endpoints
|
||||
- **Event Filtering**: Configure which events trigger notifications
|
||||
- **Multiple Webhook Formats**: Native support for Slack and Discord webhook payloads
|
||||
|
||||
## Installation
|
||||
|
||||
### Option 1: Copy to plugins directory
|
||||
|
||||
```bash
|
||||
cp -r fusion-plugin-notification ~/.fusion/plugins/
|
||||
```
|
||||
|
||||
### Option 2: Install via CLI
|
||||
|
||||
```bash
|
||||
fn plugin install /path/to/fusion-plugin-notification
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
After installation, configure the plugin through the Fusion dashboard:
|
||||
|
||||
1. Go to **Settings → Plugins**
|
||||
2. Find "Notification Plugin" and click the settings icon
|
||||
3. Set your webhook URL and preferred webhook type
|
||||
4. Optionally filter which events trigger notifications
|
||||
|
||||
### Settings Reference
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `webhookUrl` | string | Yes | — | URL to send webhook notifications to |
|
||||
| `webhookType` | enum | No | `generic` | Webhook payload format: `slack`, `discord`, or `generic` |
|
||||
| `events` | string | No | (all) | Comma-separated list of events: `task-completed`, `task-moved`, `task-failed`. Empty = all events |
|
||||
|
||||
### Example Settings (JSON)
|
||||
|
||||
```json
|
||||
{
|
||||
"webhookUrl": "https://hooks.slack.com/services/XXX/YYY/ZZZ",
|
||||
"webhookType": "slack",
|
||||
"events": "task-completed,task-moved"
|
||||
}
|
||||
```
|
||||
|
||||
## Webhook Payload Formats
|
||||
|
||||
### Slack
|
||||
|
||||
```json
|
||||
{
|
||||
"text": "✅ Task completed: Fix login bug"
|
||||
}
|
||||
```
|
||||
|
||||
### Discord
|
||||
|
||||
```json
|
||||
{
|
||||
"content": "✅ Task completed: Fix login bug"
|
||||
}
|
||||
```
|
||||
|
||||
### Generic
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "task-completed",
|
||||
"timestamp": "2024-01-01T00:00:00.000Z",
|
||||
"task": {
|
||||
"id": "FN-001",
|
||||
"title": "Fix login bug",
|
||||
"from": "todo",
|
||||
"to": "done"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Run tests
|
||||
pnpm test
|
||||
|
||||
# Build
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
21
plugins/examples/fusion-plugin-notification/package.json
Normal file
21
plugins/examples/fusion-plugin-notification/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@fusion-plugin-examples/notification",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Example Fusion plugin that sends webhook notifications on task lifecycle events",
|
||||
"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:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import plugin from "../index.js";
|
||||
|
||||
// ── Types for mocking ─────────────────────────────────────────────────────────
|
||||
|
||||
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-notification",
|
||||
settings: {
|
||||
webhookUrl: "https://example.com/webhook",
|
||||
webhookType: "generic",
|
||||
events: "",
|
||||
},
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
emitEvent: vi.fn(),
|
||||
taskStore: {
|
||||
getTask: vi.fn(),
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Mock Task ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockTask = {
|
||||
id: "FN-001",
|
||||
title: "Test Task",
|
||||
description: "A test task description",
|
||||
column: "done" 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",
|
||||
};
|
||||
|
||||
// ── Test Suite ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("notification plugin", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("plugin export", () => {
|
||||
it("should export a valid FusionPlugin with correct manifest fields", () => {
|
||||
expect(plugin.manifest.id).toBe("fusion-plugin-notification");
|
||||
expect(plugin.manifest.name).toBe("Notification Plugin");
|
||||
expect(plugin.manifest.version).toBe("0.1.0");
|
||||
expect(plugin.manifest.description).toBe(
|
||||
"Sends webhook notifications on task lifecycle events",
|
||||
);
|
||||
expect(plugin.state).toBe("installed");
|
||||
expect(plugin.hooks).toBeDefined();
|
||||
});
|
||||
|
||||
it("should have all required hooks defined", () => {
|
||||
expect(plugin.hooks.onLoad).toBeDefined();
|
||||
expect(plugin.hooks.onTaskCompleted).toBeDefined();
|
||||
expect(plugin.hooks.onTaskMoved).toBeDefined();
|
||||
expect(plugin.hooks.onError).toBeDefined();
|
||||
});
|
||||
|
||||
it("should have settings schema defined", () => {
|
||||
expect(plugin.manifest.settingsSchema).toBeDefined();
|
||||
expect(plugin.manifest.settingsSchema!.webhookUrl).toBeDefined();
|
||||
expect(plugin.manifest.settingsSchema!.webhookType).toBeDefined();
|
||||
expect(plugin.manifest.settingsSchema!.events).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("Notification plugin loaded");
|
||||
});
|
||||
|
||||
it("should warn if webhookUrl is not configured", async () => {
|
||||
const ctx = createMockContext({
|
||||
settings: { webhookUrl: "", webhookType: "generic", events: "" },
|
||||
});
|
||||
await plugin.hooks.onLoad?.(ctx as any);
|
||||
expect(ctx.logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("No webhook URL configured"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should not warn if webhookUrl is configured", async () => {
|
||||
const ctx = createMockContext({
|
||||
settings: {
|
||||
webhookUrl: "https://example.com/webhook",
|
||||
webhookType: "generic",
|
||||
events: "",
|
||||
},
|
||||
});
|
||||
await plugin.hooks.onLoad?.(ctx as any);
|
||||
expect(ctx.logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hooks.onTaskCompleted", () => {
|
||||
it("should fire fetch call with generic payload for generic webhook type", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const ctx = createMockContext({
|
||||
settings: {
|
||||
webhookUrl: "https://example.com/webhook",
|
||||
webhookType: "generic",
|
||||
events: "",
|
||||
},
|
||||
});
|
||||
|
||||
await plugin.hooks.onTaskCompleted?.(mockTask as any, ctx as any);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://example.com/webhook",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const call = fetchMock.mock.calls[0];
|
||||
const body = JSON.parse(call[1].body);
|
||||
expect(body.event).toBe("task-completed");
|
||||
expect(body.task.id).toBe("FN-001");
|
||||
expect(body.task.title).toBe("Test Task");
|
||||
});
|
||||
|
||||
it("should fire fetch call with Slack payload for slack webhook type", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const ctx = createMockContext({
|
||||
settings: {
|
||||
webhookUrl: "https://hooks.slack.com/webhook",
|
||||
webhookType: "slack",
|
||||
events: "",
|
||||
},
|
||||
});
|
||||
|
||||
await plugin.hooks.onTaskCompleted?.(mockTask as any, ctx as any);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const call = fetchMock.mock.calls[0];
|
||||
const body = JSON.parse(call[1].body);
|
||||
expect(body).toEqual({ text: expect.stringContaining("Task completed") });
|
||||
});
|
||||
|
||||
it("should fire fetch call with Discord payload for discord webhook type", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const ctx = createMockContext({
|
||||
settings: {
|
||||
webhookUrl: "https://discord.com/api/webhooks/webhook",
|
||||
webhookType: "discord",
|
||||
events: "",
|
||||
},
|
||||
});
|
||||
|
||||
await plugin.hooks.onTaskCompleted?.(mockTask as any, ctx as any);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const call = fetchMock.mock.calls[0];
|
||||
const body = JSON.parse(call[1].body);
|
||||
expect(body).toEqual({ content: expect.stringContaining("Task completed") });
|
||||
});
|
||||
|
||||
it("should not fire webhook if webhookUrl is not set", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const ctx = createMockContext({
|
||||
settings: { webhookUrl: "", webhookType: "generic", events: "" },
|
||||
});
|
||||
|
||||
await plugin.hooks.onTaskCompleted?.(mockTask as any, ctx as any);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not fire webhook if event is filtered out", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const ctx = createMockContext({
|
||||
settings: {
|
||||
webhookUrl: "https://example.com/webhook",
|
||||
webhookType: "generic",
|
||||
events: "task-moved", // Only task-moved events
|
||||
},
|
||||
});
|
||||
|
||||
await plugin.hooks.onTaskCompleted?.(mockTask as any, ctx as any);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("event filtering", () => {
|
||||
it("should fire webhook for task-completed when events filter is task-completed", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const ctx = createMockContext({
|
||||
settings: {
|
||||
webhookUrl: "https://example.com/webhook",
|
||||
webhookType: "generic",
|
||||
events: "task-completed",
|
||||
},
|
||||
});
|
||||
|
||||
await plugin.hooks.onTaskCompleted?.(mockTask as any, ctx as any);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should NOT fire webhook for task-moved when events filter is task-completed only", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const ctx = createMockContext({
|
||||
settings: {
|
||||
webhookUrl: "https://example.com/webhook",
|
||||
webhookType: "generic",
|
||||
events: "task-completed",
|
||||
},
|
||||
});
|
||||
|
||||
await plugin.hooks.onTaskMoved?.(
|
||||
mockTask as any,
|
||||
"todo",
|
||||
"in-progress",
|
||||
ctx as any,
|
||||
);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should fire for multiple events when multiple are specified", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const ctx = createMockContext({
|
||||
settings: {
|
||||
webhookUrl: "https://example.com/webhook",
|
||||
webhookType: "generic",
|
||||
events: "task-completed,task-moved",
|
||||
},
|
||||
});
|
||||
|
||||
await plugin.hooks.onTaskCompleted?.(mockTask as any, ctx as any);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
await plugin.hooks.onTaskMoved?.(
|
||||
mockTask as any,
|
||||
"todo",
|
||||
"in-progress",
|
||||
ctx as any,
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hooks.onTaskMoved", () => {
|
||||
it("should send webhook with from and to columns", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const ctx = createMockContext({
|
||||
settings: {
|
||||
webhookUrl: "https://example.com/webhook",
|
||||
webhookType: "generic",
|
||||
events: "",
|
||||
},
|
||||
});
|
||||
|
||||
await plugin.hooks.onTaskMoved?.(
|
||||
mockTask as any,
|
||||
"todo",
|
||||
"in-progress",
|
||||
ctx as any,
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const call = fetchMock.mock.calls[0];
|
||||
const body = JSON.parse(call[1].body);
|
||||
expect(body.event).toBe("task-moved");
|
||||
expect(body.task.from).toBe("todo");
|
||||
expect(body.task.to).toBe("in-progress");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hooks.onError", () => {
|
||||
it("should send webhook with error message", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const ctx = createMockContext({
|
||||
settings: {
|
||||
webhookUrl: "https://example.com/webhook",
|
||||
webhookType: "generic",
|
||||
events: "",
|
||||
},
|
||||
});
|
||||
|
||||
const error = new Error("Something went wrong");
|
||||
await plugin.hooks.onError?.(error, ctx as any);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const call = fetchMock.mock.calls[0];
|
||||
const body = JSON.parse(call[1].body);
|
||||
expect(body.event).toBe("error");
|
||||
expect(body.error).toBe("Something went wrong");
|
||||
});
|
||||
});
|
||||
|
||||
describe("webhook failure handling", () => {
|
||||
it("should not throw when fetch fails", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("Network error"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const ctx = createMockContext({
|
||||
settings: {
|
||||
webhookUrl: "https://example.com/webhook",
|
||||
webhookType: "generic",
|
||||
events: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Should not throw
|
||||
await expect(
|
||||
plugin.hooks.onTaskCompleted?.(mockTask as any, ctx as any),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
expect(ctx.logger.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Network error"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should log error when fetch returns non-ok status", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: "Internal Server Error",
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const ctx = createMockContext({
|
||||
settings: {
|
||||
webhookUrl: "https://example.com/webhook",
|
||||
webhookType: "generic",
|
||||
events: "",
|
||||
},
|
||||
});
|
||||
|
||||
await plugin.hooks.onTaskCompleted?.(mockTask as any, ctx as any);
|
||||
|
||||
expect(ctx.logger.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("500"),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
210
plugins/examples/fusion-plugin-notification/src/index.ts
Normal file
210
plugins/examples/fusion-plugin-notification/src/index.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { definePlugin } from "@fusion/plugin-sdk";
|
||||
import type {
|
||||
FusionPlugin,
|
||||
PluginContext,
|
||||
PluginSettingSchema,
|
||||
} from "@fusion/plugin-sdk";
|
||||
|
||||
// ── Settings Schema ─────────────────────────────────────────────────────────────
|
||||
|
||||
const settingsSchema: Record<string, PluginSettingSchema> = {
|
||||
webhookUrl: {
|
||||
type: "string",
|
||||
label: "Webhook URL",
|
||||
description: "URL to send notifications to",
|
||||
required: true,
|
||||
},
|
||||
webhookType: {
|
||||
type: "enum",
|
||||
label: "Webhook Type",
|
||||
description: "Format of the webhook payload",
|
||||
enumValues: ["slack", "discord", "generic"],
|
||||
defaultValue: "generic",
|
||||
},
|
||||
events: {
|
||||
type: "string",
|
||||
label: "Events Filter",
|
||||
description:
|
||||
"Comma-separated list: task-completed,task-moved,task-failed (empty = all)",
|
||||
defaultValue: "",
|
||||
},
|
||||
};
|
||||
|
||||
// ── Event Filter Helper ────────────────────────────────────────────────────────
|
||||
|
||||
function isEventAllowed(settings: Record<string, unknown>, event: string): boolean {
|
||||
const eventsSetting = settings.events as string | undefined;
|
||||
if (!eventsSetting || eventsSetting.trim() === "") {
|
||||
return true; // Empty = all events
|
||||
}
|
||||
const allowedEvents = eventsSetting
|
||||
.split(",")
|
||||
.map((e) => e.trim())
|
||||
.filter(Boolean);
|
||||
return allowedEvents.includes(event);
|
||||
}
|
||||
|
||||
// ── Webhook Payload Formatters ──────────────────────────────────────────────────
|
||||
|
||||
function formatSlackPayload(message: string): { text: string } {
|
||||
return { text: message };
|
||||
}
|
||||
|
||||
function formatDiscordPayload(message: string): { content: string } {
|
||||
return { content: message };
|
||||
}
|
||||
|
||||
function formatGenericPayload(data: {
|
||||
event: string;
|
||||
taskId?: string;
|
||||
taskTitle?: string;
|
||||
fromColumn?: string;
|
||||
toColumn?: string;
|
||||
errorMessage?: string;
|
||||
}): Record<string, unknown> {
|
||||
return {
|
||||
event: data.event,
|
||||
timestamp: new Date().toISOString(),
|
||||
task: data.taskId
|
||||
? {
|
||||
id: data.taskId,
|
||||
title: data.taskTitle,
|
||||
from: data.fromColumn,
|
||||
to: data.toColumn,
|
||||
}
|
||||
: undefined,
|
||||
error: data.errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Notification Sender ─────────────────────────────────────────────────────────
|
||||
|
||||
async function sendWebhook(
|
||||
url: string,
|
||||
payload: unknown,
|
||||
webhookType: string,
|
||||
logger: PluginContext["logger"],
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error(
|
||||
`Webhook request failed with status ${response.status}: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
// Errors are logged but never propagated
|
||||
logger.error(
|
||||
`Webhook request failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Plugin Definition ───────────────────────────────────────────────────────────
|
||||
|
||||
const plugin: FusionPlugin = definePlugin({
|
||||
manifest: {
|
||||
id: "fusion-plugin-notification",
|
||||
name: "Notification Plugin",
|
||||
version: "0.1.0",
|
||||
description: "Sends webhook notifications on task lifecycle events",
|
||||
settingsSchema,
|
||||
},
|
||||
state: "installed",
|
||||
hooks: {
|
||||
onLoad: (ctx) => {
|
||||
ctx.logger.info("Notification plugin loaded");
|
||||
const webhookUrl = ctx.settings.webhookUrl as string | undefined;
|
||||
if (!webhookUrl) {
|
||||
ctx.logger.warn(
|
||||
"No webhook URL configured. Plugin will not send notifications until webhookUrl is set.",
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
onTaskCompleted: async (task, ctx) => {
|
||||
const webhookUrl = ctx.settings.webhookUrl as string | undefined;
|
||||
const webhookType = (ctx.settings.webhookType as string) || "generic";
|
||||
|
||||
if (!webhookUrl) return;
|
||||
if (!isEventAllowed(ctx.settings, "task-completed")) return;
|
||||
|
||||
const message = `✅ Task completed: ${task.title || task.id}`;
|
||||
let payload: unknown;
|
||||
|
||||
if (webhookType === "slack") {
|
||||
payload = formatSlackPayload(message);
|
||||
} else if (webhookType === "discord") {
|
||||
payload = formatDiscordPayload(message);
|
||||
} else {
|
||||
payload = formatGenericPayload({
|
||||
event: "task-completed",
|
||||
taskId: task.id,
|
||||
taskTitle: task.title,
|
||||
});
|
||||
}
|
||||
|
||||
await sendWebhook(webhookUrl, payload, webhookType, ctx.logger);
|
||||
},
|
||||
|
||||
onTaskMoved: async (task, fromColumn, toColumn, ctx) => {
|
||||
const webhookUrl = ctx.settings.webhookUrl as string | undefined;
|
||||
const webhookType = (ctx.settings.webhookType as string) || "generic";
|
||||
|
||||
if (!webhookUrl) return;
|
||||
if (!isEventAllowed(ctx.settings, "task-moved")) return;
|
||||
|
||||
const message = `📋 Task moved: ${task.title || task.id} (${fromColumn} → ${toColumn})`;
|
||||
let payload: unknown;
|
||||
|
||||
if (webhookType === "slack") {
|
||||
payload = formatSlackPayload(message);
|
||||
} else if (webhookType === "discord") {
|
||||
payload = formatDiscordPayload(message);
|
||||
} else {
|
||||
payload = formatGenericPayload({
|
||||
event: "task-moved",
|
||||
taskId: task.id,
|
||||
taskTitle: task.title,
|
||||
fromColumn,
|
||||
toColumn,
|
||||
});
|
||||
}
|
||||
|
||||
await sendWebhook(webhookUrl, payload, webhookType, ctx.logger);
|
||||
},
|
||||
|
||||
onError: async (error, ctx) => {
|
||||
const webhookUrl = ctx.settings.webhookUrl as string | undefined;
|
||||
const webhookType = (ctx.settings.webhookType as string) || "generic";
|
||||
|
||||
if (!webhookUrl) return;
|
||||
if (!isEventAllowed(ctx.settings, "task-failed")) return;
|
||||
|
||||
const message = `❌ Error: ${error.message}`;
|
||||
let payload: unknown;
|
||||
|
||||
if (webhookType === "slack") {
|
||||
payload = formatSlackPayload(message);
|
||||
} else if (webhookType === "discord") {
|
||||
payload = formatDiscordPayload(message);
|
||||
} else {
|
||||
payload = formatGenericPayload({
|
||||
event: "error",
|
||||
errorMessage: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
await sendWebhook(webhookUrl, payload, webhookType, ctx.logger);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node", "vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
pool: "threads",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user