Files
Panama/user/agents/skills/ticket/scripts/jira-import.ts
T

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(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'")
.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
}