244 lines
8.6 KiB
TypeScript
244 lines
8.6 KiB
TypeScript
// 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],
|
|
};
|
|
}
|