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",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user