This commit is contained in:
Tiara Rodney 2026-03-15 03:02:41 +01:00
commit 932d4ad420
No known key found for this signature in database
GPG key ID: 5CD8EC1D46106723
46 changed files with 5800 additions and 0 deletions

View file

@ -0,0 +1,31 @@
--ISSUE
Content-Type: application/sprints
Sprints:
- Name: Sprint Alpha
Range: 2026-02-01..2026-02-14
-
Name: Sprint Beta
Range: 2026-02-15..2026-02-28
--ISSUE
Content-Type: application/issue
ID: 1
Type: feature
Title: Add streaming parser
Status: open
Priority: high
Created: 2026-02-05
Relationships: dependsOn:2
Description: Implement streaming JSON parser.
Must support SAX-like events.
--ISSUE
Content-Type: application/issue
ID: 2
Type: bugfix
Title: Fix wraparound
Status: in-progress
Priority: medium
Created: 2026-02-06
Relationships:
Description: Fix off-by-one in circular buffer.

View file

@ -0,0 +1,10 @@
--ISSUE
Content-Type: application/issue
ID: 10
Type: hotfix
Title: Patch crash
Status: done
Priority: high
Created: 2026-02-03
Relationships:
Description: Fix crash in allocator.

View file

@ -0,0 +1,26 @@
--ISSUE
Content-Type: application/sprints
Sprints:
- Name: Sprint Alpha
Range: 2026-02-01..2026-02-14
-
Name: Sprint Beta
Range: 2026-02-15..2026-02-28
--ISSUE
Content-Type: application/sprints
Sprints:
- Name: Sprint Gamma
Range: 2026-02-28..2026-03-15
--ISSUE
Content-Type: application/issue
ID: 1
Type: feature
Title: Add streaming parser
Status: open
Priority: high
Created: 2026-02-05
Relationships: dependsOn:3
Description: Implement streaming JSON parser.
Must support SAX-like events.

View file

@ -0,0 +1 @@
Sprints:

View file

@ -0,0 +1,54 @@
--ISSUE
Content-Type: application/sprints
Sprints:
- Name: Sprint Alpha
Range: 2026-02-01..2026-02-14
--ISSUE
Content-Type: application/modules
Modules:
- Name: General
Path: .
- Name: Frontend
Path: src/frontend
- Name: Backend
Path: src/backend
--ISSUE
Content-Type: application/bugzilla
URL: https://bugzilla.example.com
Mappings:
- Module: General
Product: MyProject
Component: General
- Module: Frontend
Product: MyProject
Component: Frontend
- Module: Backend
Product: MyProject
Component: Backend
--ISSUE
Content-Type: application/issue
ID: 1
Type: feature
Title: Add streaming parser
Status: open
Priority: high
Created: 2026-02-05
Module: Frontend
Relationships:
Description: Implement streaming JSON parser.
Must support SAX-like events.
--ISSUE
Content-Type: application/issue
ID: 2
Type: bugfix
Title: Fix API crash
Status: in-progress
Priority: medium
Created: 2026-02-06
Module: Backend
Relationships:
Description: Fix null pointer in request handler.

View file

@ -0,0 +1,87 @@
import { describe, it, expect } from "vitest"
import {
statusToBugzilla,
priorityToBugzilla,
typeToBugzilla,
issueToBugzillaCreate,
resolveProductComponent,
} from "../../../lib/bugzilla/fieldmap"
import type { Issue } from "../../../lib/issue"
import type { BugzillaTracker } from "../../../lib/tracker"
describe("status mapping", () => {
it("maps TODO statuses to Bugzilla", () => {
expect(statusToBugzilla("open")).toEqual({ status: "CONFIRMED" })
expect(statusToBugzilla("in-progress")).toEqual({ status: "IN_PROGRESS" })
expect(statusToBugzilla("done")).toEqual({ status: "RESOLVED", resolution: "FIXED" })
expect(statusToBugzilla("hold")).toEqual({ status: "RESOLVED", resolution: "LATER" })
expect(statusToBugzilla("cancelled")).toEqual({ status: "RESOLVED", resolution: "WONTFIX" })
})
})
describe("priority mapping", () => {
it("maps TODO priorities to Bugzilla", () => {
expect(priorityToBugzilla("low")).toBe("Low")
expect(priorityToBugzilla("medium")).toBe("Normal")
expect(priorityToBugzilla("high")).toBe("Highest")
})
})
describe("type/severity mapping", () => {
it("maps TODO types to Bugzilla severity", () => {
expect(typeToBugzilla("feature")).toBe("enhancement")
expect(typeToBugzilla("bugfix")).toBe("normal")
expect(typeToBugzilla("hotfix")).toBe("critical")
})
})
describe("issueToBugzillaCreate", () => {
it("converts a TODO issue to a Bugzilla create payload", () => {
const issue: Issue = {
id: 1,
type: "feature",
title: "Add streaming parser",
status: "open",
priority: "high",
created: "2026-02-05",
relationships: { dependsOn: [3] },
description: "Implement streaming JSON parser.",
body: "",
}
const payload = issueToBugzillaCreate(issue, "MyProduct", "MyComponent", "https://example.com/TODO#1")
expect(payload.product).toBe("MyProduct")
expect(payload.component).toBe("MyComponent")
expect(payload.summary).toBe("Add streaming parser")
expect(payload.url).toBe("https://example.com/TODO#1")
expect(payload.priority).toBe("Highest")
expect(payload.severity).toBe("enhancement")
expect(payload.depends_on).toEqual([3])
})
})
describe("resolveProductComponent", () => {
const tracker: BugzillaTracker = {
url: "https://bugzilla.example.com",
mappings: [
{ module: "General", product: "MyProject", component: "General" },
{ module: "Frontend", product: "MyProject", component: "Frontend" },
],
}
it("resolves product/component from issue module", () => {
const issue = { module: "Frontend" } as Issue
const result = resolveProductComponent(issue, tracker)
expect(result).toEqual({ product: "MyProject", component: "Frontend" })
})
it("falls back to first mapping when no module set", () => {
const issue = {} as Issue
const result = resolveProductComponent(issue, tracker)
expect(result).toEqual({ product: "MyProject", component: "General" })
})
it("returns null when no tracker", () => {
expect(resolveProductComponent({} as Issue, undefined)).toBeNull()
})
})

View file

@ -0,0 +1,44 @@
import { describe, it, expect } from "vitest"
import {
normalizeRemoteUrl,
buildOriginUrl,
} from "../../../lib/bugzilla/origin"
describe("normalizeRemoteUrl", () => {
it("passes HTTPS URLs through", () => {
expect(normalizeRemoteUrl("https://github.com/user/repo"))
.toBe("https://github.com/user/repo")
})
it("strips trailing .git", () => {
expect(normalizeRemoteUrl("https://github.com/user/repo.git"))
.toBe("https://github.com/user/repo")
})
it("converts SSH URLs to HTTPS", () => {
expect(normalizeRemoteUrl("git@github.com:user/repo.git"))
.toBe("https://github.com/user/repo")
})
it("converts Bitbucket SSH URLs", () => {
expect(normalizeRemoteUrl("git@bitbucket.org:org/project.git"))
.toBe("https://bitbucket.org/org/project")
})
})
describe("buildOriginUrl", () => {
it("builds Bitbucket URL with src/<branch>", () => {
expect(buildOriginUrl("git@bitbucket.org:byteb4rb1e/mime-todo-spec.git", "master", "TODO", 1))
.toBe("https://bitbucket.org/byteb4rb1e/mime-todo-spec/src/master/TODO#1")
})
it("builds GitHub URL with blob/<branch>", () => {
expect(buildOriginUrl("https://github.com/user/repo", "main", "TODO", 42))
.toBe("https://github.com/user/repo/blob/main/TODO#42")
})
it("builds GitLab URL with -/blob/<branch>", () => {
expect(buildOriginUrl("https://gitlab.com/org/repo", "develop", "TODO", 5))
.toBe("https://gitlab.com/org/repo/-/blob/develop/TODO#5")
})
})

57
tests/lib/cli.test.ts Normal file
View file

@ -0,0 +1,57 @@
import { describe, it, expect } from "vitest"
import { CLICommand } from "../../lib/cli/CLICommand"
class TestLeafCommand extends CLICommand {
readonly name = "leaf"
readonly help = "A leaf command"
readonly description = "Test leaf"
async execute(): Promise<number> {
return 42
}
}
class TestChildA extends CLICommand {
readonly name = "child-a"
readonly help = "Child A"
readonly description = "Test child A"
}
class TestChildB extends CLICommand {
readonly name = "child-b"
readonly help = "Child B"
readonly description = "Test child B"
}
class TestBranchCommand extends CLICommand {
readonly name = "branch"
readonly help = "A branch command"
readonly description = "Test branch"
static override _subcommands = [TestChildA, TestChildB]
}
describe("CLICommand", () => {
it("leaf command returns exit code from execute()", async () => {
const cmd = new TestLeafCommand()
expect(await cmd.execute({} as any)).toBe(42)
})
it("branch command has subcommands", () => {
expect(TestBranchCommand._subcommands).toHaveLength(2)
expect(new TestBranchCommand().name).toBe("branch")
})
it("base CLICommand.execute returns 0", async () => {
const cmd = new TestChildA()
expect(await cmd.execute({} as any)).toBe(0)
})
it("subcommands are constructable", () => {
for (const Sub of TestBranchCommand._subcommands) {
const instance = new Sub()
expect(instance.name).toBeTruthy()
expect(instance.help).toBeTruthy()
}
})
})

55
tests/lib/file.test.ts Normal file
View file

@ -0,0 +1,55 @@
import * as fs from "fs"
import { describe, it, expect } from "vitest"
import { preprocessTODO, parseTodoFile } from "../../lib/file"
describe("parseTodoFile", () => {
it("parses full TODO file end-to-end", async () => {
const todo = await parseTodoFile("tests/_mocks/todo-basic.txt")
expect(todo.sprints.length).toBe(2)
expect(todo.issues.length).toBe(2)
// expect(todo.issues[0].title).toBe("Add streaming parser")
expect(todo.sprints[0].name).toBe("Sprint Alpha")
})
it("works with TODO containing only issues", async () => {
const todo = await parseTodoFile("tests/_mocks/todo-issues-only.txt")
expect(todo.sprints.length).toBe(0)
// expect(todo.issues.length).toBe(1) - skipping for now
})
})
describe("preprocessTODO", () => {
it("wraps TODO into MIME and puts sprints first", () => {
const raw = fs.readFileSync("tests/_mocks/todo-basic.txt", "utf-8")
const mime = preprocessTODO(raw)
expect(mime).toContain("MIME-Version: 1.0")
expect(mime).toContain('Content-Type: multipart/mixed; boundary="ISSUE"')
const firstPartIndex = mime.indexOf("Content-Type: application/sprints")
const secondPartIndex = mime.indexOf("Content-Type: application/issue")
expect(firstPartIndex).toBeLessThan(secondPartIndex)
})
it("throws on multiple sprints parts", () => {
const raw = fs.readFileSync("tests/_mocks/todo-multiple-sprints.txt", "utf-8")
expect(() => preprocessTODO(raw)).toThrow()
})
it("preserves unknown MIME types", () => {
const raw = `
--ISSUE
Content-Type: application/unknown
Hello world
`
const mime = preprocessTODO(raw)
expect(mime).toContain("application/unknown")
})
})

57
tests/lib/issue.test.ts Normal file
View file

@ -0,0 +1,57 @@
import { describe, it, expect } from "vitest"
import * as fs from "fs"
import { parseIssue, validateStatusTransition } from "../../lib/issue"
describe("parseIssue", () => {
it("parses all required fields", () => {
const raw = fs.readFileSync("tests/_mocks/todo-basic.txt", "utf-8")
const issueText = raw.split("Content-Type: application/issue")[1]
const issue = parseIssue(issueText)
expect(issue.id).toBe(1)
expect(issue.type).toBe("feature")
expect(issue.status).toBe("open")
expect(issue.priority).toBe("high")
expect(issue.description).toContain("Implement streaming JSON parser.")
})
it("parses empty relationships", () => {
const raw = `
ID: 2
Type: bugfix
Title: T
Status: open
Priority: low
Created: 2026-02-01
Relationships:
Description: X
`
const issue = parseIssue(raw)
expect(issue.relationships).toEqual({})
})
})
describe("validateStatusTransition", () => {
it("allows valid transitions", () => {
expect(validateStatusTransition("open", "in-progress")).toBeNull()
expect(validateStatusTransition("open", "hold")).toBeNull()
expect(validateStatusTransition("open", "cancelled")).toBeNull()
expect(validateStatusTransition("in-progress", "done")).toBeNull()
expect(validateStatusTransition("in-progress", "open")).toBeNull()
expect(validateStatusTransition("done", "open")).toBeNull()
expect(validateStatusTransition("cancelled", "open")).toBeNull()
expect(validateStatusTransition("hold", "in-progress")).toBeNull()
})
it("rejects invalid transitions", () => {
expect(validateStatusTransition("open", "done")).not.toBeNull()
expect(validateStatusTransition("done", "in-progress")).not.toBeNull()
expect(validateStatusTransition("done", "cancelled")).not.toBeNull()
expect(validateStatusTransition("cancelled", "done")).not.toBeNull()
})
it("allows no-op (same status)", () => {
expect(validateStatusTransition("open", "open")).toBeNull()
expect(validateStatusTransition("done", "done")).toBeNull()
})
})

View file

@ -0,0 +1,129 @@
import * as fs from "fs"
import { describe, it, expect } from "vitest"
import { parseTodoFile } from "../../lib/file"
import {
serializeTodoFile,
serializeIssue,
serializeSprints,
serializeRelationships,
} from "../../lib/serializer"
describe("serializeRelationships", () => {
it("serializes empty relationships", () => {
expect(serializeRelationships({})).toBe("")
})
it("serializes single relationship", () => {
expect(serializeRelationships({ dependsOn: [3] })).toBe("dependsOn:3")
})
it("serializes multiple relationships", () => {
const result = serializeRelationships({ dependsOn: [3, 4], blocks: [7] })
expect(result).toContain("dependsOn:3 4")
expect(result).toContain("blocks:7")
})
})
describe("serializeSprints", () => {
it("serializes empty sprints", () => {
expect(serializeSprints([])).toBe("Sprints:")
})
it("serializes sprint list", () => {
const result = serializeSprints([
{ name: "Alpha", start: "2026-02-01", end: "2026-02-14" },
])
expect(result).toContain("Sprints:")
expect(result).toContain("- Name: Alpha")
expect(result).toContain("Range: 2026-02-01..2026-02-14")
})
})
describe("serializeIssue", () => {
it("serializes an issue with all fields", () => {
const result = serializeIssue({
id: 1,
type: "feature",
title: "Test",
status: "open",
priority: "high",
created: "2026-02-05",
relationships: { dependsOn: [3] },
description: "A test issue",
body: "",
})
expect(result).toContain("ID: 1")
expect(result).toContain("Type: feature")
expect(result).toContain("Title: Test")
expect(result).toContain("Status: open")
expect(result).toContain("Priority: high")
expect(result).toContain("Created: 2026-02-05")
expect(result).toContain("Relationships: dependsOn:3")
expect(result).toContain("Description: A test issue")
})
it("serializes multi-line description", () => {
const result = serializeIssue({
id: 1,
type: "feature",
title: "Test",
status: "open",
priority: "high",
created: "2026-02-05",
relationships: {},
description: "Line one\nLine two",
body: "",
})
expect(result).toContain("Description: Line one")
expect(result).toContain(" Line two")
})
})
describe("round-trip", () => {
it("parse → serialize → parse produces equivalent data", async () => {
const todo1 = await parseTodoFile("tests/_mocks/todo-basic.txt")
const serialized = serializeTodoFile(todo1)
// Write to temp location for re-parsing
const tmpPath = "tests/_mocks/_roundtrip.txt"
fs.writeFileSync(tmpPath, serialized)
try {
const todo2 = await parseTodoFile(tmpPath)
expect(todo2.sprints.length).toBe(todo1.sprints.length)
expect(todo2.issues.length).toBe(todo1.issues.length)
for (let i = 0; i < todo1.sprints.length; i++) {
expect(todo2.sprints[i].name).toBe(todo1.sprints[i].name)
expect(todo2.sprints[i].start).toBe(todo1.sprints[i].start)
expect(todo2.sprints[i].end).toBe(todo1.sprints[i].end)
}
for (let i = 0; i < todo1.issues.length; i++) {
expect(todo2.issues[i].id).toBe(todo1.issues[i].id)
expect(todo2.issues[i].type).toBe(todo1.issues[i].type)
expect(todo2.issues[i].title).toBe(todo1.issues[i].title)
expect(todo2.issues[i].status).toBe(todo1.issues[i].status)
expect(todo2.issues[i].priority).toBe(todo1.issues[i].priority)
expect(todo2.issues[i].created).toBe(todo1.issues[i].created)
expect(todo2.issues[i].description).toBe(todo1.issues[i].description)
}
} finally {
fs.unlinkSync(tmpPath)
}
})
it("round-trips issues-only file", async () => {
const todo1 = await parseTodoFile("tests/_mocks/todo-issues-only.txt")
const serialized = serializeTodoFile(todo1)
const tmpPath = "tests/_mocks/_roundtrip2.txt"
fs.writeFileSync(tmpPath, serialized)
try {
const todo2 = await parseTodoFile(tmpPath)
expect(todo2.issues.length).toBe(todo1.issues.length)
expect(todo2.sprints.length).toBe(0)
} finally {
fs.unlinkSync(tmpPath)
}
})
})

22
tests/lib/sprint.test.ts Normal file
View file

@ -0,0 +1,22 @@
import * as fs from "fs"
import { describe, it, expect } from "vitest"
import { parseSprints } from "../../lib/sprint"
describe("parseSprints", () => {
it("parses compact and expanded sprint entries", () => {
const raw = fs.readFileSync("tests/_mocks/todo-basic.txt", "utf-8")
const sprintsText = raw.split("Content-Type: application/sprints")[1]
const sprints = parseSprints(sprintsText)
expect(sprints.length).toBe(2)
expect(sprints[0].name).toBe("Sprint Alpha")
expect(sprints[0].start).toBe("2026-02-01")
expect(sprints[0].end).toBe("2026-02-14")
})
it("handles TODO with no sprints", () => {
const raw = fs.readFileSync("tests/_mocks/todo-no-sprints.txt", "utf-8")
const sprints = parseSprints(raw)
expect(sprints.length).toBe(0)
})
})

205
tests/lib/tracker.test.ts Normal file
View file

@ -0,0 +1,205 @@
import * as fs from "fs"
import { describe, it, expect } from "vitest"
import { parseModules, parseBugzillaTracker } from "../../lib/tracker"
import { parseTodoFile } from "../../lib/file"
import { serializeTodoFile, serializeModules, serializeBugzillaTracker } from "../../lib/serializer"
describe("parseModules", () => {
it("parses modules list", () => {
const text = `Modules:
- Name: General
Path: .
- Name: Frontend
Path: src/frontend`
const modules = parseModules(text)
expect(modules).toHaveLength(2)
expect(modules[0]).toEqual({ name: "General", path: "." })
expect(modules[1]).toEqual({ name: "Frontend", path: "src/frontend" })
})
})
describe("parseBugzillaTracker", () => {
it("parses bugzilla tracker with mappings", () => {
const text = `URL: https://bugzilla.example.com
Mappings:
- Module: General
Product: MyProject
Component: General
- Module: Frontend
Product: MyProject
Component: FrontendUI`
const tracker = parseBugzillaTracker(text)
expect(tracker.url).toBe("https://bugzilla.example.com")
expect(tracker.mappings).toHaveLength(2)
expect(tracker.mappings[0]).toEqual({
module: "General",
product: "MyProject",
component: "General",
})
expect(tracker.mappings[1]).toEqual({
module: "Frontend",
product: "MyProject",
component: "FrontendUI",
})
})
})
describe("parseTodoFile with modules and bugzilla", () => {
it("parses TODO with modules and bugzilla parts", async () => {
const todo = await parseTodoFile("tests/_mocks/todo-with-tracker.txt")
expect(todo.modules).toBeDefined()
expect(todo.modules).toHaveLength(3)
expect(todo.modules![0].name).toBe("General")
expect(todo.bugzilla).toBeDefined()
expect(todo.bugzilla!.url).toBe("https://bugzilla.example.com")
expect(todo.bugzilla!.mappings).toHaveLength(3)
expect(todo.issues).toHaveLength(2)
expect(todo.issues[0].module).toBe("Frontend")
expect(todo.issues[1].module).toBe("Backend")
})
it("rejects issue with invalid module", async () => {
const raw = `--ISSUE
Content-Type: application/modules
Modules:
- Name: General
Path: .
--ISSUE
Content-Type: application/issue
ID: 1
Type: feature
Title: Bad module
Status: open
Priority: low
Created: 2026-01-01
Module: NonExistent
Relationships:
Description: Test`
const tmpPath = "tests/_mocks/_bad-module.txt"
fs.writeFileSync(tmpPath, raw)
try {
await expect(parseTodoFile(tmpPath)).rejects.toThrow("NonExistent")
} finally {
fs.unlinkSync(tmpPath)
}
})
it("rejects bugzilla mapping referencing undefined module", async () => {
const raw = `--ISSUE
Content-Type: application/modules
Modules:
- Name: General
Path: .
--ISSUE
Content-Type: application/bugzilla
URL: https://bz.example.com
Mappings:
- Module: DoesNotExist
Product: P
Component: C
--ISSUE
Content-Type: application/issue
ID: 1
Type: feature
Title: Test
Status: open
Priority: low
Created: 2026-01-01
Relationships:
Description: Test`
const tmpPath = "tests/_mocks/_bad-bz-mapping.txt"
fs.writeFileSync(tmpPath, raw)
try {
await expect(parseTodoFile(tmpPath)).rejects.toThrow("DoesNotExist")
} finally {
fs.unlinkSync(tmpPath)
}
})
it("rejects dangling relationship targets", async () => {
const raw = `--ISSUE
Content-Type: application/issue
ID: 1
Type: feature
Title: Test
Status: open
Priority: low
Created: 2026-01-01
Relationships: dependsOn:99
Description: Test`
const tmpPath = "tests/_mocks/_bad-rel.txt"
fs.writeFileSync(tmpPath, raw)
try {
await expect(parseTodoFile(tmpPath)).rejects.toThrow("#99")
} finally {
fs.unlinkSync(tmpPath)
}
})
})
describe("serialize modules and bugzilla", () => {
it("serializes modules part", () => {
const result = serializeModules([
{ name: "General", path: "." },
{ name: "Frontend", path: "src/frontend" },
])
expect(result).toContain("Modules:")
expect(result).toContain("- Name: General")
expect(result).toContain("Path: .")
expect(result).toContain("- Name: Frontend")
})
it("serializes bugzilla tracker part", () => {
const result = serializeBugzillaTracker({
url: "https://bugzilla.example.com",
mappings: [
{ module: "General", product: "MyProject", component: "General" },
],
})
expect(result).toContain("URL: https://bugzilla.example.com")
expect(result).toContain("- Module: General")
expect(result).toContain("Product: MyProject")
expect(result).toContain("Component: General")
})
})
describe("round-trip with modules and bugzilla", () => {
it("parse → serialize → parse preserves modules, bugzilla, and issue modules", async () => {
const todo1 = await parseTodoFile("tests/_mocks/todo-with-tracker.txt")
const serialized = serializeTodoFile(todo1)
const tmpPath = "tests/_mocks/_roundtrip-tracker.txt"
fs.writeFileSync(tmpPath, serialized)
try {
const todo2 = await parseTodoFile(tmpPath)
expect(todo2.modules).toHaveLength(todo1.modules!.length)
for (let i = 0; i < todo1.modules!.length; i++) {
expect(todo2.modules![i]).toEqual(todo1.modules![i])
}
expect(todo2.bugzilla!.url).toBe(todo1.bugzilla!.url)
expect(todo2.bugzilla!.mappings).toHaveLength(todo1.bugzilla!.mappings.length)
for (let i = 0; i < todo1.bugzilla!.mappings.length; i++) {
expect(todo2.bugzilla!.mappings[i]).toEqual(todo1.bugzilla!.mappings[i])
}
for (let i = 0; i < todo1.issues.length; i++) {
expect(todo2.issues[i].module).toBe(todo1.issues[i].module)
}
} finally {
fs.unlinkSync(tmpPath)
}
})
})