Add the ticket page data model and its ticket-page CLI
This commit is contained in:
@@ -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(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/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
@@ -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 === "<" ? "<" : "&")));
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,422 +0,0 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
plan.html template for the ticket skill. Copy this file next to plan.md and fill
|
||||
every slot marked "slot:". Everything outside a slot stays as it is, so every plan
|
||||
page looks and behaves the same. The page is self-contained: no network, no
|
||||
external assets, opens from file://.
|
||||
|
||||
Slots, in order:
|
||||
KEY, TITLE, JIRA_URL, BRANCH, KIND the header line
|
||||
criteria one <li> per acceptance criterion
|
||||
questions one <li> per open question, or the "None" line
|
||||
approach the Approach narrative as <p> blocks
|
||||
steps one <li> per step, with a badge for (mock) / (deliverable)
|
||||
risks one <li> per risk or edge case
|
||||
tests one <li> per test plan item
|
||||
mocks one <template> per artboard, plus its tab button;
|
||||
delete the whole <section id="mocks"> and its
|
||||
nav link when the ticket changes nothing a user sees
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<!-- slot: KEY and TITLE -->
|
||||
<title>KACP-00000 plan</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f7f7f5;
|
||||
--surface: #ffffff;
|
||||
--ink: #1c1c1a;
|
||||
--muted: #6b6b66;
|
||||
--line: #e2e2dd;
|
||||
--accent: #2f5fd0;
|
||||
--accent-ink: #ffffff;
|
||||
--warn-bg: #fff6e0;
|
||||
--warn-line: #e6c36b;
|
||||
--badge-mock: #e8f0ff;
|
||||
--badge-deliverable: #eaf7ea;
|
||||
--mono: ui-monospace, "JetBrains Mono", "Cascadia Mono", Menlo, monospace;
|
||||
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Inter", sans-serif;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--bg: #151614;
|
||||
--surface: #1e1f1c;
|
||||
--ink: #ecebe6;
|
||||
--muted: #9b9a93;
|
||||
--line: #2e2f2b;
|
||||
--accent: #7c9cf0;
|
||||
--accent-ink: #0f1420;
|
||||
--warn-bg: #2b2513;
|
||||
--warn-line: #7a6427;
|
||||
--badge-mock: #1f2a44;
|
||||
--badge-deliverable: #1d2f1f;
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #151614;
|
||||
--surface: #1e1f1c;
|
||||
--ink: #ecebe6;
|
||||
--muted: #9b9a93;
|
||||
--line: #2e2f2b;
|
||||
--accent: #7c9cf0;
|
||||
--accent-ink: #0f1420;
|
||||
--warn-bg: #2b2513;
|
||||
--warn-line: #7a6427;
|
||||
--badge-mock: #1f2a44;
|
||||
--badge-deliverable: #1d2f1f;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font: 16px/1.55 var(--sans);
|
||||
}
|
||||
a { color: var(--accent); }
|
||||
code { font-family: var(--mono); font-size: 0.92em; }
|
||||
pre {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.88em;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 12px 14px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
gap: 32px;
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 16px 64px;
|
||||
}
|
||||
nav {
|
||||
position: sticky;
|
||||
top: 24px;
|
||||
align-self: start;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
nav ol { list-style: none; margin: 0; padding: 0; }
|
||||
nav li { margin: 0 0 6px; }
|
||||
nav a { color: var(--muted); text-decoration: none; }
|
||||
nav a:hover, nav a.active { color: var(--ink); }
|
||||
nav .progress {
|
||||
margin-top: 18px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
header.plan { margin-bottom: 28px; }
|
||||
header.plan h1 { margin: 0 0 6px; font-size: 1.7em; line-height: 1.2; }
|
||||
header.plan .meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 18px;
|
||||
color: var(--muted);
|
||||
font-size: 0.92em;
|
||||
}
|
||||
header.plan .meta code { color: var(--ink); }
|
||||
|
||||
section { margin-bottom: 36px; }
|
||||
section h2 {
|
||||
font-size: 1.15em;
|
||||
margin: 0 0 12px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
section p { margin: 0 0 12px; max-width: 72ch; }
|
||||
section ul { padding-left: 22px; margin: 0; }
|
||||
section li { margin: 0 0 8px; max-width: 72ch; }
|
||||
|
||||
.checklist { list-style: none; padding: 0; }
|
||||
.checklist li { display: flex; gap: 10px; align-items: baseline; }
|
||||
.checklist input { margin: 0; flex: none; position: relative; top: 2px; }
|
||||
.checklist li.done label { color: var(--muted); text-decoration: line-through; }
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
font-size: 0.75em;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
margin-left: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.badge.mock { background: var(--badge-mock); }
|
||||
.badge.deliverable { background: var(--badge-deliverable); }
|
||||
|
||||
.callout {
|
||||
background: var(--warn-bg);
|
||||
border: 1px solid var(--warn-line);
|
||||
border-radius: 6px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.callout ul { margin: 0; }
|
||||
.callout.quiet { background: var(--surface); border-color: var(--line); color: var(--muted); }
|
||||
|
||||
/* Mocks: one artboard per <template>, shown in an iframe so the mock's CSS stays its own. */
|
||||
.mock-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.mock-bar .spacer { flex: 1; }
|
||||
.mock-bar button {
|
||||
font: inherit;
|
||||
font-size: 0.9em;
|
||||
color: var(--ink);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 5px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mock-bar button[aria-pressed="true"] {
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.mock-stage {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.mock-stage iframe {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 480px;
|
||||
margin: 0 auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
}
|
||||
.mock-stage[data-width="tablet"] iframe { width: 820px; }
|
||||
.mock-stage[data-width="phone"] iframe { width: 390px; }
|
||||
.mock-note { color: var(--muted); font-size: 0.92em; margin-top: 10px; }
|
||||
.mock-note:empty { display: none; }
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.layout { grid-template-columns: 1fr; gap: 16px; }
|
||||
nav { position: static; }
|
||||
nav ol { display: flex; flex-wrap: wrap; gap: 4px 14px; }
|
||||
nav .progress { display: none; }
|
||||
.mock-stage[data-width="tablet"] iframe,
|
||||
.mock-stage[data-width="phone"] iframe { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
|
||||
<nav aria-label="Sections">
|
||||
<ol>
|
||||
<li><a href="#criteria">Acceptance criteria</a></li>
|
||||
<li><a href="#questions">Open questions</a></li>
|
||||
<li><a href="#approach">Approach</a></li>
|
||||
<!-- slot: mocks nav link, delete with the section when there are no mocks -->
|
||||
<li><a href="#mocks">Mocks</a></li>
|
||||
<li><a href="#steps">Steps</a></li>
|
||||
<li><a href="#risks">Risks and edge cases</a></li>
|
||||
<li><a href="#tests">Test plan</a></li>
|
||||
</ol>
|
||||
<div class="progress" id="progress"></div>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
|
||||
<header class="plan">
|
||||
<!-- slot: KEY, TITLE, JIRA_URL, BRANCH, KIND (bounded or architectural) -->
|
||||
<h1>KACP-00000: Short title of the ticket</h1>
|
||||
<div class="meta">
|
||||
<span><a href="https://ksense-tech.atlassian.net/browse/KACP-00000">Open in Jira</a></span>
|
||||
<span>Branch <code>KACP-00000-Short-Title</code></span>
|
||||
<span>Classified <code>bounded</code></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section id="criteria">
|
||||
<h2>Acceptance criteria</h2>
|
||||
<p class="mock-note">Tick a box as you confirm the plan covers it. Ticks stay in this browser only.</p>
|
||||
<ul class="checklist" data-persist="criteria">
|
||||
<!-- slot: criteria, one <li> per criterion, restated precisely from the ticket -->
|
||||
<li><input type="checkbox" id="ac-1"><label for="ac-1">First acceptance criterion</label></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="questions">
|
||||
<h2>Open questions</h2>
|
||||
<!-- slot: questions. Use class="callout" with one <li> per question that needs an answer
|
||||
before or during implementation. With none, keep the quiet version below instead. -->
|
||||
<div class="callout">
|
||||
<ul>
|
||||
<li>A question whose answer changes the plan. Name the options and which one the plan assumes.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<!-- <div class="callout quiet">None. Nothing here needs an answer before implementation.</div> -->
|
||||
</section>
|
||||
|
||||
<section id="approach">
|
||||
<h2>Approach</h2>
|
||||
<!-- slot: approach, the plan.md narrative as <p> blocks; <code> for file and symbol names -->
|
||||
<p>What will change, why, which files and modules are involved, and how it fits the patterns already in the codebase.</p>
|
||||
</section>
|
||||
|
||||
<!-- slot: mocks. Keep this section only when a step changes what a user sees.
|
||||
One <template> per artboard: a <style> block followed by the screen's markup, rendered
|
||||
in its own iframe so nothing leaks between the mock and this page.
|
||||
Several artboards when the direction is the user's call, one when it is settled.
|
||||
The data-note attribute states what the artboard shows that the implementation will not do. -->
|
||||
<section id="mocks">
|
||||
<h2>Mocks</h2>
|
||||
<div class="mock-bar" role="tablist">
|
||||
<button type="button" data-mock="mock-a" aria-pressed="true">Option A</button>
|
||||
<button type="button" data-mock="mock-b" aria-pressed="false">Option B</button>
|
||||
<span class="spacer"></span>
|
||||
<button type="button" data-width="desktop" aria-pressed="true">Desktop</button>
|
||||
<button type="button" data-width="tablet" aria-pressed="false">Tablet</button>
|
||||
<button type="button" data-width="phone" aria-pressed="false">Phone</button>
|
||||
</div>
|
||||
<div class="mock-stage" data-width="desktop">
|
||||
<iframe title="Mock" sandbox="allow-same-origin"></iframe>
|
||||
<div class="mock-note"></div>
|
||||
</div>
|
||||
|
||||
<template id="mock-a" data-note="Option A keeps the existing layout and adds the new column at the end.">
|
||||
<style>
|
||||
body { margin: 0; padding: 24px; font: 14px/1.5 system-ui, sans-serif; color: #1c1c1a; background: #fff; }
|
||||
</style>
|
||||
<h1 style="font-size:18px;margin:0 0 12px">Screen name</h1>
|
||||
<p>Draw the intended screen here with plain HTML and its own style block.</p>
|
||||
</template>
|
||||
|
||||
<template id="mock-b" data-note="Option B moves the action into the row menu.">
|
||||
<style>
|
||||
body { margin: 0; padding: 24px; font: 14px/1.5 system-ui, sans-serif; color: #1c1c1a; background: #fff; }
|
||||
</style>
|
||||
<h1 style="font-size:18px;margin:0 0 12px">Screen name</h1>
|
||||
<p>Second option.</p>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section id="steps">
|
||||
<h2>Steps</h2>
|
||||
<ul class="checklist" data-persist="steps">
|
||||
<!-- slot: steps, one <li> per step, roughly one commit each.
|
||||
Add <span class="badge mock">mock</span> or <span class="badge deliverable">deliverable</span>
|
||||
after the label text for steps marked that way in plan.md. -->
|
||||
<li><input type="checkbox" id="step-1"><label for="step-1">First step</label></li>
|
||||
<li><input type="checkbox" id="step-2"><label for="step-2">A step that builds a mock</label><span class="badge mock">mock</span></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="risks">
|
||||
<h2>Risks and edge cases considered</h2>
|
||||
<ul>
|
||||
<!-- slot: risks, one <li> per edge case: the case, then how the plan handles it -->
|
||||
<li><strong>Edge case.</strong> How the plan handles it.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="tests">
|
||||
<h2>Test plan</h2>
|
||||
<ul>
|
||||
<!-- slot: tests, one <li> per item: which criterion or ticket test case it proves and how -->
|
||||
<li><strong>Criterion or ticket case.</strong> The suite to run or the test to add, and what it proves.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const key = location.pathname;
|
||||
|
||||
// Checkbox ticks persist per page in this browser. Storage can be unavailable on file://
|
||||
// in some browsers, so every access is guarded.
|
||||
const store = {
|
||||
get(name) { try { return JSON.parse(localStorage.getItem(key + ':' + name) || '[]'); } catch { return []; } },
|
||||
set(name, ids) { try { localStorage.setItem(key + ':' + name, JSON.stringify(ids)); } catch {} },
|
||||
};
|
||||
const lists = [...document.querySelectorAll('.checklist[data-persist]')];
|
||||
const progress = document.getElementById('progress');
|
||||
const paint = () => {
|
||||
const boxes = lists.flatMap(l => [...l.querySelectorAll('input[type="checkbox"]')]);
|
||||
boxes.forEach(b => b.closest('li').classList.toggle('done', b.checked));
|
||||
if (progress) progress.textContent = boxes.filter(b => b.checked).length + ' of ' + boxes.length + ' reviewed';
|
||||
};
|
||||
lists.forEach(list => {
|
||||
const name = list.dataset.persist;
|
||||
const saved = new Set(store.get(name));
|
||||
list.querySelectorAll('input[type="checkbox"]').forEach(box => {
|
||||
box.checked = saved.has(box.id);
|
||||
box.addEventListener('change', () => {
|
||||
const ids = [...list.querySelectorAll('input:checked')].map(b => b.id);
|
||||
store.set(name, ids);
|
||||
paint();
|
||||
});
|
||||
});
|
||||
});
|
||||
paint();
|
||||
|
||||
// Mock tabs and width toggle. Each <template> is a full HTML document loaded into the iframe.
|
||||
const mocks = document.getElementById('mocks');
|
||||
if (mocks) {
|
||||
const frame = mocks.querySelector('iframe');
|
||||
const stage = mocks.querySelector('.mock-stage');
|
||||
const note = mocks.querySelector('.mock-note');
|
||||
const press = (buttons, active) => buttons.forEach(b => b.setAttribute('aria-pressed', String(b === active)));
|
||||
const mockButtons = [...mocks.querySelectorAll('button[data-mock]')];
|
||||
const widthButtons = [...mocks.querySelectorAll('button[data-width]')];
|
||||
const fit = () => {
|
||||
try {
|
||||
const doc = frame.contentDocument;
|
||||
if (doc && doc.documentElement) frame.style.height = Math.max(320, doc.documentElement.scrollHeight + 2) + 'px';
|
||||
} catch {}
|
||||
};
|
||||
const show = id => {
|
||||
const tpl = document.getElementById(id);
|
||||
if (!tpl) return;
|
||||
frame.srcdoc = tpl.innerHTML;
|
||||
note.textContent = tpl.dataset.note || '';
|
||||
press(mockButtons, mockButtons.find(b => b.dataset.mock === id));
|
||||
};
|
||||
frame.addEventListener('load', fit);
|
||||
mockButtons.forEach(b => b.addEventListener('click', () => show(b.dataset.mock)));
|
||||
widthButtons.forEach(b => b.addEventListener('click', () => {
|
||||
stage.dataset.width = b.dataset.width;
|
||||
press(widthButtons, b);
|
||||
setTimeout(fit, 50);
|
||||
}));
|
||||
if (mockButtons[0]) show(mockButtons[0].dataset.mock);
|
||||
}
|
||||
|
||||
// Highlight the section in view in the nav.
|
||||
const links = [...document.querySelectorAll('nav a[href^="#"]')];
|
||||
const byId = new Map(links.map(a => [a.getAttribute('href').slice(1), a]));
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
entries.forEach(e => {
|
||||
const a = byId.get(e.target.id);
|
||||
if (a && e.isIntersecting) { links.forEach(l => l.classList.remove('active')); a.classList.add('active'); }
|
||||
});
|
||||
}, { rootMargin: '-10% 0px -70% 0px' });
|
||||
byId.forEach((_, id) => { const el = document.getElementById(id); if (el) observer.observe(el); });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user