init
This commit is contained in:
commit
932d4ad420
46 changed files with 5800 additions and 0 deletions
199
lib/file.ts
Normal file
199
lib/file.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
// 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<TodoFile> {
|
||||
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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue