playbook/outfitter-agents/scripts/ensure-changeset.ts

263 lines
7.3 KiB
TypeScript

#!/usr/bin/env bun
/**
* Ensures a changeset exists for the current branch if plugin files changed.
*
* Usage:
* bun scripts/ensure-changeset.ts [commit-message]
*
* If no commit message provided, reads from .git/COMMIT_EDITMSG or HEAD commit.
*
* Logic:
* 1. Get current branch and parent branch
* 2. Diff against parent to find files changed in this branch only
* 3. Filter for plugin directories
* 4. Parse conventional commit → bump type
* 5. Create/update .changeset/{branch-name}.md
*/
import { execSync } from "node:child_process";
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const ROOT = import.meta.dirname ? join(import.meta.dirname, "..") : process.cwd();
const CHANGESET_DIR = join(ROOT, ".changeset");
// Local plugins that need changesets
const PLUGIN_DIRS = ["outfitter", "but", "gt", "cli-dev"];
// Conventional commit types that skip changesets
const SKIP_TYPES = new Set(["chore", "test", "ci", "build"]);
// Type to bump mapping
const TYPE_TO_BUMP: Record<string, "major" | "minor" | "patch"> = {
feat: "minor",
fix: "patch",
perf: "patch",
refactor: "patch",
docs: "patch",
style: "patch",
};
interface ParsedCommit {
type: string;
scope: string | null;
breaking: boolean;
description: string;
body: string;
}
interface ChangesetData {
plugins: Map<string, "major" | "minor" | "patch">;
description: string;
}
function exec(cmd: string): string {
try {
return execSync(cmd, { cwd: ROOT, encoding: "utf-8" }).trim();
} catch {
return "";
}
}
function getCurrentBranch(): string {
return exec("git rev-parse --abbrev-ref HEAD");
}
function getParentBranch(): string {
// Try gt parent first (Graphite)
const gtParent = exec("gt parent 2>/dev/null");
if (gtParent && !gtParent.includes("error")) {
return gtParent;
}
// Fall back to main/master
const defaultBranch = exec("git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null")
.replace("refs/remotes/origin/", "") || "main";
return defaultBranch;
}
function getChangedFiles(parentBranch: string): string[] {
// Get files changed in current branch vs parent
// Include both committed and staged changes
const committed = exec(`git diff --name-only ${JSON.stringify(parentBranch)}...HEAD 2>/dev/null`);
const staged = exec("git diff --name-only --cached");
const files = new Set<string>();
for (const f of committed.split("\n").filter(Boolean)) files.add(f);
for (const f of staged.split("\n").filter(Boolean)) files.add(f);
return Array.from(files);
}
function getAffectedPlugins(files: string[]): string[] {
const plugins = new Set<string>();
for (const file of files) {
for (const plugin of PLUGIN_DIRS) {
if (file.startsWith(`${plugin}/`)) {
plugins.add(plugin);
}
}
}
return Array.from(plugins).sort();
}
function getCommitMessage(arg?: string): string {
// Priority: CLI arg > COMMIT_EDITMSG > HEAD commit
if (arg) return arg;
const commitMsgFile = join(ROOT, ".git", "COMMIT_EDITMSG");
if (existsSync(commitMsgFile)) {
return readFileSync(commitMsgFile, "utf-8").trim();
}
return exec("git log -1 --format=%B HEAD");
}
function parseConventionalCommit(message: string): ParsedCommit {
const lines = message.split("\n");
const firstLine = lines[0] || "";
const body = lines.slice(1).join("\n").trim();
// Parse: type(scope)!: description
// Regex: ^(\w+)(?:\(([^)]+)\))?(!)?: (.+)$
const match = firstLine.match(/^(\w+)(?:\(([^)]+)\))?(!)?: (.+)$/);
if (!match) {
// Not a conventional commit, treat as patch with full message as description
return {
type: "patch",
scope: null,
breaking: false,
description: firstLine,
body,
};
}
const [, type, scope, bang, description] = match;
const breaking = bang === "!" || body.includes("BREAKING CHANGE:");
return {
type: type.toLowerCase(),
scope: scope || null,
breaking,
description,
body,
};
}
function getBumpType(parsed: ParsedCommit): "major" | "minor" | "patch" | "skip" {
if (parsed.breaking) return "major";
if (SKIP_TYPES.has(parsed.type)) return "skip";
return TYPE_TO_BUMP[parsed.type] || "patch";
}
function sanitizeBranchName(branch: string): string {
// Convert branch name to valid filename
return branch.replace(/[^a-zA-Z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
}
function getChangesetPath(branch: string): string {
const sanitized = sanitizeBranchName(branch);
return join(CHANGESET_DIR, `${sanitized}.md`);
}
function readExistingChangeset(path: string): ChangesetData | null {
if (!existsSync(path)) return null;
const content = readFileSync(path, "utf-8");
const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
if (!match) return null;
const [, frontmatter, description] = match;
const plugins = new Map<string, "major" | "minor" | "patch">();
for (const line of frontmatter.split("\n")) {
const pkgMatch = line.match(/^"([^"]+)":\s*(major|minor|patch)$/);
if (pkgMatch) {
plugins.set(pkgMatch[1], pkgMatch[2] as "major" | "minor" | "patch");
}
}
return { plugins, description: description.trim() };
}
function writeChangeset(path: string, data: ChangesetData): void {
const frontmatter = Array.from(data.plugins.entries())
.map(([pkg, bump]) => `"${pkg}": ${bump}`)
.join("\n");
const content = `---\n${frontmatter}\n---\n\n${data.description}\n`;
writeFileSync(path, content);
}
function mergeBump(
existing: "major" | "minor" | "patch" | undefined,
incoming: "major" | "minor" | "patch"
): "major" | "minor" | "patch" {
const order = { major: 3, minor: 2, patch: 1 };
if (!existing) return incoming;
return order[existing] >= order[incoming] ? existing : incoming;
}
function main() {
const branch = getCurrentBranch();
if (branch === "main" || branch === "master" || branch === "HEAD") {
console.log("⏭ Skipping changeset on trunk branch");
process.exit(0);
}
const parent = getParentBranch();
const files = getChangedFiles(parent);
const plugins = getAffectedPlugins(files);
if (plugins.length === 0) {
console.log("⏭ No plugin changes detected");
process.exit(0);
}
const message = getCommitMessage(process.argv[2]);
const parsed = parseConventionalCommit(message);
const bump = getBumpType(parsed);
if (bump === "skip") {
// Clean up stale changeset if one exists from previous non-skip commits
const changesetPath = getChangesetPath(branch);
if (existsSync(changesetPath)) {
unlinkSync(changesetPath);
execSync(`git add ${JSON.stringify(changesetPath)}`, { cwd: ROOT });
console.log(`🗑 Removed stale changeset: ${changesetPath}`);
}
console.log(`⏭ Skipping changeset for ${parsed.type}: commit`);
process.exit(0);
}
const changesetPath = getChangesetPath(branch);
const existing = readExistingChangeset(changesetPath);
// Build new changeset data
const data: ChangesetData = {
plugins: existing?.plugins || new Map(),
description: parsed.description,
};
// Update/add affected plugins with appropriate bump
for (const plugin of plugins) {
const currentBump = data.plugins.get(plugin);
data.plugins.set(plugin, mergeBump(currentBump, bump));
}
writeChangeset(changesetPath, data);
// Stage the changeset
execSync(`git add ${JSON.stringify(changesetPath)}`, { cwd: ROOT });
const pluginList = plugins.map((p) => `${p}:${bump}`).join(", ");
console.log(`✓ Changeset updated: ${pluginList}`);
console.log(`${changesetPath}`);
}
main();