437 lines
23 KiB
TypeScript
437 lines
23 KiB
TypeScript
// 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 === "<" ? "<" : "&")));
|
|
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);
|
|
}
|
|
}
|