// mime-todo/lib/file.ts import * as fs from "fs" import { simpleParser } from "mailparser" import { parseIssue, Issue } from "./issue" import { parseSprints, Sprint } from "./sprint" import { parseModules, parseBugzillaTracker, Module, BugzillaTracker } from "./tracker" import { serializeTodoFile } from "./serializer" import * as schema from "./file.schema.json" import Ajv from "ajv" const ajv = new Ajv({ allErrors: true }) const validateFile = ajv.compile(schema) export interface TodoFile { sprints: Sprint[] issues: Issue[] modules?: Module[] bugzilla?: BugzillaTracker } export async function parseMime(mimeText: string) { return await simpleParser(mimeText) } export function preprocessTODO(raw: string): string { const boundary = "ISSUE" const rawParts = raw .split(`--${boundary}`) .map(p => p.trim()) .filter(Boolean) interface Part { type: string body: string } const parts: Part[] = [] for (const rawPart of rawParts) { const lines = rawPart.split(/\r?\n/) const typeLine = lines[0] if (!typeLine?.toLowerCase().startsWith("content-type:")) { throw new Error(`Part missing Content-Type header:\n${rawPart}`) } const type = typeLine.slice("Content-Type:".length).trim() const body = lines.slice(1).join("\n").trim() parts.push({ type, body }) } // Validate singleton parts for (const ct of ["application/sprints", "application/modules", "application/bugzilla"]) { const count = parts.filter(p => p.type === ct).length if (count > 1) { throw new Error(`Multiple ${ct} parts found`) } } const sprintsPart = parts.find(p => p.type === "application/sprints") ?? null const modulesPart = parts.find(p => p.type === "application/modules") ?? null const bugzillaPart = parts.find(p => p.type === "application/bugzilla") ?? null const singletonParts = new Set([sprintsPart, modulesPart, bugzillaPart]) // Reorder: sprints, modules, bugzilla, then everything else const orderedParts: Part[] = [] if (sprintsPart) orderedParts.push(sprintsPart) if (modulesPart) orderedParts.push(modulesPart) if (bugzillaPart) orderedParts.push(bugzillaPart) for (const part of parts) { if (!singletonParts.has(part)) orderedParts.push(part) } // MIME envelope const out: string[] = [] out.push(`MIME-Version: 1.0`) out.push(`Content-Type: multipart/mixed; boundary="${boundary}"`) out.push("") for (const part of orderedParts) { out.push(`--${boundary}`) out.push(`Content-Type: ${part.type}`) out.push("") out.push(part.body) out.push("") } out.push(`--${boundary}--`) out.push("") return out.join("\n") } export async function parseTodoFile(path = "TODO"): Promise { const raw = fs.readFileSync(path, "utf-8") const mimeWrapped = preprocessTODO(raw) const parsed = await parseMime(mimeWrapped) const sprints: Sprint[] = [] const issues: Issue[] = [] let modules: Module[] | undefined let bugzilla: BugzillaTracker | undefined const parts = (parsed as any).attachments ?? [] for (const part of parts) { const contentType = String(part.contentType || "").toLowerCase() const body = part.content?.toString("utf-8") ?? "" if (contentType.startsWith("application/sprints")) { sprints.push(...parseSprints(body)) } else if (contentType.startsWith("application/modules")) { modules = parseModules(body) } else if (contentType.startsWith("application/bugzilla")) { bugzilla = parseBugzillaTracker(body) } else if (contentType.startsWith("application/issue")) { issues.push(parseIssue(body)) } } const file: TodoFile = { sprints, issues, modules, bugzilla }; if (!validateFile(file)) { throw new Error( "Schema validation failed: " + JSON.stringify(validateFile.errors, null, 2) ) } // Validate: issue modules must reference defined modules if (modules) { const validModules = new Set(modules.map(m => m.name)) for (const issue of issues) { if (issue.module && !validModules.has(issue.module)) { throw new Error( `Issue #${issue.id} references module "${issue.module}" ` + `which is not defined in application/modules. ` + `Valid modules: ${[...validModules].join(", ")}` ) } } } // Validate: bugzilla tracker mappings must reference defined modules if (bugzilla && modules) { const validModules = new Set(modules.map(m => m.name)) for (const mapping of bugzilla.mappings) { if (!validModules.has(mapping.module)) { throw new Error( `Bugzilla mapping references module "${mapping.module}" ` + `which is not defined in application/modules. ` + `Valid modules: ${[...validModules].join(", ")}` ) } } } // Validate: relationship targets must reference existing issue IDs const issueIds = new Set(issues.map(i => i.id)) const issueMap = new Map(issues.map(i => [i.id, i])) for (const issue of issues) { for (const [kind, targets] of Object.entries(issue.relationships)) { for (const targetId of targets) { if (!issueIds.has(targetId)) { throw new Error( `Issue #${issue.id} has ${kind} relationship to #${targetId}, ` + `but issue #${targetId} does not exist` ) } // Warn on stale relationships (target is cancelled) const target = issueMap.get(targetId) if (target?.status === "cancelled") { console.error( `Warning: Issue #${issue.id} has ${kind} relationship to #${targetId}, ` + `but issue #${targetId} is cancelled` ) } } } } return file; } export function writeTodoFile(todo: TodoFile, path = "TODO"): void { const content = serializeTodoFile(todo) fs.writeFileSync(path, content) }