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();
|
||||
}
|
||||
Reference in New Issue
Block a user