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,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();
});
});