The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").
Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.
Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).
One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
148 lines
5.6 KiB
TypeScript
148 lines
5.6 KiB
TypeScript
// The issue drafted from a debug session — pure functions, so what the composer shows and
|
||
// what it saves are one computation, testable without a browser.
|
||
//
|
||
// The composer renders the markdown HERE, in the user's language, and the server carries it as it
|
||
// was reviewed (backend app/issues/bundle.py): the server adds no sentence of its own.
|
||
import type { ServerLogRow } from "./api";
|
||
import { FRONTEND_SHA, FRONTEND_VERSION } from "./version";
|
||
|
||
/** Where a step came from: a marker the user set, a page change, or typed in the composer. */
|
||
type StepKind = "marker" | "nav" | "manual";
|
||
|
||
export interface DraftStep {
|
||
id: string;
|
||
kind: StepKind;
|
||
text: string;
|
||
/** A screenshot was taken at this step. */
|
||
shot?: boolean;
|
||
}
|
||
|
||
/** What the composer edits and saves (`form` in the saved draft). */
|
||
export interface DraftForm {
|
||
title: string;
|
||
description: string;
|
||
steps: DraftStep[];
|
||
/** Log row ids taken out of the evidence. */
|
||
excluded: number[];
|
||
}
|
||
|
||
/** The steps a session suggests: every marker the user set, and the page changes between them. A
|
||
* page change repeated back to back is one step; a screenshot belongs to the step before it. A
|
||
* marker set without a label is named by its time (`clock`) — the one thing that finds it in the
|
||
* log — rather than left empty, where the issue would drop it. */
|
||
export function draftSteps(
|
||
rows: readonly ServerLogRow[],
|
||
pageName: (page: string) => string,
|
||
clock: (ts: string) => string,
|
||
) {
|
||
const steps: DraftStep[] = [];
|
||
for (const row of rows) {
|
||
if (row.source !== "web") continue;
|
||
const attrs = row.attrs ?? {};
|
||
if (row.event === "debug.step") {
|
||
const label = typeof attrs.label === "string" ? attrs.label.trim() : "";
|
||
steps.push({ id: `r${row.id}`, kind: "marker", text: label || clock(row.ts) });
|
||
} else if (row.event === "nav.view") {
|
||
const page = typeof attrs.page === "string" ? attrs.page : String(attrs.path ?? "");
|
||
const text = page ? pageName(page) : "";
|
||
const last = steps[steps.length - 1];
|
||
if (!text || (last?.kind === "nav" && last.text === text)) continue;
|
||
steps.push({ id: `r${row.id}`, kind: "nav", text });
|
||
} else if (row.event === "debug.screenshot") {
|
||
const last = steps[steps.length - 1];
|
||
if (last) last.shot = true;
|
||
}
|
||
}
|
||
return steps;
|
||
}
|
||
|
||
/** The operating system a user agent names. Checked most specific first: Android's agent also says
|
||
* "Linux" — and says it first — and an iPad's also says "Mac OS X". */
|
||
export function osName(ua: string): string {
|
||
const tests: [RegExp, (m: RegExpExecArray) => string][] = [
|
||
[/Android [\d.]+/, (m) => m[0]],
|
||
[/(iPhone|iPad)[^)]*? OS ([\d_]+)/, (m) => `${m[1]} ${m[2]!.replace(/_/g, ".")}`],
|
||
[/Windows NT [\d.]+/, (m) => m[0]],
|
||
[/Mac OS X ([\d_]+)/, (m) => `macOS ${m[1]!.replace(/_/g, ".")}`],
|
||
[/CrOS|Linux/, (m) => m[0]],
|
||
];
|
||
for (const [re, name] of tests) {
|
||
const m = re.exec(ua);
|
||
if (m) return name(m);
|
||
}
|
||
return "?";
|
||
}
|
||
|
||
/** The environment table: what a developer asks first. Read from this browser at composing time —
|
||
* the same browser the session recorded in, or the report says which one it came from. */
|
||
export function environment(language: string): [key: string, value: string][] {
|
||
const ua = navigator.userAgent;
|
||
const browser =
|
||
/Edg\/([\d.]+)/.exec(ua)?.[0] ??
|
||
/Firefox\/([\d.]+)/.exec(ua)?.[0] ??
|
||
/Chrome\/([\d.]+)/.exec(ua)?.[0] ??
|
||
/Version\/([\d.]+).*Safari/.exec(ua)?.[0] ??
|
||
ua;
|
||
const rootPx = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
||
return [
|
||
["version", `${FRONTEND_VERSION} (${FRONTEND_SHA})`],
|
||
["browser", browser.replace("/", " ")],
|
||
["os", osName(ua)],
|
||
[
|
||
"window",
|
||
`${window.innerWidth} × ${window.innerHeight} · ${window.devicePixelRatio}x · ${Math.round((rootPx / 16) * 100)}%`,
|
||
],
|
||
["language", language],
|
||
];
|
||
}
|
||
|
||
/** A table cell: a pipe or a line break would break the markdown table. */
|
||
const cell = (value: string) => value.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
|
||
|
||
export interface IssueText {
|
||
steps: string;
|
||
environment: string;
|
||
envKeys: Record<string, string>;
|
||
screenshot: string;
|
||
attachments: string;
|
||
clientLog: (n: number) => string;
|
||
serverLog: (n: number) => string;
|
||
screenshots: (n: number) => string;
|
||
/** Shown when the evidence was cut at the server's cap. */
|
||
truncated?: string;
|
||
}
|
||
|
||
/** The issue body as markdown: the description, the steps, the environment, the attachments. */
|
||
export function renderIssue(
|
||
form: DraftForm,
|
||
env: readonly [string, string][],
|
||
counts: { client: number; server: number; screenshots: number },
|
||
text: IssueText,
|
||
): string {
|
||
const parts: string[] = [];
|
||
const description = form.description.trim();
|
||
if (description) parts.push(description);
|
||
const steps = form.steps.filter((s) => s.text.trim());
|
||
if (steps.length)
|
||
parts.push(
|
||
`### ${text.steps}\n\n` +
|
||
steps
|
||
.map((s, i) => `${i + 1}. ${s.text.trim()}${s.shot ? ` _(${text.screenshot})_` : ""}`)
|
||
.join("\n"),
|
||
);
|
||
parts.push(
|
||
`### ${text.environment}\n\n| | |\n|---|---|\n` +
|
||
env.map(([k, v]) => `| ${cell(text.envKeys[k] ?? k)} | ${cell(v)} |`).join("\n"),
|
||
);
|
||
const attachments = [
|
||
counts.client ? `- \`client.log\` — ${text.clientLog(counts.client)}` : "",
|
||
counts.server ? `- \`server.log\` — ${text.serverLog(counts.server)}` : "",
|
||
counts.screenshots ? `- ${text.screenshots(counts.screenshots)}` : "",
|
||
].filter(Boolean);
|
||
if (attachments.length) {
|
||
const cut = text.truncated ? `\n\n_${text.truncated}_` : "";
|
||
parts.push(`### ${text.attachments}\n\n${attachments.join("\n")}${cut}`);
|
||
}
|
||
return parts.join("\n\n");
|
||
}
|