Files
Panama/user/agents/skills/ticket/scripts/ticket-data.ts
T

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],
};
}