chore(template): clean-slate template snapshot from bb4a0c7
Curated, product-agnostic snapshot of the post-story-04 tree: demo content deleted, auth-only reference feature, web-next shell, all gates green. Product-specific docs, ADRs 027-029, PRDs/epics/archive, editor library traces, and product naming are curated out; generic template repairs (coverage provider devDeps, root test:coverage script, live lint fixes, root-only release-please) are kept. See TEMPLATE.md for provenance, curation list, and usage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
This commit is contained in:
240
scripts/work/prd-ship.mjs
Normal file
240
scripts/work/prd-ship.mjs
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* 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: <approved|in-review> -> shipped
|
||||
* - shipped: <ISO date> (today, UTC)
|
||||
* - shipping-commits: [<sha1>, <sha2>, ...] (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 <prd-id>
|
||||
* pnpm work prd-ship <prd-id> --commits sha1,sha2,sha3
|
||||
* pnpm work prd-ship <prd-id> --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<string, string|string[]>, 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 <prd-id> [--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;
|
||||
}
|
||||
Reference in New Issue
Block a user