155 lines
8.5 KiB
TypeScript
155 lines
8.5 KiB
TypeScript
// 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();
|
|
}
|