/** * scripts/work/prd-ship.mjs — flip a PRD's status to `shipped`. * * Invoked when an epic completes and its seed PRD's implementation is fully * landed. Writes back to the PRD's frontmatter: * - status: -> shipped * - shipped: (today, UTC) * - shipping-commits: [, , ...] (optional) * * Refuses to flip from `draft` (must go through human review first — * draft -> approved is the human step, NOT automated by this command). * Refuses to flip if already `shipped` (idempotent fail-soft). * * Usage: * pnpm work prd-ship * pnpm work prd-ship --commits sha1,sha2,sha3 * pnpm work prd-ship --auto-commits # derive from `git log` since the PRD's * # created date on the PRD file's first * # appearance in git history */ import fs from "node:fs"; import path from "node:path"; import { execSync } from "node:child_process"; /** * Parse the top YAML frontmatter block of a markdown file. Returns * { frontmatter: Record, body: string, * raw: { frontmatterText, frontmatterStart, frontmatterEnd } } * * Hand-rolled YAML-ish parser — handles scalar lines and "- item" lists, * the only two shapes the PRD/epic/story frontmatter uses. Sufficient for * this use case; not a general YAML parser. */ export function parseFrontmatter(text) { if (!text.startsWith("---\n")) { return { frontmatter: {}, body: text, raw: null }; } const end = text.indexOf("\n---\n", 4); if (end === -1) return { frontmatter: {}, body: text, raw: null }; const fmText = text.slice(4, end); const body = text.slice(end + 5); const fm = {}; let currentList = null; for (const line of fmText.split("\n")) { if (line.startsWith(" - ")) { if (currentList) currentList.push(line.slice(4).trim()); continue; } currentList = null; const m = /^([a-zA-Z_-]+):\s*(.*)$/.exec(line); if (!m) continue; const [, key, value] = m; if (value === "") { // Empty value — could be the start of a list fm[key] = []; currentList = fm[key]; } else { fm[key] = value; } } return { frontmatter: fm, body, raw: { frontmatterText: fmText, frontmatterStart: 4, frontmatterEnd: end }, }; } /** * Serialize a parsed frontmatter back to YAML text. Preserves declared key * order for keys that were in the original; appends new keys at the end. */ export function serializeFrontmatter(originalText, newFrontmatter) { const lines = []; const originalKeyOrder = []; if (originalText) { for (const line of originalText.split("\n")) { const m = /^([a-zA-Z_-]+):/.exec(line); if (m) originalKeyOrder.push(m[1]); } } const seen = new Set(); for (const key of originalKeyOrder) { if (seen.has(key)) continue; seen.add(key); if (!(key in newFrontmatter)) continue; emitKey(lines, key, newFrontmatter[key]); } for (const key of Object.keys(newFrontmatter)) { if (seen.has(key)) continue; emitKey(lines, key, newFrontmatter[key]); } return lines.join("\n"); } function emitKey(lines, key, value) { if (Array.isArray(value)) { lines.push(`${key}:`); for (const item of value) lines.push(` - ${item}`); } else { lines.push(`${key}: ${value}`); } } /** * Flip a PRD's status to "shipped". Pure function over the file's text. * Returns the new file text. Throws on illegal transitions. */ export function flipPrdStatus(text, { shippedDate, commits } = {}) { const { frontmatter, body, raw } = parseFrontmatter(text); if (!raw) { throw new Error("PRD has no parseable frontmatter"); } const current = frontmatter.status; if (current === "shipped") { throw new Error("PRD is already marked shipped (idempotent fail-soft)"); } if (current === "draft") { throw new Error( "PRD is still draft — flip to approved (human review) before shipping", ); } if (current !== "approved" && current !== "in-review") { throw new Error( `Unexpected PRD status "${current}" — expected approved or in-review`, ); } const newFm = { ...frontmatter }; newFm.status = "shipped"; newFm.shipped = shippedDate ?? new Date().toISOString().slice(0, 10); if (commits && commits.length > 0) { newFm["shipping-commits"] = commits; } const newFmText = serializeFrontmatter(raw.frontmatterText, newFm); return `---\n${newFmText}\n---\n${body}`; } /** * Derive shipping commits for a PRD by walking `git log` for the PRD's * linked epic folder. Best-effort; returns empty array if the epic folder * doesn't exist or git isn't available. */ export function deriveShippingCommits(repoRoot, prdId, workRoot) { // Find the epic whose `prd:` matches this prdId const epicsRoot = path.join(workRoot, "epics"); if (!fs.existsSync(epicsRoot)) return []; const entries = fs.readdirSync(epicsRoot, { withFileTypes: true }); let epicDir = null; for (const entry of entries) { if (!entry.isDirectory()) continue; const epicFile = path.join(epicsRoot, entry.name, "_epic.md"); if (!fs.existsSync(epicFile)) continue; const { frontmatter } = parseFrontmatter(fs.readFileSync(epicFile, "utf8")); if (frontmatter.prd === prdId) { epicDir = entry.name; break; } } if (!epicDir) return []; try { const log = execSync( `git log --format=%h --reverse -- docs/work/epics/${epicDir}/`, { cwd: repoRoot, encoding: "utf8" }, ); return log.trim().split("\n").filter(Boolean); } catch { return []; } } export function findPrdPath(workRoot, prdId) { const prdsDir = path.join(workRoot, "prds"); if (!fs.existsSync(prdsDir)) return null; for (const file of fs.readdirSync(prdsDir)) { if (!file.endsWith(".prd.md")) continue; const text = fs.readFileSync(path.join(prdsDir, file), "utf8"); const { frontmatter } = parseFrontmatter(text); if (frontmatter.id === prdId) { return path.join(prdsDir, file); } } return null; } // ---- CLI ---- export function runCli(args, { repoRoot, workRoot }) { const prdId = args[0]; if (!prdId) { process.stderr.write( "Usage: pnpm work prd-ship [--commits sha1,sha2,...] [--auto-commits]\n", ); return 2; } let commits; let autoCommits = false; for (let i = 1; i < args.length; i++) { if (args[i] === "--commits") { commits = args[++i] .split(",") .map((s) => s.trim()) .filter(Boolean); } else if (args[i] === "--auto-commits") { autoCommits = true; } } const prdPath = findPrdPath(workRoot, prdId); if (!prdPath) { process.stderr.write( `PRD with id="${prdId}" not found under docs/work/prds/\n`, ); return 1; } if (autoCommits && !commits) { commits = deriveShippingCommits(repoRoot, prdId, workRoot); } const text = fs.readFileSync(prdPath, "utf8"); let newText; try { newText = flipPrdStatus(text, { commits }); } catch (err) { process.stderr.write(`Cannot ship PRD ${prdId}: ${err.message}\n`); return 1; } fs.writeFileSync(prdPath, newText); process.stdout.write( `Shipped ${prdId} (${path.relative(repoRoot, prdPath)})` + (commits ? ` with ${commits.length} commit(s)` : "") + "\n", ); return 0; }