Skip to content

Extension Recipes

[view as markdown]

Practical, copy-pasteable extension patterns for common use cases.

Block status transitions that violate your workflow rules.

import type { ExtensionInit } from "@fiberplane/extensions";
const init: ExtensionInit = (fp) => {
const requiredLabels = new Set(
fp.config.get("required_labels", "").split(",").filter(Boolean),
);
fp.on("issue:status:changing", async ({ issue, from, to }) => {
if (to !== "done" || requiredLabels.size === 0) {
return undefined;
}
const labels = (issue.properties?.labels as string[]) ?? [];
const missing = [...requiredLabels].filter((l) => !labels.includes(l));
if (missing.length > 0) {
return {
code: "MISSING_LABELS",
message: `Add required labels before marking done: ${missing.join(", ")}`,
};
}
return undefined;
});
};
export default init;

Add a guidance comment and optional child issues when an issue is created.

import type { ExtensionInit } from "@fiberplane/extensions";
const init: ExtensionInit = (fp) => {
fp.on("issue:created", async ({ issue }) => {
try {
await fp.comments.create(
issue.id,
"Remember to add acceptance criteria before starting work.",
);
} catch (err) {
fp.log.warn(`Failed to add guidance comment: ${err}`);
}
});
};
export default init;

Run an external tool and use its output in a hook.

import type { ExtensionInit } from "@fiberplane/extensions";
import { spawn } from "node:child_process";
import { resolve } from "node:path";
function runCommand(cmd: string, args: string[], cwd: string): Promise<{ code: number; stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const proc = spawn(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
proc.stdout.on("data", (d) => { stdout += d; });
proc.stderr.on("data", (d) => { stderr += d; });
proc.on("error", reject);
proc.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
});
}
const init: ExtensionInit = (fp) => {
fp.on("issue:status:changing", async ({ issue, to }) => {
if (to !== "done") {
return undefined;
}
const result = await runCommand("make", ["test"], fp.projectDir);
if (result.code !== 0) {
return {
code: "TESTS_FAILED",
message: `Tests must pass before marking done. Exit code: ${result.code}`,
};
}
return undefined;
});
};
export default init;

Read config values and parse them into typed data.

import type { ExtensionInit } from "@fiberplane/extensions";
const init: ExtensionInit = (fp) => {
const blockedStatuses = new Set(
fp.config.get("blocked_transitions_from", "").split(",").filter(Boolean),
);
const dryRun = ["true", "1", "yes"].includes(
fp.config.get("dry_run", "false").toLowerCase(),
);
fp.on("issue:status:changing", ({ from, to }) => {
if (blockedStatuses.has(from)) {
if (dryRun) {
fp.log.warn(`Dry run: would block transition from ${from} to ${to}`);
return undefined;
}
return {
code: "BLOCKED_TRANSITION",
message: `Transitions from '${from}' are not allowed`,
};
}
return undefined;
});
};
export default init;

Send a Slack message whenever an issue is created or changes status. Set the webhook URL as an environment variable or in local extension config, and configure which events to notify on.

Terminal window
# Set webhook URL as env var (add to ~/.zshrc to persist)
export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/T.../B.../xxx"
.fp/config.toml
[extensions.slack-notify]
events = "issue:created,issue:status:changed"
project_name = "My Project"
.fp/config.local.toml
[extensions.slack-notify]
webhook_url = "https://hooks.slack.com/services/T.../B.../xxx"
import type { ExtensionInit } from "@fiberplane/extensions";
const init: ExtensionInit = async (fp) => {
const webhookUrl = process.env.SLACK_WEBHOOK_URL ?? fp.config.get("webhook_url", "");
if (!webhookUrl) {
fp.log.warn("No Slack webhook URL configured.");
return;
}
const projectName = fp.config.get("project_name", "");
const prefix = projectName ? `*[${projectName}]*` : "📋";
fp.on("issue:created", async ({ issue }) => {
const text = `${prefix} 🆕 *${issue.id}*: ${issue.title}`;
await fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text,
blocks: [{
type: "section",
text: { type: "mrkdwn", text },
}],
}),
}).catch((err) => fp.log.warn(`Slack failed: ${err}`));
});
fp.on("issue:status:changed", async ({ issue, from, to }) => {
const emoji = to === "done" ? "" : to === "in-progress" ? "🔄" : "📝";
const text = `${prefix} ${emoji} *${issue.id}* \`${from}\`\`${to}\`\n${issue.title}`;
await fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text,
blocks: [{
type: "section",
text: { type: "mrkdwn", text },
}],
}),
}).catch((err) => fp.log.warn(`Slack failed: ${err}`));
});
};
export default init;

The same pattern works for Discord ({ content: "text" }), Microsoft Teams, or any webhook service — just change the payload shape. See the full slack-notify example for config-driven event filtering and status transition guards.