// 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; names: Record; } /** 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 { 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(/]*class="icon"[^>]*>/gi, "") .replace(/https?:\/\/[^"'\s]+\/(?:rest\/api\/\d\/attachment\/content|secure\/attachment)\/(\d+)(?:\/[^"'\s]*)?/g, (url, id: string) => attachments.get(id) ?? url) .replace(/]*href="https?:)/gi, ' (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 & { 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 = { ".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(/]*>/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(); }