Add the ticket page data model and its ticket-page CLI

This commit is contained in:
Gabriel Brown
2026-09-22 14:54:05 -04:00
parent 1843c0495a
commit dfc009e422
6 changed files with 955 additions and 422 deletions
@@ -0,0 +1,154 @@
// Turns a fetched Jira issue (jira-fetch-issue.sh output) and the ticket's resources/
// directory into the source half of a Ticket: its metadata, every rich-text field as
// sanitized HTML, and its attachments with transcripts and frames.
import { readdirSync, readFileSync, statSync } from "node:fs";
import { extname, join } from "node:path";
import type { Attachment, Section, Segment, Ticket } from "./ticket-data";
import { now } from "./ticket-data";
type Named = { name?: string; value?: string; displayName?: string };
interface JiraLink { type: { inward: string; outward: string }; inwardIssue?: LinkedIssue; outwardIssue?: LinkedIssue }
interface LinkedIssue { key: string; fields?: { summary?: string } }
/** The parts of jira-fetch-issue.sh's response this importer reads. Every other field
* stays `unknown` and only ever passes through `plain()`. */
export interface JiraIssue {
key: string;
fields: {
summary: string;
issuetype?: Named; status?: Named; priority?: Named; assignee?: Named; reporter?: Named;
timeoriginalestimate?: number | null;
parent?: LinkedIssue;
issuelinks?: JiraLink[];
attachment?: { id: string | number; filename: string }[];
[field: string]: unknown;
};
renderedFields: Record<string, unknown>;
names: Record<string, string>;
}
/** Rich-text fields in the order a reader wants them. Anything populated that matches
* none of these still gets imported, after them, so a field with an unexpected name
* is never dropped. */
const FIELD_ORDER = [/^description$/i, /acceptance criteria/i, /review instructions/i, /test cases|working feature proof/i, /risk mitigation$|^risk mitigation/i];
/** Strips anything executable and rewrites attachment links to the downloaded copies,
* so the page renders Jira's HTML offline without running any of it. */
export function sanitize(html: string, attachments: Map<string, string>): string {
return html
.replace(/<(script|style|iframe|object|embed)\b[\s\S]*?<\/\1\s*>/gi, "")
.replace(/<(script|style|iframe|object|embed|link|meta)\b[^>]*\/?>/gi, "")
.replace(/\son[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, "")
.replace(/(href|src)\s*=\s*(["'])\s*javascript:[^"']*\2/gi, '$1="#"')
.replace(/<img\b[^>]*class="icon"[^>]*>/gi, "")
.replace(/https?:\/\/[^"'\s]+\/(?:rest\/api\/\d\/attachment\/content|secure\/attachment)\/(\d+)(?:\/[^"'\s]*)?/g,
(url, id: string) => attachments.get(id) ?? url)
.replace(/<a\s+(?=[^>]*href="https?:)/gi, '<a target="_blank" rel="noreferrer" ');
}
/** Workflow bookkeeping, matched against both field ids and display names. */
const PLUMBING = /^(created|updated|last ?viewed|status ?category|watches|votes|progress|aggregate|work ?ratio|creator|project|time ?tracking|worklog|comment|subtasks|security|environment|resolution|due ?date|rank|epic link|development|children count|remaining estimate|time spent|sprint|flagged)/i;
const hours = (seconds?: number | null) => (seconds ? `${+(seconds / 3600).toFixed(1)}h` : undefined);
/** A plain value for the details table, or null when the field is empty or plumbing. */
function plain(value: unknown): string | null {
if (value == null || value === "" || (Array.isArray(value) && !value.length)) return null;
if (typeof value === "string" || typeof value === "number") return String(value);
if (Array.isArray(value)) return value.map(plain).filter(Boolean).join(", ") || null;
if (typeof value === "object") { const v = value as Named; return v.displayName ?? v.value ?? v.name ?? null; }
return null;
}
export function importIssue(issue: JiraIssue, baseUrl: string): Pick<Ticket, "key" | "title" | "type" | "status" | "priority" | "estimate" | "assignee" | "epic" | "jira" | "links" | "source"> & { unplaced: string[] } {
const f = issue.fields;
const files = new Map((f.attachment ?? []).map(a => [String(a.id), `resources/${a.filename}`]));
const rich = Object.entries(issue.renderedFields)
.filter((e): e is [string, string] => typeof e[1] === "string" && /<[a-z]/i.test(e[1]))
.map(([id, html]) => ({ id, title: issue.names[id] ?? id, html }));
const rank = (title: string) => { const i = FIELD_ORDER.findIndex(r => r.test(title)); return i < 0 ? FIELD_ORDER.length : i; };
rich.sort((a, b) => rank(a.title) - rank(b.title));
const sections: Section[] = rich.map(r => ({ id: r.id, title: r.title, html: sanitize(r.html, files), origin: "jira" }));
const shown: [string, string | undefined | null][] = [
["Type", f.issuetype?.name], ["Status", f.status?.name], ["Priority", f.priority?.name],
["Assignee", f.assignee?.displayName], ["Reporter", f.reporter?.displayName],
["Estimate", hours(f.timeoriginalestimate)], ["Labels", plain(f.labels)], ["Components", plain(f.components)],
["Fix versions", plain(f.fixVersions)], ["Story points", plain(f.customfield_10016)],
];
const fields = shown.filter((x): x is [string, string] => !!x[1]);
// Populated plain fields the page does not show, named for the agent's sweep.
const known = new Set(["summary", "issuetype", "status", "priority", "assignee", "reporter", "timeoriginalestimate", "labels",
"components", "fixVersions", "customfield_10016", "parent", "issuelinks", "attachment", "description", ...rich.map(r => r.id)]);
const unplaced = Object.entries(f)
.filter(([k, v]) => !known.has(k) && plain(v) && !PLUMBING.test(k) && !PLUMBING.test(issue.names[k] ?? ""))
.map(([k, v]) => `${issue.names[k] ?? k}: ${String(plain(v)).slice(0, 80)}`);
const links = (f.issuelinks ?? []).flatMap(l => {
const other = l.outwardIssue ?? l.inwardIssue;
return other ? [{ type: l.outwardIssue ? l.type.outward : l.type.inward, key: other.key, title: other.fields?.summary ?? "" }] : [];
});
return {
key: issue.key,
title: f.summary,
type: f.issuetype?.name ?? "Issue",
status: f.status?.name ?? "",
priority: f.priority?.name ?? "",
estimate: hours(f.timeoriginalestimate),
assignee: f.assignee?.displayName,
epic: f.parent ? { key: f.parent.key, title: f.parent.fields?.summary ?? "" } : null,
jira: `${baseUrl.replace(/\/$/, "")}/browse/${issue.key}`,
links,
source: { importedAt: now(), fields, sections },
unplaced,
};
}
export function parseSrt(srt: string): Segment[] {
const secs = (t: string) => { const [h, m, s] = t.replace(",", ".").split(":"); return Math.round((+h * 3600 + +m * 60 + +s) * 10) / 10; };
return srt.trim().split(/\r?\n\s*\r?\n/).flatMap(block => {
const lines = block.trim().split(/\r?\n/);
const time = lines.find(l => l.includes("-->"));
if (!time) return [];
const text = lines.slice(lines.indexOf(time) + 1).join(" ").trim();
return text.length > 1 ? [{ t: secs(time.split("-->")[0].trim()), text }] : [];
});
}
const KIND: Record<string, Attachment["kind"]> = {
".png": "image", ".jpg": "image", ".jpeg": "image", ".gif": "image", ".webp": "image", ".svg": "image",
".mp4": "video", ".webm": "video", ".mov": "video", ".mkv": "video",
".mp3": "audio", ".m4a": "audio", ".wav": "audio", ".ogg": "audio",
};
/** Every file in resources/, with each video's transcript and frames attached to it. */
export function scanResources(ticketDir: string): Attachment[] {
const dir = join(ticketDir, "resources");
let names: string[];
try { names = readdirSync(dir); } catch { return []; }
const skip = (n: string) => n === "issue.raw.json" || /\.transcript\.(txt|srt|vtt)$/.test(n) || n.endsWith(".frames") || n.startsWith(".");
return names.filter(n => !skip(n) && statSync(join(dir, n)).isFile()).sort().map(name => {
const kind = KIND[extname(name).toLowerCase()] ?? "file";
const base = name.slice(0, -extname(name).length);
const att: Attachment = { name, path: `resources/${name}`, kind, bytes: statSync(join(dir, name)).size };
if (kind === "video" || kind === "audio") {
try { att.transcript = parseSrt(readFileSync(join(dir, `${base}.transcript.srt`), "utf8")); } catch {}
try { att.frames = readdirSync(join(dir, `${base}.frames`)).filter(n => /\.(png|jpe?g)$/i.test(n)).sort().map(n => `resources/${base}.frames/${n}`); } catch {}
}
return att;
});
}
/** Readable text from Jira-ish HTML, for the markdown export an agent reads. */
export function htmlToText(html: string): string {
return html
.replace(/<(br|\/p|\/div|\/h\d|\/li|\/tr)\b[^>]*>/gi, "\n")
.replace(/<\/t[dh]>/gi, " | ")
.replace(/<li\b[^>]*>/gi, "- ")
.replace(/<[^>]+>/g, "")
.replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'")
.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
}
@@ -0,0 +1,243 @@
// The ticket page's data model: one `ticket.data.js` per ticket and one `epic.data.js`
// per epic, each a single `window.X = <strict JSON>;` assignment so a page opened from
// file:// can load it with a <script> tag. Everything that reads or writes those files
// goes through this module, so the shape is defined once and validated on every write.
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
export type StepStatus = "todo" | "running" | "done";
export type StepKind = "code" | "mock" | "deliverable";
export interface Section {
id: string;
title: string;
html: string;
/** "jira" is imported verbatim from the ticket; "note" is written by the agent. */
origin: "jira" | "note";
}
export interface Segment { t: number; text: string }
export interface Attachment {
name: string;
/** Relative to the ticket directory. */
path: string;
kind: "image" | "video" | "audio" | "file";
bytes: number;
transcript?: Segment[];
frames?: string[];
}
export interface Criterion {
id: string;
text: string;
steps: number[];
status: "todo" | "done";
doneAt?: string;
evidence?: string;
}
export interface Step {
n: number;
text: string;
kind: StepKind;
tdd?: boolean;
status: StepStatus;
commits: string[];
doneAt?: string;
}
export interface Risk { risk: string; handling: string; criteria?: string[]; fromTicket?: boolean }
export interface Test { text: string; kind: string; criteria?: string[]; fromTicket?: boolean }
/** A design artboard drawn at plan time. `criteria` names what it illustrates, so the
* Progress tab can show it beside the proof for those criteria. */
export interface Mock { id: string; title: string; note: string; html: string; criteria: string[] }
export interface Plan {
writtenAt: string;
approvedAt?: string;
criteria: Criterion[];
/** HTML fragments. Empty means none. */
questions: string[];
/** HTML. */
approach: string;
decisions: [question: string, choice: string, why: string][];
steps: Step[];
risks: Risk[];
tests: Test[];
mocks: Mock[];
}
export interface Proof {
kind: "image" | "text" | "test";
/** Relative to the ticket directory, for image and text proof. */
file?: string;
/** Embedded content of a text proof, because file:// pages cannot fetch it. */
text?: string;
/** The passing test's name, for test proof. */
test?: string;
proves: string[];
caption: string;
mock?: string;
at: string;
}
export interface Finding {
id: string;
severity: "CONFIRMED" | "PLAUSIBLE";
text: string;
where?: string;
status: "open" | "fixed" | "rejected";
commit?: string;
reason?: string;
at: string;
}
export interface Event { at: string; kind: string; text: string; commit?: string }
export interface Ticket {
schema: 1;
key: string;
title: string;
type: string;
status: string;
priority: string;
estimate?: string;
assignee?: string;
epic: { key: string; title: string } | null;
jira: string;
branch?: string;
classified?: "bounded" | "architectural";
links: { type: string; key: string; title: string }[];
source: { importedAt: string; fields: [string, string][]; sections: Section[] };
attachments: Attachment[];
plan: Plan | null;
proof: Proof[];
review: {
findings: Finding[];
preMr: { verdict: string; recommendation?: string; head?: string; runs: number; at: string } | null;
};
jiraFields: { name: string; at: string }[];
mr: { at: string } | null;
events: Event[];
updatedAt: string;
}
export type StoryStatus = "todo" | "planned" | "building" | "verifying" | "ready" | "done";
export interface Story {
key: string;
title: string;
hours?: number;
risk?: string;
after: string[];
status: StoryStatus;
/** Set once a ticket page exists for the story, so the index can link to it. */
page?: boolean;
progress?: { criteria: [number, number]; steps: [number, number] };
}
export interface Epic {
schema: 1;
key: string;
title: string;
jira: string;
/** Why the build order is what it is, one reason per entry. */
why: string[];
/** In build order. */
stories: Story[];
closed: { key: string; title: string }[];
updatedAt: string;
}
export const now = () => new Date().toISOString();
const GLOBAL = { ticket: "TICKET", epic: "EPIC" } as const;
export function readData(file: string, kind: "ticket"): Ticket;
export function readData(file: string, kind: "epic"): Epic;
export function readData(file: string, kind: keyof typeof GLOBAL): Ticket | Epic {
const raw = readFileSync(file, "utf8");
const prefix = `window.${GLOBAL[kind]} = `;
if (!raw.startsWith(prefix)) throw new Error(`${file} does not start with ${prefix.trim()}`);
return JSON.parse(raw.slice(prefix.length).trim().replace(/;$/, ""));
}
/** Validates, then writes through a temp file and a rename so a page reloading
* the file mid-write never sees half of it. */
export function writeData(file: string, kind: "ticket", data: Ticket): void;
export function writeData(file: string, kind: "epic", data: Epic): void;
export function writeData(file: string, kind: keyof typeof GLOBAL, data: Ticket | Epic) {
const problems = kind === "ticket" ? validateTicket(data as Ticket) : validateEpic(data as Epic);
if (problems.length) throw new Error(`refusing to write ${file}:\n - ${problems.join("\n - ")}`);
data.updatedAt = now();
mkdirSync(dirname(file), { recursive: true });
const tmp = join(dirname(file), `.${Date.now()}.${process.pid}.tmp`);
writeFileSync(tmp, `window.${GLOBAL[kind]} = ${JSON.stringify(data, null, 1)};\n`);
renameSync(tmp, file);
}
const isStr = (x: unknown): x is string => typeof x === "string";
export function validateTicket(t: Ticket): string[] {
const p: string[] = [];
if (t.schema !== 1) p.push("schema must be 1");
for (const k of ["key", "title", "type", "status", "jira"] as const) if (!isStr(t[k]) || !t[k]) p.push(`${k} is required`);
if (!Array.isArray(t.events)) p.push("events must be a list");
if (!t.plan) return p;
const plan = t.plan;
const ids = new Set<string>();
plan.criteria.forEach((c, i) => {
if (!c.id || !c.text) p.push(`criterion ${i + 1} needs an id and text`);
if (ids.has(c.id)) p.push(`criterion id ${c.id} is used twice`);
ids.add(c.id);
for (const n of c.steps) if (!plan.steps.some(s => s.n === n)) p.push(`${c.id} names step ${n}, which does not exist`);
});
plan.steps.forEach((s, i) => {
if (s.n !== i + 1) p.push(`steps must be numbered 1..${plan.steps.length} in order, found ${s.n} at position ${i + 1}`);
if (!["code", "mock", "deliverable"].includes(s.kind)) p.push(`step ${s.n} kind must be code, mock or deliverable`);
if (!["todo", "running", "done"].includes(s.status)) p.push(`step ${s.n} status must be todo, running or done`);
});
const mockIds = new Set(plan.mocks.map(m => m.id));
for (const m of plan.mocks) for (const c of m.criteria) if (!ids.has(c)) p.push(`mock ${m.id} names unknown criterion ${c}`);
for (const r of plan.risks) for (const c of r.criteria ?? []) if (!ids.has(c)) p.push(`risk "${r.risk}" names unknown criterion ${c}`);
for (const x of plan.tests) for (const c of x.criteria ?? []) if (!ids.has(c)) p.push(`test "${x.text}" names unknown criterion ${c}`);
for (const pr of t.proof) {
for (const c of pr.proves) if (!ids.has(c)) p.push(`proof ${pr.file ?? pr.test} names unknown criterion ${c}`);
if (pr.mock && !mockIds.has(pr.mock)) p.push(`proof ${pr.file} names unknown mock ${pr.mock}`);
}
return p;
}
export function validateEpic(e: Epic): string[] {
const p: string[] = [];
if (e.schema !== 1) p.push("schema must be 1");
if (!e.key || !e.title) p.push("key and title are required");
const keys = new Set<string>();
for (const s of e.stories) {
if (keys.has(s.key)) p.push(`${s.key} appears twice in the order`);
keys.add(s.key);
}
for (const s of e.stories) for (const a of s.after) if (!keys.has(a)) p.push(`${s.key} waits on ${a}, which is not in the epic`);
return p;
}
/** Where a ticket stands, derived from its data rather than stored, so the page,
* the epic index and `show` can never disagree. */
export function storyStatus(t: Ticket): StoryStatus {
if (!t.plan) return "todo";
if (t.review.preMr?.verdict === "Ready to Open MR") return "ready";
if (!t.plan.approvedAt) return "planned";
return t.plan.steps.every(s => s.status === "done") ? "verifying" : "building";
}
export function progress(t: Ticket): Story["progress"] {
const pl = t.plan;
if (!pl) return { criteria: [0, 0], steps: [0, 0] };
return {
criteria: [pl.criteria.filter(c => c.status === "done").length, pl.criteria.length],
steps: [pl.steps.filter(s => s.status === "done").length, pl.steps.length],
};
}
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# Runs the ticket page CLI under bun. See ../SITE.md, or: ticket-page help
set -euo pipefail
here="$(dirname "$(readlink -f "$0")")"
command -v bun >/dev/null 2>&1 || { echo "ticket-page: bun is not on PATH" >&2; exit 127; }
exec bun "$here/ticket-page.ts" "$@"
@@ -0,0 +1,116 @@
// Run with: bun test (from this directory)
import { describe, expect, test } from "bun:test";
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { importIssue, type JiraIssue, parseSrt, sanitize } from "./jira-import";
import { readData, type Ticket, writeData } from "./ticket-data";
const issue = (over: Partial<JiraIssue["fields"]> = {}, rendered: Record<string, string> = {}): JiraIssue => ({
key: "KACP-1",
fields: { summary: "Add canonical IDs", issuetype: { name: "Story" }, status: { name: "To Do" }, priority: { name: "Medium" },
parent: { key: "KACP-9", fields: { summary: "Engagement epic" } }, attachment: [{ id: "42830", filename: "shot.png" }], ...over },
renderedFields: { description: "<p>Do the thing</p>", ...rendered },
names: { description: "Description", customfield_1: "Risk Mitigation", customfield_2: "Some Oddly Named Field", customfield_3: "Developer Review Instructions" },
});
describe("sanitize", () => {
test("drops anything executable and points attachments at the downloaded copy", () => {
const out = sanitize(
`<p onclick="x()">hi</p><script>alert(1)</script><a href="javascript:evil()">x</a>` +
`<img src="https://ksense-tech.atlassian.net/rest/api/3/attachment/content/42830" alt="shot.png">`,
new Map([["42830", "resources/shot.png"]]));
expect(out).not.toMatch(/onclick|<script|javascript:/i);
expect(out).toContain('src="resources/shot.png"');
});
});
describe("importIssue", () => {
test("keeps every rich field, reader order first and unexpected names after", () => {
const t = importIssue(issue({}, { customfield_2: "<p>odd</p>", customfield_1: "<table><tr><td>r</td></tr></table>", customfield_3: "<p>dev</p>" }), "https://j");
expect(t.source.sections.map(s => s.title)).toEqual(["Description", "Developer Review Instructions", "Risk Mitigation", "Some Oddly Named Field"]);
expect(t.epic).toEqual({ key: "KACP-9", title: "Engagement epic" });
});
});
test("parseSrt skips cues with no words", () => {
const segs = parseSrt("1\n00:00:01,500 --> 00:00:03,000\n Hello there\n\n2\n00:00:03,000 --> 00:00:04,000\n I\n\n3\n00:01:02,000 --> 00:01:05,000\nBye");
expect(segs).toEqual([{ t: 1.5, text: "Hello there" }, { t: 62, text: "Bye" }]);
});
test("writeData refuses a plan that names a missing step and leaves the file as it was", () => {
const dir = mkdtempSync(join(tmpdir(), "tp-"));
const file = join(dir, "ticket.data.js");
const base = { schema: 1, key: "KACP-1", title: "t", type: "Story", status: "To Do", priority: "", epic: null, jira: "j", links: [],
source: { importedAt: "", fields: [], sections: [] }, attachments: [], plan: null, proof: [], review: { findings: [], preMr: null },
jiraFields: [], mr: null, events: [], updatedAt: "" } satisfies Ticket;
writeData(file, "ticket", base);
const before = readFileSync(file, "utf8");
const broken: Ticket = { ...base, plan: { writtenAt: "", criteria: [{ id: "AC1", text: "x", steps: [2], status: "todo" }], questions: [], approach: "",
decisions: [], steps: [{ n: 1, text: "s", kind: "code", status: "todo", commits: [] }], risks: [], tests: [], mocks: [] } };
expect(() => writeData(file, "ticket", broken)).toThrow(/AC1 names step 2/);
expect(readFileSync(file, "utf8")).toBe(before);
});
describe("the CLI, end to end", () => {
const docs = mkdtempSync(join(tmpdir(), "tp-docs-"));
const cli = (...args: string[]) => spawnSync("bun", [join(import.meta.dir, "ticket-page.ts"), ...args], { encoding: "utf8", env: { ...process.env, TICKET_DOCS: docs } });
const scratch = mkdtempSync(join(tmpdir(), "tp-in-"));
const issuePath = join(scratch, "issue.json");
writeFileSync(issuePath, JSON.stringify(issue()));
const dir = join(docs, "KACP-9", "KACP-1");
const ticket = () => readData(join(dir, "ticket.data.js"), "ticket");
const epic = () => readData(join(docs, "KACP-9", "epic.data.js"), "epic");
test("init files the ticket under its epic and writes both pages", () => {
expect(cli("init", "KACP-1", "--issue", issuePath).status).toBe(0);
for (const f of ["index.html", "ticket.data.js"]) expect(existsSync(join(dir, f))).toBe(true);
expect(existsSync(join(docs, "KACP-9", "index.html"))).toBe(true);
expect(readFileSync(join(dir, "index.html"), "utf8")).toMatch(/href="..\/..\/_site\/site.css\?v=[0-9a-f]{10}"/);
expect(epic().stories.map(s => [s.key, s.status])).toEqual([["KACP-1", "todo"]]);
});
test("a step is done only once its commit exists, and the epic row follows", () => {
const plan = join(scratch, "plan.json");
writeFileSync(plan, JSON.stringify({ approach: "<p>a</p>", criteria: [{ id: "AC1", text: "c", steps: [1] }], steps: [{ n: 1, text: "s" }, { n: 2, text: "t" }] }));
expect(cli("plan", "KACP-1", "--file", plan).status).toBe(0);
expect(epic().stories[0].status).toBe("planned");
cli("approve", "KACP-1");
const refused = cli("step", "KACP-1", "1", "done");
expect(refused.status).toBe(2);
expect(refused.stderr).toMatch(/--commit/);
expect(cli("step", "KACP-1", "1", "done", "--commit", "abc1234").status).toBe(0);
expect(ticket().plan!.steps[0]).toMatchObject({ status: "done", commits: ["abc1234"] });
expect(epic().stories[0]).toMatchObject({ status: "building", progress: { steps: [1, 2] } });
});
test("text proof is embedded, because a file:// page cannot fetch it", () => {
mkdirSync(join(dir, "proof"), { recursive: true });
writeFileSync(join(dir, "proof", "counts.txt"), "70 of 70 codes stored");
expect(cli("proof", "KACP-1", "--file", "proof/counts.txt", "--proves", "AC1", "--caption", "Counts").status).toBe(0);
expect(ticket().proof[0]).toMatchObject({ kind: "text", text: "70 of 70 codes stored" });
expect(cli("proof", "KACP-1", "--file", "proof/counts.txt", "--proves", "AC7", "--caption", "x").status).toBe(1);
});
test("--amend keeps progress for the steps that remain, a rewrite starts over", () => {
const plan = join(scratch, "plan2.json");
const exported = JSON.parse(cli("plan-json", "KACP-1").stdout);
exported.steps.push({ n: 3, text: "a review fix" });
writeFileSync(plan, JSON.stringify(exported));
expect(cli("plan", "KACP-1", "--file", plan, "--amend").status).toBe(0);
expect(ticket().plan!.steps.map(s => s.status)).toEqual(["done", "todo", "todo"]);
expect(ticket().plan!.approvedAt).toBeDefined();
expect(ticket().proof).toHaveLength(1);
expect(cli("plan", "KACP-1", "--file", plan).status).toBe(0);
expect(ticket().plan!.steps.every(s => s.status === "todo")).toBe(true);
expect(ticket().proof).toHaveLength(0);
});
test("re-importing keeps the plan unless --reset", () => {
cli("init", "KACP-1", "--issue", issuePath);
expect(ticket().plan).not.toBeNull();
cli("init", "KACP-1", "--issue", issuePath, "--reset");
expect(ticket().plan).toBeNull();
});
});
@@ -0,0 +1,436 @@
// ticket-page: the only writer of a ticket's page data. See ../SITE.md for when the
// ticket skill calls each command. Run `ticket-page help` for the command list.
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join, relative, resolve } from "node:path";
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { htmlToText, importIssue, type JiraIssue, scanResources } from "./jira-import";
import {
type Criterion, type Epic, type Finding, type Mock, now, type Plan, progress, readData, type Step,
storyStatus, type Ticket, writeData,
} from "./ticket-data";
const SITE_TEMPLATES = resolve(import.meta.dir, "../templates/site");
class UsageError extends Error {}
function fail(msg: string): never { throw new UsageError(msg); }
/** --flag value pairs and bare --switches after the positional arguments. */
function parse(argv: string[]) {
const pos: string[] = [];
const opt = new Map<string, string[]>();
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (!a.startsWith("--")) { pos.push(a); continue; }
const name = a.slice(2);
const next = argv[i + 1];
const value = next === undefined || next.startsWith("--") ? "true" : (i++, next);
opt.set(name, [...(opt.get(name) ?? []), value]);
}
return { pos, one: (k: string) => opt.get(k)?.at(-1), all: (k: string) => opt.get(k) ?? [], has: (k: string) => opt.has(k) };
}
const list = (s?: string) => (s ? s.split(",").map(x => x.trim()).filter(Boolean) : []);
// ── Where things live ────────────────────────────────────────────────────────
export function docsRoot(): string {
if (process.env.TICKET_DOCS) return resolve(process.env.TICKET_DOCS);
const git = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" });
if (git.status !== 0) fail("not inside a git repository, and TICKET_DOCS is not set");
return join(git.stdout.trim(), ".claude/docs/epics");
}
function ticketDir(key: string): string {
const root = docsRoot();
const hit = existsSync(root) && readdirSync(root).map(d => join(root, d, key)).find(d => existsSync(join(d, "ticket.data.js")));
return hit || fail(`no ticket page for ${key} under ${root}. Run: ticket-page init ${key} --issue <issue.raw.json>`);
}
const ticketFile = (dir: string) => join(dir, "ticket.data.js");
const epicFile = (epicKey: string) => join(docsRoot(), epicKey, "epic.data.js");
/** Copies the shared renderer into _site/ when the skill's copy changed, and writes the
* page shell. Run on every write so an old ticket picks up the current renderer. */
function refreshSite(pageDir: string, kind: "ticket" | "epic", title: string) {
const site = join(docsRoot(), "_site");
mkdirSync(site, { recursive: true });
const version = createHash("sha1");
for (const f of ["site.css", "site.js"]) {
const from = join(SITE_TEMPLATES, f), to = join(site, f);
const body = readFileSync(from, "utf8");
version.update(body);
if (!existsSync(to) || readFileSync(to, "utf8") !== body) copyFileSync(from, to);
}
// The shell names the renderer by content hash, so a browser that cached an older
// renderer loads the new one the next time the page opens.
const shell = readFileSync(join(SITE_TEMPLATES, `${kind}.html`), "utf8")
.replaceAll("{{SITE}}", relative(pageDir, site) || ".")
.replaceAll("{{VERSION}}", version.digest("hex").slice(0, 10))
.replaceAll("{{TITLE}}", title.replace(/[<&]/g, c => (c === "<" ? "&lt;" : "&amp;")));
const page = join(pageDir, "index.html");
if (!existsSync(page) || readFileSync(page, "utf8") !== shell) writeFileSync(page, shell);
}
// ── Ticket reads and writes ──────────────────────────────────────────────────
function load(key: string) {
const dir = ticketDir(key);
return { dir, t: readData(ticketFile(dir), "ticket") };
}
function save(dir: string, t: Ticket) {
writeData(ticketFile(dir), "ticket", t);
refreshSite(dir, "ticket", `${t.key}: ${t.title}`);
if (t.epic) syncStory(t);
}
function log(t: Ticket, kind: string, text: string, commit?: string) {
t.events.push({ at: now(), kind, text, ...(commit ? { commit } : {}) });
}
/** Keeps this ticket's row on its epic index current. Creates the epic page if the
* ticket is the first one worked under it. A story marked done stays done. */
function syncStory(t: Ticket) {
const epic = t.epic!;
const file = epicFile(epic.key);
const e: Epic = existsSync(file) ? readData(file, "epic")
: { schema: 1, key: epic.key, title: epic.title, jira: t.jira.replace(t.key, epic.key), why: [], stories: [], closed: [], updatedAt: now() };
let s = e.stories.find(x => x.key === t.key);
if (!s) { s = { key: t.key, title: t.title, after: [], status: "todo" }; e.stories.push(s); }
s.title = t.title;
s.page = true;
s.progress = progress(t);
if (s.status !== "done") s.status = storyStatus(t);
writeData(file, "epic", e);
refreshSite(dirname(file), "epic", `${e.key}: ${e.title}`);
}
// ── Commands ─────────────────────────────────────────────────────────────────
type Args = ReturnType<typeof parse>;
const commands: Record<string, { usage: string; run: (a: Args) => void }> = {};
const command = (name: string, usage: string, run: (a: Args) => void) => { commands[name] = { usage, run }; };
command("init", "init <KEY> --issue <issue.raw.json> [--reset] import the ticket, keeping any plan unless --reset", a => {
const [key] = a.pos;
const issuePath = a.one("issue") ?? fail("init needs --issue <issue.raw.json>");
const issue: JiraIssue = JSON.parse(readFileSync(issuePath, "utf8"));
if (issue.key !== key) fail(`${issuePath} holds ${issue.key}, not ${key}`);
const imported = importIssue(issue, process.env.JIRA_BASE_URL ?? "https://ksense-tech.atlassian.net");
const { unplaced, ...meta } = imported;
const dir = join(docsRoot(), meta.epic?.key ?? "tickets", key);
mkdirSync(join(dir, "resources"), { recursive: true });
const prior = existsSync(ticketFile(dir)) ? readData(ticketFile(dir), "ticket") : null;
const keep = prior && !a.has("reset") ? prior : null;
const t: Ticket = {
schema: 1, ...meta,
branch: keep?.branch, classified: keep?.classified,
// Notes the agent wrote survive a re-import. Jira's own sections are replaced.
source: { ...meta.source, sections: [...meta.source.sections, ...(keep?.source.sections.filter(s => s.origin === "note") ?? [])] },
attachments: scanResources(dir),
plan: keep?.plan ?? null, proof: keep?.proof ?? [], review: keep?.review ?? { findings: [], preMr: null },
jiraFields: keep?.jiraFields ?? [], mr: keep?.mr ?? null, events: keep?.events ?? [], updatedAt: now(),
};
log(t, "fetch", prior ? (keep ? "Re-imported the ticket from Jira, plan kept" : "Re-imported the ticket from Jira, plan cleared") : "Imported the ticket from Jira");
save(dir, t);
console.log(dir);
if (unplaced.length) console.log(`\nPopulated fields not on the page. Add any that matter with \`ticket-page note\`:\n ${unplaced.join("\n ")}`);
});
command("scan", "scan <KEY> re-read resources/ after downloads, transcripts or frames change", a => {
const { dir, t } = load(a.pos[0]);
t.attachments = scanResources(dir);
const withTranscript = t.attachments.filter(x => x.transcript?.length).length;
log(t, "fetch", `Scanned ${t.attachments.length} attachments, ${withTranscript} with a transcript`);
save(dir, t);
});
command("meta", "meta <KEY> [--branch B] [--classified bounded|architectural]", a => {
const { dir, t } = load(a.pos[0]);
if (a.has("branch")) t.branch = a.one("branch");
const c = a.one("classified");
if (c) t.classified = c === "bounded" || c === "architectural" ? c : fail("--classified is bounded or architectural");
save(dir, t);
});
command("note", "note <KEY> <title> --file <notes.html> add or replace an agent-written section on the Ticket tab", a => {
const [key, title] = a.pos;
if (!title) fail("note needs a title");
const { dir, t } = load(key);
const html = readFileSync(a.one("file") ?? fail("note needs --file"), "utf8");
const id = "note-" + title.toLowerCase().replace(/[^a-z0-9]+/g, "-");
t.source.sections = [...t.source.sections.filter(s => s.id !== id), { id, title, html, origin: "note" }];
save(dir, t);
});
command("plan", "plan <KEY> --file <plan.json> [--amend] write the plan. A rewrite resets progress and proof; --amend keeps both for the steps and criteria that remain", a => {
const { dir, t } = load(a.pos[0]);
type StepIn = Omit<Step, "status" | "commits" | "doneAt"> & Partial<Pick<Step, "kind">>;
type CritIn = Omit<Criterion, "status" | "doneAt" | "evidence">;
const input = JSON.parse(readFileSync(a.one("file") ?? fail("plan needs --file"), "utf8")) as
Partial<Omit<Plan, "steps" | "criteria">> & { steps: StepIn[]; criteria: CritIn[] };
const prior = t.plan;
const amend = a.has("amend");
if (amend && !prior) fail("--amend needs an existing plan");
const was = <T,>(list: T[] | undefined, match: (x: T) => boolean) => (amend ? list?.find(match) : undefined);
const plan: Plan = {
writtenAt: now(),
...(amend && prior?.approvedAt ? { approvedAt: prior.approvedAt } : {}),
criteria: (input.criteria ?? fail("plan.json needs criteria")).map(c => {
const old = was(prior?.criteria, x => x.id === c.id);
return { ...c, steps: c.steps ?? [], status: old?.status ?? "todo", ...(old?.doneAt ? { doneAt: old.doneAt } : {}), ...(old?.evidence ? { evidence: old.evidence } : {}) };
}),
questions: input.questions ?? [],
approach: input.approach ?? "",
decisions: input.decisions ?? [],
steps: (input.steps ?? fail("plan.json needs steps")).map(s => {
const old = was(prior?.steps, x => x.n === s.n && x.text === s.text);
return { ...s, kind: s.kind ?? "code", status: old?.status ?? "todo", commits: old?.commits ?? [], ...(old?.doneAt ? { doneAt: old.doneAt } : {}) };
}),
risks: input.risks ?? [],
tests: input.tests ?? [],
mocks: (input.mocks ?? prior?.mocks ?? []).map(m => ({ ...m, criteria: m.criteria ?? [] })),
};
t.plan = plan;
const ids = new Set(plan.criteria.map(c => c.id));
t.proof = amend ? t.proof.map(p => ({ ...p, proves: p.proves.filter(c => ids.has(c)) })).filter(p => p.proves.length) : [];
log(t, "plan", amend ? `Amended the plan: ${plan.criteria.length} criteria, ${plan.steps.length} steps`
: `Wrote the plan: ${plan.criteria.length} criteria, ${plan.steps.length} steps${t.classified ? `, classified ${t.classified}` : ""}`);
save(dir, t);
});
command("plan-json", "plan-json <KEY> print the current plan as plan.json, to edit and reload with plan --amend", a => {
const pl = load(a.pos[0]).t.plan ?? fail("there is no plan yet");
console.log(JSON.stringify({
criteria: pl.criteria.map(({ id, text, steps }) => ({ id, text, steps })),
questions: pl.questions, approach: pl.approach, decisions: pl.decisions,
steps: pl.steps.map(({ n, text, kind, tdd }) => ({ n, text, kind, ...(tdd ? { tdd } : {}) })),
risks: pl.risks, tests: pl.tests,
}, null, 2));
});
command("mock", "mock <KEY> <id> --title T --criteria AC1,AC2 --file <artboard.html> [--note N] add or replace a mock artboard", a => {
const [key, id] = a.pos;
const { dir, t } = load(key);
if (!t.plan) fail("write the plan before its mocks");
const m: Mock = { id: id ?? fail("mock needs an id"), title: a.one("title") ?? fail("mock needs --title"), note: a.one("note") ?? "", html: readFileSync(a.one("file") ?? fail("mock needs --file"), "utf8"),
criteria: list(a.one("criteria")).length ? list(a.one("criteria")) : fail("mock needs --criteria, the criteria it illustrates") };
t.plan!.mocks = [...t.plan!.mocks.filter(x => x.id !== id), m];
save(dir, t);
});
command("approve", "approve <KEY> mark the plan approved; Phase 2 starts", a => {
const { dir, t } = load(a.pos[0]);
if (!t.plan) fail("there is no plan to approve");
t.plan!.approvedAt = now();
log(t, "approve", "Plan approved");
save(dir, t);
});
command("step", "step <KEY> <n> running|done|todo [--commit SHA]... done needs at least one commit, or --no-commit for a step with none", a => {
const [key, n, status] = a.pos;
const { dir, t } = load(key);
const step = t.plan?.steps.find(s => s.n === Number(n)) ?? fail(`no step ${n}`);
if (status !== "running" && status !== "done" && status !== "todo") fail("status is running, done or todo");
const commits = a.all("commit");
if (status === "done" && !commits.length && !step.commits.length && !a.has("no-commit")) fail(`step ${n} is done only once its commit exists: pass --commit <sha>`);
if (status === "running") t.plan!.steps.forEach(s => { if (s.status === "running") s.status = "todo"; });
step.status = status;
step.commits = [...new Set([...step.commits, ...commits])];
step.doneAt = status === "done" ? now() : undefined;
if (status === "running") log(t, "start", `Started step ${n}: ${step.text}`);
if (status === "done") log(t, "commit", `Finished step ${n}`, commits.at(-1));
save(dir, t);
});
command("criterion", "criterion <KEY> <id> done|todo [--evidence TEXT]", a => {
const [key, id, status] = a.pos;
const { dir, t } = load(key);
const c = t.plan?.criteria.find(x => x.id === id) ?? fail(`no criterion ${id}`);
if (status !== "done" && status !== "todo") fail("status is done or todo");
c.status = status;
c.doneAt = status === "done" ? now() : undefined;
if (a.has("evidence")) c.evidence = a.one("evidence");
if (status === "done") log(t, "criterion", `Met ${id}${c.evidence ? `: ${c.evidence}` : ""}`);
save(dir, t);
});
command("proof", "proof <KEY> (--file proof/<name> | --test NAME) --proves AC1,AC2 --caption TEXT [--mock ID]", a => {
const { dir, t } = load(a.pos[0]);
const file = a.one("file"), test = a.one("test");
if (!file === !test) fail("proof takes exactly one of --file or --test");
const proves = list(a.one("proves"));
if (!proves.length) fail("proof needs --proves");
const caption = a.one("caption") ?? fail("proof needs --caption");
if (file && !existsSync(join(dir, file))) fail(`${file} does not exist in ${dir}`);
const isText = !!file && /\.(txt|log|md|json|csv|diff)$/i.test(file);
t.proof = t.proof.filter(p => (file ? p.file !== file : p.test !== test));
t.proof.push({
kind: test ? "test" : isText ? "text" : "image", ...(file ? { file } : { test }),
...(isText ? { text: readFileSync(join(dir, file!), "utf8") } : {}),
proves, caption, ...(a.one("mock") ? { mock: a.one("mock") } : {}), at: now(),
});
log(t, "proof", `Captured proof for ${proves.join(", ")}: ${caption}`);
save(dir, t);
});
command("unproof", "unproof <KEY> <file-or-test> drop a proof entry, for files cut from proof/", a => {
const [key, what] = a.pos;
const { dir, t } = load(key);
t.proof = t.proof.filter(p => p.file !== what && p.test !== what);
save(dir, t);
});
command("finding", "finding <KEY> add TEXT --severity CONFIRMED|PLAUSIBLE [--where file:line] | finding <KEY> <id> fixed --commit SHA | rejected --reason TEXT", a => {
const [key, idOrAdd, arg] = a.pos;
const { dir, t } = load(key);
if (idOrAdd === "add") {
const severity = a.one("severity");
if (severity !== "CONFIRMED" && severity !== "PLAUSIBLE") fail("--severity is CONFIRMED or PLAUSIBLE");
const f: Finding = { id: `F${t.review.findings.length + 1}`, severity, text: arg ?? fail("finding add needs its text"), status: "open", at: now(), ...(a.one("where") ? { where: a.one("where") } : {}) };
t.review.findings.push(f);
log(t, "review", `Review finding ${f.id}: ${f.text}`);
console.log(f.id);
} else {
const f = t.review.findings.find(x => x.id === idOrAdd) ?? fail(`no finding ${idOrAdd}`);
if (arg === "fixed") { f.status = "fixed"; f.commit = a.one("commit") ?? fail("fixed needs --commit"); log(t, "fix", `Fixed ${f.id}: ${f.text}`, f.commit); }
else if (arg === "rejected") { f.status = "rejected"; f.reason = a.one("reason") ?? fail("rejected needs --reason"); log(t, "review", `Rejected ${f.id}: ${f.reason}`); }
else fail("finding <id> takes fixed or rejected");
f.at = now();
}
save(dir, t);
});
command("verdict", "verdict <KEY> <verdict> [--recommendation R] [--head SHA] record a pre-mr-review run", a => {
const [key, ...words] = a.pos;
const { dir, t } = load(key);
const verdict = words.join(" ") || fail("verdict needs the verdict text");
t.review.preMr = { verdict, recommendation: a.one("recommendation"), head: a.one("head"), runs: (t.review.preMr?.runs ?? 0) + 1, at: now() };
log(t, "verdict", `pre-mr-review: ${verdict}`);
save(dir, t);
});
command("jira", "jira <KEY> <field name> record a Jira field as filled", a => {
const [key, ...name] = a.pos;
const { dir, t } = load(key);
const field = name.join(" ") || fail("jira needs the field name");
t.jiraFields = [...t.jiraFields.filter(f => f.name !== field), { name: field, at: now() }];
log(t, "jira", `Filled ${field} in Jira`);
save(dir, t);
});
command("mr", "mr <KEY> record that mr.md is written", a => {
const { dir, t } = load(a.pos[0]);
t.mr = { at: now() };
log(t, "mr", "Wrote mr.md");
save(dir, t);
});
command("event", "event <KEY> TEXT [--kind K] [--commit SHA] add a line to the live log", a => {
const [key, ...text] = a.pos;
const { dir, t } = load(key);
log(t, a.one("kind") ?? "note", text.join(" ") || fail("event needs text"), a.one("commit"));
save(dir, t);
});
command("show", "show <KEY> print where the ticket stands", a => {
const { dir, t } = load(a.pos[0]);
const p = progress(t)!;
console.log(`${t.key} ${storyStatus(t)} criteria ${p.criteria.join("/")} steps ${p.steps.join("/")} proof ${t.proof.length}\n${join(dir, "index.html")}`);
for (const s of t.plan?.steps ?? []) console.log(` ${s.status === "done" ? "x" : s.status === "running" ? ">" : " "} ${s.n}. ${s.text}${s.commits.length ? ` ${s.commits.join(" ")}` : ""}`);
});
command("path", "path <KEY> print the page path", a => console.log(join(ticketDir(a.pos[0]), "index.html")));
command("open", "open <KEY> open the page in the browser", a => {
spawnSync("xdg-open", [join(ticketDir(a.pos[0]), "index.html")], { stdio: "ignore" });
});
command("md", "md <KEY> print the ticket and plan as markdown, for an agent's reading order", a => {
const { t } = load(a.pos[0]);
const out: string[] = [`# ${t.key}: ${t.title}`, "", `${t.type}, ${t.status}. ${t.jira}`, ""];
for (const [k, v] of t.source.fields) out.push(`- ${k}: ${v}`);
for (const s of t.source.sections) out.push("", `## ${s.title}${s.origin === "note" ? " (notes)" : ""}`, "", htmlToText(s.html));
const pl = t.plan;
if (pl) {
out.push("", "# Plan", "", "## Acceptance criteria");
for (const c of pl.criteria) out.push(`- [${c.status === "done" ? "x" : " "}] ${c.id}: ${c.text} (steps ${c.steps.join(", ")})`);
out.push("", "## Open questions", ...(pl.questions.length ? pl.questions.map(q => `- ${htmlToText(q)}`) : ["None"]));
out.push("", "## Approach", "", htmlToText(pl.approach));
if (pl.decisions.length) out.push("", "## Decisions", ...pl.decisions.map(([q, c, w]) => `- ${q}: ${c}. ${w}`));
out.push("", "## Steps", ...pl.steps.map(s => `- [${s.status === "done" ? "x" : " "}] ${s.n}. ${s.text}${s.kind !== "code" ? ` (${s.kind})` : ""}${s.commits.length ? ` ${s.commits.join(" ")}` : ""}`));
out.push("", "## Risks", ...pl.risks.map(r => `- ${r.risk}: ${r.handling}${r.fromTicket ? " (from the ticket)" : ""}`));
out.push("", "## Test plan", ...pl.tests.map(x => `- ${x.kind}: ${x.text}${x.fromTicket ? " (from the ticket)" : ""}`));
}
if (t.proof.length) out.push("", "# Proof", ...t.proof.map(p => `- ${p.file ?? p.test}: ${p.caption} (proves ${p.proves.join(", ")})`));
console.log(out.join("\n"));
});
command("check", "check <KEY> validate the data file", a => {
const { t } = load(a.pos[0]);
console.log(`${t.key} is valid`);
});
// Epic commands work on the epic's own data file.
function withEpic(key: string, fn: (e: Epic) => void) {
const file = epicFile(key);
const e = existsSync(file) ? readData(file, "epic") : fail(`no epic page for ${key}. Run: ticket-page epic ${key} init --title T`);
fn(e);
writeData(file, "epic", e);
refreshSite(dirname(file), "epic", `${e.key}: ${e.title}`);
}
command("epic", "epic <EPIC> init --title T [--jira URL] | story <KEY> [--title T --hours H --risk R --after K1,K2 --status S] | order <KEY>... | why --file <reasons.txt> | closed <KEY> --title T", a => {
const [key, sub, ...rest] = a.pos;
if (sub === "init") {
const file = epicFile(key);
const e: Epic = existsSync(file) ? readData(file, "epic") : { schema: 1, key, title: "", jira: "", why: [], stories: [], closed: [], updatedAt: now() };
e.title = a.one("title") ?? (e.title || fail("epic init needs --title"));
e.jira = a.one("jira") ?? (e.jira || `${process.env.JIRA_BASE_URL ?? "https://ksense-tech.atlassian.net"}/browse/${key}`);
writeData(file, "epic", e);
refreshSite(dirname(file), "epic", `${e.key}: ${e.title}`);
return console.log(join(dirname(file), "index.html"));
}
withEpic(key, e => {
if (sub === "story") {
const [storyKey] = rest;
let s = e.stories.find(x => x.key === storyKey);
if (!s) { s = { key: storyKey ?? fail("story needs a key"), title: "", after: [], status: "todo" }; e.stories.push(s); }
if (a.has("title")) s.title = a.one("title")!;
if (a.has("hours")) s.hours = Number(a.one("hours"));
if (a.has("risk")) s.risk = a.one("risk");
if (a.has("after")) s.after = list(a.one("after"));
const st = a.one("status");
if (st) s.status = (["todo", "planned", "building", "verifying", "ready", "done"] as const).find(x => x === st) ?? fail("unknown status");
if (!s.title) fail(`${s.key} needs --title`);
} else if (sub === "order") {
const missing = e.stories.filter(s => !rest.includes(s.key)).map(s => s.key);
if (missing.length || rest.length !== e.stories.length) fail(`order must list every story exactly once. Missing: ${missing.join(", ") || "none"}`);
e.stories = rest.map(k => e.stories.find(s => s.key === k) ?? fail(`${k} is not in the epic`));
} else if (sub === "why") {
e.why = readFileSync(a.one("file") ?? fail("why needs --file"), "utf8").split("\n").map(x => x.trim()).filter(Boolean);
} else if (sub === "closed") {
const [k] = rest;
e.closed = [...e.closed.filter(c => c.key !== k), { key: k ?? fail("closed needs a key"), title: a.one("title") ?? fail("closed needs --title") }];
e.stories = e.stories.filter(s => s.key !== k);
} else fail("epic takes init, story, order, why or closed");
});
});
function help() {
console.log("ticket-page <command> ...\n");
for (const c of Object.values(commands)) console.log(" " + c.usage);
}
if (import.meta.main) {
const [name, ...rest] = process.argv.slice(2);
if (!name || name === "help" || !commands[name]) { help(); process.exit(name && name !== "help" ? 2 : 0); }
try {
const args = parse(rest);
if (name !== "epic" && !/^[A-Z][A-Z0-9]+-\d+$/.test(args.pos[0] ?? "")) fail(`${name} needs a ticket key like KACP-12345 first`);
commands[name].run(args);
} catch (err) {
console.error(`ticket-page ${name}: ${err instanceof Error ? err.message : err}`);
process.exit(err instanceof UsageError ? 2 : 1);
}
}