This commit is contained in:
Tiara Rodney 2026-02-10 18:59:59 +01:00
commit 76266cedc6
No known key found for this signature in database
GPG key ID: 5CD8EC1D46106723
23 changed files with 4041 additions and 0 deletions

View file

@ -0,0 +1,99 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "TODO File Schema",
"type": "object",
"properties": {
"sprints": {
"type": "array",
"items": { "$ref": "#/definitions/Sprint" }
},
"issues": {
"type": "array",
"items": { "$ref": "#/definitions/Issue" }
}
},
"required": ["sprints", "issues"],
"additionalProperties": false,
"definitions": {
"Sprint": {
"type": "object",
"properties": {
"name": { "type": "string" },
"start": {
"type": "string",
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
},
"end": {
"type": "string",
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
}
},
"required": ["name", "start", "end"],
"additionalProperties": false
},
"Issue": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"minimum": 1
},
"type": {
"type": "string",
"enum": ["feature", "bugfix", "hotfix"]
},
"title": { "type": "string" },
"status": {
"type": "string",
"enum": ["open", "in-progress", "done", "hold", "cancelled"]
},
"priority": {
"type": "string",
"enum": ["low", "medium", "high"]
},
"created": {
"type": "string",
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
},
"relationships": {
"$ref": "#/definitions/IssueRelationships"
},
"dueStart": {
"type": "string",
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
},
"dueEnd": {
"type": "string",
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
},
"description": { "type": "string" },
"body": { "type": "string" }
},
"required": [
"id",
"type",
"title",
"status",
"priority",
"created",
"relationships",
"description",
"body"
],
"additionalProperties": false
},
"IssueRelationships": {
"type": "object",
"patternProperties": {
"^(dependsOn|relatesTo|blocks)$": {
"type": "array",
"items": { "type": "integer", "minimum": 1 }
}
},
"additionalProperties": false
}
}
}

View file

@ -0,0 +1,156 @@
// mime-todo/lib/file.ts
import * as fs from "fs"
import { fileURLToPath } from "url";
import { simpleParser } from "mailparser"
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import { parseIssue, Issue } from "./issue"
import { parseSprints, Sprint } from "./sprint"
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[]
}
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 sprint parts
const sprintParts = parts.filter(p => p.type === "application/sprints")
if (sprintParts.length > 1) {
throw new Error("Multiple application/sprints parts found")
}
const sprintsPart = sprintParts[0] ?? null
// Preserve unknown types, but reorder sprints first
const orderedParts: Part[] = []
if (sprintsPart) orderedParts.push(sprintsPart)
for (const part of parts) {
if (part !== sprintsPart) 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[] = []
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")) {
// Debug: Log what we're parsing
// console.log("Parsing sprints:", body);
sprints.push(...parseSprints(body))
} else if (contentType.startsWith("application/issue")) {
issues.push(parseIssue(body))
}
}
const file = {sprints: sprints, issues: issues };
if (!validateFile(file)) {
throw new Error(
"Sprint schema validation failed: " +
JSON.stringify(validateFile.errors, null, 2)
)
}
return file;
}
function index(yargs) {
console.log(yargs.argv);
}
function _index(yargs) {
return yargs.command('index', 'welcome ter yargs!', index)
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
let argv = yargs()
.usage('$0 <cmd> [args]')
.help()
let commands = [
_index
]
commands.forEach((method) => {
method(argv)
});
yargs.parse(hideBin(process.argv))
}

View file

@ -0,0 +1,97 @@
// mime-todo/lib/issue.ts
export type RelationshipKind = "dependsOn" | "relatesTo" | "blocks"
export interface IssueRelationships {
[kind: string]: number[]
}
export type IssueType = "feature" | "bugfix" | "hotfix"
export type IssueStatus = "open" | "in-progress" | "done" | "hold" | "cancelled"
export type IssuePriority = "low" | "medium" | "high"
export interface Issue {
id: number
type: IssueType
title: string
status: IssueStatus
priority: IssuePriority
created: string // YYYY-MM-DD
relationships: IssueRelationships
dueStart?: string // YYYY-MM-DD
dueEnd?: string // YYYY-MM-DD
description: string
body: string
}
export function parseRelationships(text: string): IssueRelationships {
const relationships: IssueRelationships = {}
// Handle empty relationships
if (!text || text.trim() === "") {
return relationships
}
// Simple parsing for format like "dependsOn:3"
const parts = text.split(/,\s*/).filter(p => p.trim() !== "")
for (const part of parts) {
const [key, value] = part.split(":").map(s => s.trim())
if (key && value) {
relationships[key] = value.split(/[\s]+/).map(Number).filter(n => !isNaN(n))
}
}
return relationships
}
export function parseIssue(text: string): Issue {
const lines = text.split(/\r?\n/)
const issue: Partial<Issue> = {}
let inDescription = false
const descLines: string[] = []
const bodyLines: string[] = []
for (const line of lines) {
if (!inDescription) {
if (line.startsWith("ID:")) issue.id = Number(line.slice(3).trim())
else if (line.startsWith("Type:")) issue.type = line.slice(5).trim()
else if (line.startsWith("Title:")) issue.title = line.slice(6).trim()
else if (line.startsWith("Status:")) issue.status = line.slice(7).trim()
else if (line.startsWith("Priority:")) issue.priority = line.slice(9).trim()
else if (line.startsWith("Created:")) issue.created = line.slice(8).trim()
else if (line.startsWith("DueStart:")) issue.dueStart = line.slice(9).trim()
else if (line.startsWith("DueEnd:")) issue.dueEnd = line.slice(7).trim()
else if (line.startsWith("Relationships:")) {
const rel = line.slice("Relationships:".length).trim()
issue.relationships = parseRelationships(rel)
}
else if (line.startsWith("Description:")) {
inDescription = true
descLines.push(line.slice("Description:".length).trim())
}
} else {
// Description continuation
if (line.trim() === "") {
// blank line ends description
inDescription = false
continue
}
if (/^\s+/.test(line)) {
descLines.push(line.trim())
continue
}
// first non-indented line ends description
inDescription = false
bodyLines.push(line)
}
}
issue.description = descLines.join("\n")
issue.body = bodyLines.join("\n")
return issue as Issue
}

View file

@ -0,0 +1,2 @@
//**/*

View file

@ -0,0 +1,42 @@
// mime-todo/lib/sprint.ts
export interface Sprint {
name: string
start: string // YYYY-MM-DD
end: string // YYYY-MM-DD
}
export function parseSprints(text: string): Sprint[] {
const lines = text.split(/\r?\n/)
const sprints: Sprint[] = []
let current: Partial<Sprint> | null = null
for (const line of lines) {
// Start of sprint entry (must not trim indentation)
if (/^\s*-\s*(Name:.*)?$/.test(line)) {
if (current) sprints.push(current as Sprint)
current = {}
const match = line.match(/^\s*-\s*Name:\s*(.*)$/)
if (match) current.name = match[1]
continue
}
// Key-value pairs (must be indented)
const kv = line.match(/^\s+([A-Za-z][A-Za-z0-9]*):\s*(.*)$/)
if (kv && current) {
const key = kv[1]
const value = kv[2]
if (key === "Name") current.name = value
if (key === "Range") {
const [start, end] = value.split("..")
current.start = start
current.end = end
}
}
}
if (current) sprints.push(current as Sprint)
return sprints
}