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

148
lib/bugzilla/client.ts Normal file
View file

@ -0,0 +1,148 @@
// Bugzilla 5.0+ REST API client — thin wrapper around fetch
import type { BugzillaConfig } from "./config"
import type {
BugzillaBug,
BugzillaComment,
BugzillaProduct,
BugzillaComponent,
BugzillaSearchParams,
BugzillaCreatePayload,
BugzillaUpdatePayload,
BugzillaCreateProductPayload,
BugzillaCreateComponentPayload,
BugzillaSearchResponse,
BugzillaCreateResponse,
BugzillaUpdateResponse,
BugzillaCommentsResponse,
BugzillaProductResponse,
} from "./types"
export class BugzillaClient {
private baseUrl: string
private apiKey: string
constructor(config: BugzillaConfig) {
this.baseUrl = config.baseUrl.replace(/\/+$/, "")
this.apiKey = config.apiKey
}
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
// Auth: api_key in query for GET, in body for POST/PUT
let url: string
if (method === "GET") {
const sep = path.includes("?") ? "&" : "?"
url = `${this.baseUrl}${path}${sep}api_key=${encodeURIComponent(this.apiKey)}`
} else {
url = `${this.baseUrl}${path}`
const bodyObj = (body ?? {}) as Record<string, unknown>
body = { ...bodyObj, api_key: this.apiKey }
}
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
})
if (!res.ok) {
const text = await res.text()
throw new Error(`Bugzilla API ${method} ${path} failed (${res.status}): ${text}`)
}
return res.json() as Promise<T>
}
async getBug(id: number): Promise<BugzillaBug> {
const data = await this.request<BugzillaSearchResponse>("GET", `/bug/${id}`)
if (!data.bugs?.length) throw new Error(`Bug ${id} not found`)
return data.bugs[0]
}
async searchBugs(params: BugzillaSearchParams): Promise<BugzillaBug[]> {
const query = new URLSearchParams()
if (params.product) query.set("product", params.product)
if (params.component) query.set("component", params.component)
if (params.summary) query.set("summary", params.summary)
if (params.limit) query.set("limit", String(params.limit))
if (params.offset) query.set("offset", String(params.offset))
if (params.last_change_time) query.set("last_change_time", params.last_change_time)
if (params.status) {
const statuses = Array.isArray(params.status) ? params.status : [params.status]
for (const s of statuses) query.append("status", s)
}
if (params.id) {
const ids = Array.isArray(params.id) ? params.id : [params.id]
for (const id of ids) query.append("id", String(id))
}
if (params.alias) {
const aliases = Array.isArray(params.alias) ? params.alias : [params.alias]
for (const a of aliases) query.append("alias", a)
}
if (params.url) query.set("url", params.url)
const qs = query.toString()
const path = `/bug${qs ? `?${qs}` : ""}`
const data = await this.request<BugzillaSearchResponse>("GET", path)
return data.bugs ?? []
}
async createBug(payload: BugzillaCreatePayload): Promise<number> {
const data = await this.request<BugzillaCreateResponse>("POST", "/bug", payload)
return data.id
}
async updateBug(id: number, payload: BugzillaUpdatePayload): Promise<BugzillaUpdateResponse> {
return this.request<BugzillaUpdateResponse>("PUT", `/bug/${id}`, payload)
}
async getComments(bugId: number): Promise<BugzillaComment[]> {
const data = await this.request<BugzillaCommentsResponse>("GET", `/bug/${bugId}/comment`)
return data.bugs?.[String(bugId)]?.comments ?? []
}
async addComment(bugId: number, comment: string): Promise<number> {
const data = await this.request<{ id: number }>("POST", `/bug/${bugId}/comment`, { comment })
return data.id
}
async tagComment(commentId: number, tags: string[]): Promise<void> {
await this.request("PUT", `/bug/comment/${commentId}/tags`, { add: tags })
}
// Product operations
async getProduct(nameOrId: string | number): Promise<BugzillaProduct> {
const data = await this.request<BugzillaProductResponse>(
"GET",
`/product/${encodeURIComponent(String(nameOrId))}`
)
if (!data.products?.length) throw new Error(`Product "${nameOrId}" not found`)
return data.products[0]
}
async getAccessibleProducts(): Promise<BugzillaProduct[]> {
const data = await this.request<BugzillaProductResponse>("GET", "/product?type=accessible")
return data.products ?? []
}
async createProduct(payload: BugzillaCreateProductPayload): Promise<number> {
const data = await this.request<{ id: number }>("POST", "/product", payload)
return data.id
}
// Component operations
async getComponents(product: string): Promise<BugzillaComponent[]> {
const prod = await this.getProduct(product)
return prod.components ?? []
}
async createComponent(payload: BugzillaCreateComponentPayload): Promise<number> {
const data = await this.request<{ id: number }>("POST", "/component", payload)
return data.id
}
}

43
lib/bugzilla/config.ts Normal file
View file

@ -0,0 +1,43 @@
// Bugzilla configuration — loads from .bugzilla.json or environment variables
import * as fs from "fs"
import * as path from "path"
export interface BugzillaConfig {
baseUrl: string
apiKey: string
product: string
component: string
}
const CONFIG_FILE = ".bugzilla.json"
export function loadConfig(cwd = process.cwd()): BugzillaConfig {
const filePath = path.join(cwd, CONFIG_FILE)
if (fs.existsSync(filePath)) {
const raw = fs.readFileSync(filePath, "utf-8")
const json = JSON.parse(raw)
return {
baseUrl: json.baseUrl ?? json.base_url ?? "",
apiKey: json.apiKey ?? json.api_key,
product: json.product ?? "",
component: json.component ?? "",
}
}
const baseUrl = process.env.BUGZILLA_URL
const apiKey = process.env.BUGZILLA_API_KEY
if (!baseUrl || !apiKey) {
throw new Error(
`Bugzilla config not found. Create ${CONFIG_FILE} or set BUGZILLA_URL and BUGZILLA_API_KEY`
)
}
return {
baseUrl: baseUrl.replace(/\/+$/, ""),
apiKey,
product: process.env.BUGZILLA_PRODUCT ?? "",
component: process.env.BUGZILLA_COMPONENT ?? "",
}
}

92
lib/bugzilla/fieldmap.ts Normal file
View file

@ -0,0 +1,92 @@
// Field mapping between MIME TODO issues and Bugzilla bugs
import type { Issue, IssueType, IssueStatus, IssuePriority } from "../issue"
import type { BugzillaCreatePayload, BugzillaUpdatePayload } from "./types"
// Status mapping: TODO → Bugzilla
const STATUS_TO_BZ: Record<IssueStatus, { status: string; resolution?: string }> = {
"open": { status: "CONFIRMED" },
"in-progress": { status: "IN_PROGRESS" },
"done": { status: "RESOLVED", resolution: "FIXED" },
"hold": { status: "RESOLVED", resolution: "LATER" },
"cancelled": { status: "RESOLVED", resolution: "WONTFIX" },
}
// Priority mapping
const PRIORITY_TO_BZ: Record<IssuePriority, string> = {
"low": "Low",
"medium": "Normal",
"high": "Highest",
}
// Type → Severity mapping
const TYPE_TO_SEVERITY: Record<IssueType, string> = {
"feature": "enhancement",
"bugfix": "normal",
"hotfix": "critical",
}
export function statusToBugzilla(status: IssueStatus): { status: string; resolution?: string } {
return STATUS_TO_BZ[status] ?? { status: "NEW" }
}
export function priorityToBugzilla(priority: IssuePriority): string {
return PRIORITY_TO_BZ[priority] ?? "Normal"
}
export function typeToBugzilla(type: IssueType): string {
return TYPE_TO_SEVERITY[type] ?? "normal"
}
export function resolveProductComponent(
issue: Issue,
bugzilla?: import("../tracker").BugzillaTracker
): { product: string; component: string } | null {
if (!bugzilla) return null
if (issue.module) {
const mapping = bugzilla.mappings.find(m => m.module === issue.module)
if (mapping) return { product: mapping.product, component: mapping.component }
}
// Fall back to first mapping
return bugzilla.mappings.length > 0
? { product: bugzilla.mappings[0].product, component: bugzilla.mappings[0].component }
: null
}
export function issueToBugzillaCreate(
issue: Issue,
product: string,
component: string,
url?: string
): BugzillaCreatePayload {
const bz = statusToBugzilla(issue.status)
return {
product,
component,
version: "unspecified",
summary: issue.title,
url,
description: issue.description || undefined,
priority: priorityToBugzilla(issue.priority),
severity: typeToBugzilla(issue.type),
op_sys: "All",
rep_platform: "All",
depends_on: issue.relationships.dependsOn ?? [],
blocks: issue.relationships.blocks ?? [],
see_also: (issue.relationships.relatesTo ?? []).map(String),
}
}
export function issueToBugzillaUpdate(issue: Issue): BugzillaUpdatePayload {
const bz = statusToBugzilla(issue.status)
const payload: BugzillaUpdatePayload = {
summary: issue.title,
status: bz.status,
priority: priorityToBugzilla(issue.priority),
severity: typeToBugzilla(issue.type),
depends_on: { set: issue.relationships.dependsOn ?? [] },
blocks: { set: issue.relationships.blocks ?? [] },
}
if (bz.resolution) payload.resolution = bz.resolution
return payload
}

7
lib/bugzilla/index.ts Normal file
View file

@ -0,0 +1,7 @@
// Re-export all Bugzilla modules for convenient imports
export { BugzillaClient } from "./client"
export { loadConfig } from "./config"
export type { BugzillaConfig } from "./config"
export * from "./types"
export * from "./fieldmap"
export * from "./origin"

80
lib/bugzilla/origin.ts Normal file
View file

@ -0,0 +1,80 @@
// Origin URL — builds a resolvable URL pointing to a TODO issue in the repo's web UI
// Format varies by host:
// Bitbucket: <base>/src/<branch>/<path>#<id>
// GitHub: <base>/blob/<branch>/<path>#<id>
// GitLab: <base>/-/blob/<branch>/<path>#<id>
import { execSync } from "child_process"
export function getGitRemoteUrl(cwd = process.cwd()): string {
try {
return execSync("git remote get-url origin", { cwd, encoding: "utf-8" }).trim()
} catch {
throw new Error("Could not determine git remote URL. Is this a git repository with an 'origin' remote?")
}
}
export function getGitBranch(cwd = process.cwd()): string {
try {
return execSync("git rev-parse --abbrev-ref HEAD", { cwd, encoding: "utf-8" }).trim()
} catch {
return "main"
}
}
export function normalizeRemoteUrl(url: string): string {
let normalized = url
const sshMatch = normalized.match(/^git@([^:]+):(.+?)(?:\.git)?$/)
if (sshMatch) {
normalized = `https://${sshMatch[1]}/${sshMatch[2]}`
}
normalized = normalized.replace(/\.git$/, "")
normalized = normalized.replace(/\/+$/, "")
return normalized
}
function detectHost(url: string): "bitbucket" | "github" | "gitlab" | "unknown" {
if (url.includes("bitbucket.org")) return "bitbucket"
if (url.includes("github.com")) return "github"
if (url.includes("gitlab.com") || url.includes("gitlab")) return "gitlab"
return "unknown"
}
export function buildOriginUrl(
remoteUrl: string,
branch: string,
todoPath: string,
issueId: number
): string {
const base = normalizeRemoteUrl(remoteUrl)
const host = detectHost(base)
switch (host) {
case "github":
return `${base}/blob/${branch}/${todoPath}#${issueId}`
case "gitlab":
return `${base}/-/blob/${branch}/${todoPath}#${issueId}`
case "bitbucket":
default:
return `${base}/src/${branch}/${todoPath}#${issueId}`
}
}
export function buildCommitUrl(remoteUrl: string, commitHash: string): string {
const base = normalizeRemoteUrl(remoteUrl)
const host = detectHost(base)
switch (host) {
case "github":
return `${base}/commit/${commitHash}`
case "gitlab":
return `${base}/-/commit/${commitHash}`
case "bitbucket":
default:
return `${base}/commits/${commitHash}`
}
}
export function shortHash(hash: string): string {
return hash.slice(0, 7)
}

130
lib/bugzilla/types.ts Normal file
View file

@ -0,0 +1,130 @@
// Bugzilla 5.0+ REST API type definitions
export interface BugzillaBug {
id: number
alias: string[]
summary: string
status: string
resolution: string
priority: string
severity: string
product: string
component: string
description?: string // comment 0 text (not always in bug response)
creation_time: string
last_change_time: string
depends_on: number[]
blocks: number[]
url: string
see_also: string[]
assigned_to: string
creator: string
is_open: boolean
}
export interface BugzillaComment {
id: number
bug_id: number
text: string
creator: string
time: string
count: number
is_private: boolean
tags: string[]
}
export interface BugzillaSearchParams {
product?: string
component?: string
status?: string | string[]
id?: number | number[]
alias?: string | string[]
url?: string
summary?: string
limit?: number
offset?: number
last_change_time?: string
}
export interface BugzillaCreatePayload {
product: string
component: string
summary: string
version?: string
url?: string
description?: string
status?: string
priority?: string
severity?: string
op_sys?: string
rep_platform?: string
depends_on?: number[]
blocks?: number[]
see_also?: string[]
}
export interface BugzillaUpdatePayload {
summary?: string
status?: string
resolution?: string
priority?: string
severity?: string
depends_on?: { set: number[] }
blocks?: { set: number[] }
see_also?: { add?: string[]; remove?: string[] }
}
export interface BugzillaSearchResponse {
bugs: BugzillaBug[]
}
export interface BugzillaCreateResponse {
id: number
}
export interface BugzillaUpdateResponse {
bugs: Array<{
id: number
changes: Record<string, { removed: string; added: string }>
}>
}
export interface BugzillaCommentsResponse {
bugs: Record<string, { comments: BugzillaComment[] }>
}
// Product and component types
export interface BugzillaComponent {
id: number
name: string
description: string
default_assigned_to: string
is_active: boolean
}
export interface BugzillaProduct {
id: number
name: string
description: string
is_active: boolean
components: BugzillaComponent[]
}
export interface BugzillaProductResponse {
products: BugzillaProduct[]
}
export interface BugzillaCreateProductPayload {
name: string
description: string
version: string
is_open?: boolean
}
export interface BugzillaCreateComponentPayload {
product: string
name: string
description: string
default_assignee: string
}

71
lib/cli/CLI.ts Normal file
View file

@ -0,0 +1,71 @@
// CLI dispatcher — recursively walks CLICommand tree and wires up yargs
import yargs, { type Argv } from "yargs"
import { hideBin } from "yargs/helpers"
import { CLICommand } from "./CLICommand"
interface CLIOpts {
prog: string
description: string
}
export class CLI {
private prog: string
private description: string
constructor(opts: CLIOpts) {
this.prog = opts.prog
this.description = opts.description
}
private registerCommand(y: Argv, CommandClass: new () => CLICommand): void {
const cmd = new CommandClass()
const subcommands = (CommandClass as any)._subcommands as (new () => CLICommand)[] | undefined
if (subcommands?.length) {
// Branch command with subcommands
y.command(
cmd.name,
cmd.help,
(sub) => {
for (const Sub of subcommands) {
this.registerCommand(sub, Sub)
}
return sub.demandCommand(1, `Please specify a ${cmd.name} subcommand`)
},
() => {}
)
} else {
// Leaf command
y.command(
cmd.name,
cmd.help,
(sub) => cmd.addArguments(sub),
async (args) => {
const code = await cmd.execute(args)
process.exit(code)
}
)
}
}
bootstrap(commands: (new () => CLICommand)[]): void {
const y = yargs(hideBin(process.argv))
.scriptName(this.prog)
.usage(`${this.description}\n\n$0 <command> [options]`)
.option("verbose", {
alias: "v",
type: "boolean",
description: "Enable verbose output",
default: false,
})
.demandCommand(1, "Please specify a command")
.strict()
.help()
for (const Cmd of commands) {
this.registerCommand(y, Cmd)
}
y.parse()
}
}

18
lib/cli/CLICommand.ts Normal file
View file

@ -0,0 +1,18 @@
// Abstract base class for CLI commands — modeled after the Python CLICommand pattern
import type { Argv, ArgumentsCamelCase } from "yargs"
export abstract class CLICommand {
abstract readonly name: string
abstract readonly help: string
abstract readonly description: string
static _subcommands: (new () => CLICommand)[] = []
addArguments(yargs: Argv): Argv {
return yargs
}
async execute(args: ArgumentsCamelCase): Promise<number> {
return 0
}
}

View file

@ -0,0 +1,54 @@
import type { Argv, ArgumentsCamelCase } from "yargs"
import { CLICommand } from "../cli/CLICommand"
import { parseTodoFile, writeTodoFile } from "../file"
import { getCurrentBranch, commitFileWithBody } from "../git"
import { validateStatusTransition } from "../issue"
export class CancelCommand extends CLICommand {
readonly name = "cancel <id>"
readonly help = "Cancel an issue"
readonly description = "Set issue to cancelled (must be on issue branch or develop)"
addArguments(yargs: Argv): Argv {
return yargs
.positional("id", { type: "number", demandOption: true })
.option("reason", {
type: "string",
demandOption: true,
description: "Reason for cancellation",
})
}
async execute(args: ArgumentsCamelCase): Promise<number> {
const todo = await parseTodoFile()
const issue = todo.issues.find(i => i.id === args.id)
if (!issue) {
console.error(`Issue #${args.id} not found`)
return 1
}
// Cancel allowed from develop (if open) or issue branch (if in-progress/hold)
const issueBranch = `${issue.type}/${issue.id}`
const branch = getCurrentBranch()
if (branch !== "develop" && branch !== issueBranch) {
console.error(`Must be on develop or ${issueBranch} to cancel (currently on ${branch})`)
return 1
}
const err = validateStatusTransition(issue.status, "cancelled")
if (err) {
console.error(err)
return 1
}
issue.status = "cancelled"
writeTodoFile(todo)
commitFileWithBody(
"TODO",
`todo(${issue.id}): cancelled`,
args.reason as string
)
console.log(`Issue #${issue.id} is now cancelled`)
return 0
}
}

View file

@ -0,0 +1,87 @@
import type { Argv, ArgumentsCamelCase } from "yargs"
import { CLICommand } from "../cli/CLICommand"
import { parseTodoFile, writeTodoFile } from "../file"
import { getCurrentBranch, commitFileWithBody } from "../git"
import type { IssueType, IssuePriority } from "../issue"
export class CreateCommand extends CLICommand {
readonly name = "create"
readonly help = "Create a new issue"
readonly description = "Add an issue to the TODO file (must be on develop)"
addArguments(yargs: Argv): Argv {
return yargs
.option("type", {
alias: "t",
type: "string",
choices: ["feature", "bugfix", "hotfix"] as const,
demandOption: true,
})
.option("title", {
type: "string",
demandOption: true,
})
.option("priority", {
alias: "p",
type: "string",
choices: ["low", "medium", "high"] as const,
default: "medium",
})
.option("plan", {
type: "string",
demandOption: true,
description: "Description of what needs to be done",
})
.option("module", {
alias: "m",
type: "string",
})
}
async execute(args: ArgumentsCamelCase): Promise<number> {
const branch = getCurrentBranch()
if (branch !== "develop") {
console.error(`Must be on develop to create issues (currently on ${branch})`)
return 1
}
const todo = await parseTodoFile()
const mod = args.module as string | undefined
if (mod && todo.modules) {
const valid = todo.modules.map(m => m.name)
if (!valid.includes(mod)) {
console.error(`Module "${mod}" not defined. Valid: ${valid.join(", ")}`)
return 1
}
}
const nextId = todo.issues.length > 0
? Math.max(...todo.issues.map(i => i.id)) + 1
: 1
const today = new Date().toISOString().slice(0, 10)
todo.issues.push({
id: nextId,
type: args.type as IssueType,
title: args.title as string,
status: "open",
priority: args.priority as IssuePriority,
created: today,
module: mod,
relationships: {},
description: args.plan as string,
body: "",
})
writeTodoFile(todo)
commitFileWithBody(
"TODO",
`todo(${nextId}): open`,
args.plan as string
)
console.log(`Created issue #${nextId}: ${args.title}`)
return 0
}
}

View file

@ -0,0 +1,54 @@
import type { Argv, ArgumentsCamelCase } from "yargs"
import { CLICommand } from "../cli/CLICommand"
import { parseTodoFile, writeTodoFile } from "../file"
import { getCurrentBranch, commitFileWithBody } from "../git"
import { validateStatusTransition } from "../issue"
export class DoneCommand extends CLICommand {
readonly name = "done <id>"
readonly help = "Mark an issue as done"
readonly description = "Set issue to done (must be on issue branch)"
addArguments(yargs: Argv): Argv {
return yargs
.positional("id", { type: "number", demandOption: true })
.option("summary", {
type: "string",
demandOption: true,
description: "High-level summary of what was delivered",
})
}
async execute(args: ArgumentsCamelCase): Promise<number> {
const todo = await parseTodoFile()
const issue = todo.issues.find(i => i.id === args.id)
if (!issue) {
console.error(`Issue #${args.id} not found`)
return 1
}
// Must be on the correct issue branch
const expectedBranch = `${issue.type}/${issue.id}`
const branch = getCurrentBranch()
if (branch !== expectedBranch) {
console.error(`Must be on ${expectedBranch} to mark issue as done (currently on ${branch})`)
return 1
}
const err = validateStatusTransition(issue.status, "done")
if (err) {
console.error(err)
return 1
}
issue.status = "done"
writeTodoFile(todo)
commitFileWithBody(
"TODO",
`todo(${issue.id}): done`,
args.summary as string
)
console.log(`Issue #${issue.id} is now done`)
return 0
}
}

View file

@ -0,0 +1,53 @@
import type { Argv, ArgumentsCamelCase } from "yargs"
import { CLICommand } from "../cli/CLICommand"
import { parseTodoFile, writeTodoFile } from "../file"
import { getCurrentBranch, commitFileWithBody } from "../git"
import { validateStatusTransition } from "../issue"
export class HoldCommand extends CLICommand {
readonly name = "hold <id>"
readonly help = "Put an issue on hold"
readonly description = "Set issue to hold (must be on issue branch)"
addArguments(yargs: Argv): Argv {
return yargs
.positional("id", { type: "number", demandOption: true })
.option("reason", {
type: "string",
demandOption: true,
description: "Reason for holding",
})
}
async execute(args: ArgumentsCamelCase): Promise<number> {
const todo = await parseTodoFile()
const issue = todo.issues.find(i => i.id === args.id)
if (!issue) {
console.error(`Issue #${args.id} not found`)
return 1
}
const expectedBranch = `${issue.type}/${issue.id}`
const branch = getCurrentBranch()
if (branch !== expectedBranch) {
console.error(`Must be on ${expectedBranch} to hold issue (currently on ${branch})`)
return 1
}
const err = validateStatusTransition(issue.status, "hold")
if (err) {
console.error(err)
return 1
}
issue.status = "hold"
writeTodoFile(todo)
commitFileWithBody(
"TODO",
`todo(${issue.id}): hold`,
args.reason as string
)
console.log(`Issue #${issue.id} is now on hold`)
return 0
}
}

157
lib/commands/InitCommand.ts Normal file
View file

@ -0,0 +1,157 @@
import type { Argv, ArgumentsCamelCase } from "yargs"
import { CLICommand } from "../cli/CLICommand"
import { parseTodoFile } from "../file"
import { BugzillaClient } from "../bugzilla/client"
import { loadConfig } from "../bugzilla/config"
interface ProductState {
name: string
exists: boolean
components: {
name: string
exists: boolean
}[]
}
export class InitCommand extends CLICommand {
readonly name = "init"
readonly help = "Initialize Bugzilla products and components"
readonly description = "Check and optionally create Bugzilla products/components from TODO tracker config"
addArguments(yargs: Argv): Argv {
return yargs
.option("dry-run", {
type: "boolean",
default: false,
description: "Show what would be created without creating",
})
.option("confirm", {
type: "boolean",
default: false,
description: "Actually create missing products/components (required for writes)",
})
.option("assignee", {
type: "string",
description: "Default assignee email for new components",
})
}
async execute(args: ArgumentsCamelCase): Promise<number> {
const todo = await parseTodoFile()
if (!todo.bugzilla) {
console.error("No application/bugzilla part found in TODO file")
return 1
}
const config = loadConfig()
const client = new BugzillaClient(config)
const dryRun = args.dryRun as boolean
const confirm = args.confirm as boolean
const assignee = args.assignee as string | undefined
// Collect unique products and their components from the mappings
const productMap = new Map<string, Set<string>>()
for (const m of todo.bugzilla.mappings) {
if (!productMap.has(m.product)) productMap.set(m.product, new Set())
productMap.get(m.product)!.add(m.component)
}
// Check what exists on the server
const states: ProductState[] = []
const existingProducts = await client.getAccessibleProducts()
const existingByName = new Map(existingProducts.map(p => [p.name, p]))
for (const [productName, componentNames] of productMap) {
const existing = existingByName.get(productName)
const existingComponents = new Set(
existing?.components?.map(c => c.name) ?? []
)
states.push({
name: productName,
exists: !!existing,
components: [...componentNames].map(name => ({
name,
exists: existingComponents.has(name),
})),
})
}
// Report
let allOk = true
for (const product of states) {
const pStatus = product.exists ? "ok" : "MISSING"
console.log(`Product: ${product.name} [${pStatus}]`)
for (const comp of product.components) {
const cStatus = comp.exists ? "ok" : "MISSING"
console.log(` Component: ${comp.name} [${cStatus}]`)
if (!comp.exists) allOk = false
}
if (!product.exists) allOk = false
}
if (allOk) {
console.log("\nAll products and components exist. Nothing to do.")
return 0
}
if (dryRun) {
console.log("\n[dry-run] Would create the MISSING items above.")
return 0
}
if (!confirm) {
console.log("\nMissing items found. Run with --confirm to create them.")
return 1
}
// Need assignee for component creation
const needsComponents = states.some(p =>
p.components.some(c => !c.exists)
)
if (needsComponents && !assignee) {
console.error("\n--assignee is required when creating components")
return 1
}
// Create missing products and components
let created = 0
for (const product of states) {
if (!product.exists) {
try {
const id = await client.createProduct({
name: product.name,
description: product.name,
version: "unspecified",
})
console.log(`Created product #${id}: ${product.name}`)
created++
} catch (err: any) {
console.error(`Error creating product "${product.name}": ${err.message}`)
continue
}
}
for (const comp of product.components) {
if (!comp.exists) {
try {
const id = await client.createComponent({
product: product.name,
name: comp.name,
description: comp.name,
default_assignee: assignee!,
})
console.log(`Created component #${id}: ${comp.name} (in ${product.name})`)
created++
} catch (err: any) {
console.error(`Error creating component "${comp.name}": ${err.message}`)
}
}
}
}
console.log(`\nInit complete: ${created} items created`)
return 0
}
}

View file

@ -0,0 +1,17 @@
import type { ArgumentsCamelCase } from "yargs"
import { CLICommand } from "../cli/CLICommand"
import { parseTodoFile } from "../file"
export class IssueListCommand extends CLICommand {
readonly name = "list"
readonly help = "List all issues"
readonly description = "List all issues in the TODO file"
async execute(args: ArgumentsCamelCase): Promise<number> {
const todo = await parseTodoFile()
for (const issue of todo.issues) {
console.log(`#${issue.id} [${issue.type}] (${issue.status}) ${issue.title}`)
}
return 0
}
}

View file

@ -0,0 +1,42 @@
import type { Argv, ArgumentsCamelCase } from "yargs"
import { CLICommand } from "../cli/CLICommand"
import { parseTodoFile } from "../file"
export class IssueShowCommand extends CLICommand {
readonly name = "show <id>"
readonly help = "Show details for a single issue"
readonly description = "Print all fields for one issue"
addArguments(yargs: Argv): Argv {
return yargs.positional("id", { type: "number", demandOption: true })
}
async execute(args: ArgumentsCamelCase): Promise<number> {
const todo = await parseTodoFile()
const issue = todo.issues.find(i => i.id === args.id)
if (!issue) {
console.error(`Issue #${args.id} not found`)
return 1
}
console.log(`ID: ${issue.id}`)
console.log(`Type: ${issue.type}`)
console.log(`Title: ${issue.title}`)
console.log(`Status: ${issue.status}`)
console.log(`Priority: ${issue.priority}`)
console.log(`Created: ${issue.created}`)
if (issue.module) console.log(`Module: ${issue.module}`)
if (issue.dueStart) console.log(`DueStart: ${issue.dueStart}`)
if (issue.dueEnd) console.log(`DueEnd: ${issue.dueEnd}`)
const rels = Object.entries(issue.relationships)
.map(([k, v]) => `${k}:${(v as number[]).join(" ")}`)
.join(", ")
console.log(`Relationships: ${rels}`)
console.log(`Description: ${issue.description}`)
if (issue.body) {
console.log()
console.log(issue.body)
}
return 0
}
}

View file

@ -0,0 +1,33 @@
import type { Argv, ArgumentsCamelCase } from "yargs"
import { CLICommand } from "../cli/CLICommand"
import { parseTodoFile } from "../file"
export class IssuesInSprintCommand extends CLICommand {
readonly name = "issues-in-sprint <name>"
readonly help = "List issues in a sprint"
readonly description = "Find issues whose date range overlaps with a sprint"
addArguments(yargs: Argv): Argv {
return yargs.positional("name", { type: "string", demandOption: true })
}
async execute(args: ArgumentsCamelCase): Promise<number> {
const todo = await parseTodoFile()
const sprint = todo.sprints.find(s => s.name === args.name)
if (!sprint) {
console.error(`Sprint not found: ${args.name}`)
return 1
}
const { start, end } = sprint
for (const issue of todo.issues) {
const ds = issue.dueStart ?? issue.dueEnd
const de = issue.dueEnd ?? issue.dueStart
if (!ds || !de) continue
if (ds <= end && de >= start) {
console.log(`#${issue.id} [${issue.type}] (${issue.status}) ${issue.title}`)
}
}
return 0
}
}

394
lib/commands/PushCommand.ts Normal file
View file

@ -0,0 +1,394 @@
import type { Argv, ArgumentsCamelCase } from "yargs"
import { CLICommand } from "../cli/CLICommand"
import { parseTodoFile } from "../file"
import { BugzillaClient } from "../bugzilla/client"
import { loadConfig } from "../bugzilla/config"
import {
issueToBugzillaCreate,
statusToBugzilla,
resolveProductComponent,
} from "../bugzilla/fieldmap"
import {
buildOriginUrl,
buildCommitUrl,
shortHash,
getGitRemoteUrl,
getGitBranch,
} from "../bugzilla/origin"
import {
getCurrentBranch,
parseIssueBranch,
getCommitsSinceDiverge,
getCommitsFromRef,
parseTodoTransition,
type GitCommit,
} from "../git"
type Strategy = "smart" | "full"
export class PushCommand extends CLICommand {
readonly name = "push [ref]"
readonly help = "Push commits to Bugzilla"
readonly description = "Push git commits as Bugzilla comments, transitions as status updates"
addArguments(yargs: Argv): Argv {
return yargs
.positional("ref", {
type: "string",
description: "Git ref specifier (e.g. HEAD~3) — issue branches only",
})
.option("dry-run", {
type: "boolean",
default: false,
description: "Show what would be pushed without pushing",
})
.option("strategy", {
type: "string",
choices: ["smart", "full"] as const,
default: "smart",
description: "Fetch strategy: smart (only relevant bugs) or full (all bugs)",
})
}
async execute(args: ArgumentsCamelCase): Promise<number> {
const branch = getCurrentBranch()
const issueBranch = parseIssueBranch(branch)
const isDevelop = branch === "develop"
if (!isDevelop && !issueBranch) {
console.error(`Must be on develop or an issue branch (currently on ${branch})`)
return 1
}
const todo = await parseTodoFile()
const config = loadConfig()
const client = new BugzillaClient(config)
const remoteUrl = getGitRemoteUrl()
// Origin URL always uses develop — that's where the TODO lives canonically
const gitBranch = "develop"
const strategy = args.strategy as Strategy
const dryRun = args.dryRun as boolean
if (issueBranch) {
return this.pushFromIssueBranch(
client, config, todo, remoteUrl, gitBranch,
issueBranch, args.ref as string | undefined, strategy, dryRun
)
} else {
return this.pushFromDevelop(
client, config, todo, remoteUrl, gitBranch,
args.ref as string | undefined, strategy, dryRun
)
}
}
// Push work comments from an issue branch
private async pushFromIssueBranch(
client: BugzillaClient,
config: import("../bugzilla/config").BugzillaConfig,
todo: import("../file").TodoFile,
remoteUrl: string,
gitBranch: string,
issueBranch: { type: string; id: number },
ref: string | undefined,
strategy: Strategy,
dryRun: boolean
): Promise<number> {
const issue = todo.issues.find(i => i.id === issueBranch.id)
if (!issue) {
console.error(`Issue #${issueBranch.id} not found in TODO`)
return 1
}
// Issue must be in-progress to push work comments
if (issue.status !== "in-progress") {
console.error(
`Issue #${issue.id} is "${issue.status}" — work comments can only be pushed when in-progress`
)
return 1
}
// Resolve the bug
const bugId = await this.findOrCreateBug(
client, config, todo, remoteUrl, gitBranch, issue, strategy, dryRun
)
if (!bugId) return 1
// Check Bugzilla comments for a done transition — no work after done
const existingComments = await this.fetchCommentMeta(client, bugId)
if (existingComments.hasDone) {
console.error(
`Bug #${bugId} already has a [done] transition — no further work comments allowed`
)
return 1
}
// Get commits to push
const commits = ref
? getCommitsFromRef(`${ref}..HEAD`)
: getCommitsSinceDiverge("develop")
if (commits.length === 0) {
console.log("No commits to push")
return 0
}
let pushed = 0
for (const commit of commits) {
if (existingComments.hashes.has(shortHash(commit.hash))) continue
if (dryRun) {
console.log(`[dry-run] bug #${bugId}${shortHash(commit.hash)} ${commit.subject}`)
continue
}
try {
const commitUrl = buildCommitUrl(remoteUrl, commit.hash)
const commentId = await this.postComment(client, bugId, commit, commitUrl)
await this.tagAsPushed(client, commentId, commit.hash)
pushed++
console.log(`bug #${bugId}${shortHash(commit.hash)} ${commit.subject}`)
} catch (err: any) {
console.error(`Error pushing ${shortHash(commit.hash)}: ${err.message}`)
}
}
console.log(`Push complete: ${pushed} comments`)
return 0
}
// Push transitions from develop
private async pushFromDevelop(
client: BugzillaClient,
config: import("../bugzilla/config").BugzillaConfig,
todo: import("../file").TodoFile,
remoteUrl: string,
gitBranch: string,
ref: string | undefined,
strategy: Strategy,
dryRun: boolean
): Promise<number> {
// Get transition commits
let allCommits: GitCommit[]
if (ref) {
allCommits = getCommitsFromRef(`${ref}..HEAD`)
} else {
allCommits = getCommitsFromRef("HEAD")
}
const relevantCommits = allCommits.filter(c =>
parseTodoTransition(c.subject) !== null
)
if (relevantCommits.length === 0) {
console.log("No transitions to push")
return 0
}
// Resolve all bugs
const bugMap = await this.resolveAllBugs(
client, config, todo, remoteUrl, gitBranch, strategy, dryRun
)
let pushed = 0
let transitioned = 0
for (const commit of relevantCommits) {
const transition = parseTodoTransition(commit.subject)
if (!transition) continue
const bugId = bugMap.get(transition.issueId)
if (!bugId) continue
// Idempotency check
const meta = await this.fetchCommentMeta(client, bugId)
if (meta.hashes.has(shortHash(commit.hash))) continue
if (dryRun) {
console.log(`[dry-run] bug #${bugId}${shortHash(commit.hash)} ${commit.subject}`)
console.log(`[dry-run] → status: ${transition.status}`)
continue
}
try {
const commitUrl = buildCommitUrl(remoteUrl, commit.hash)
const commentId = await this.postComment(client, bugId, commit, commitUrl)
// Update status, then tag — tag only after everything succeeds
const bz = statusToBugzilla(transition.status as any)
const payload: any = { status: bz.status }
if (bz.resolution) payload.resolution = bz.resolution
await client.updateBug(bugId, payload)
await this.tagAsPushed(client, commentId, commit.hash)
pushed++
transitioned++
console.log(`bug #${bugId}${shortHash(commit.hash)} [${transition.status}]`)
} catch (err: any) {
console.error(`Error pushing ${shortHash(commit.hash)} to bug #${bugId}: ${err.message}`)
}
}
console.log(`Push complete: ${pushed} comments, ${transitioned} transitions`)
return 0
}
// Find a bug for a specific issue, or create it
private async findOrCreateBug(
client: BugzillaClient,
config: import("../bugzilla/config").BugzillaConfig,
todo: import("../file").TodoFile,
remoteUrl: string,
gitBranch: string,
issue: import("../issue").Issue,
strategy: Strategy,
dryRun: boolean
): Promise<number | null> {
// Try to find existing bug by URL
const originUrl = buildOriginUrl(remoteUrl, gitBranch, "TODO", issue.id)
if (strategy === "smart") {
const bugs = await client.searchBugs({ url: originUrl })
if (bugs.length > 0) return bugs[0].id
}
// Fallback: search all bugs in product/component
const searchTargets = todo.bugzilla
? todo.bugzilla.mappings.map(m => ({ product: m.product, component: m.component }))
: [{ product: config.product, component: config.component }]
for (const target of searchTargets) {
if (!target.product) continue
const bugs = await client.searchBugs(target)
for (const bug of bugs) {
if (bug.url === originUrl) return bug.id
}
}
// Not found — create it
if (dryRun) {
console.log(`[dry-run] Would create bug for issue #${issue.id}: ${issue.title}`)
return null
}
try {
const resolved = resolveProductComponent(issue, todo.bugzilla)
const product = resolved?.product ?? config.product
const component = resolved?.component ?? config.component
const payload = issueToBugzillaCreate(issue, product, component, originUrl)
const bugId = await client.createBug(payload)
console.log(`Created bug #${bugId} for issue #${issue.id}`)
return bugId
} catch (err: any) {
console.error(`Error creating bug for issue #${issue.id}: ${err.message}`)
return null
}
}
// Resolve bugs for all issues in the TODO
private async resolveAllBugs(
client: BugzillaClient,
config: import("../bugzilla/config").BugzillaConfig,
todo: import("../file").TodoFile,
remoteUrl: string,
gitBranch: string,
strategy: Strategy,
dryRun: boolean
): Promise<Map<number, number>> {
const bugMap = new Map<number, number>()
const searchTargets = todo.bugzilla
? todo.bugzilla.mappings.map(m => ({ product: m.product, component: m.component }))
: [{ product: config.product, component: config.component }]
const seen = new Set<number>()
for (const target of searchTargets) {
if (!target.product) continue
const bugs = await client.searchBugs(target)
for (const bug of bugs) {
if (seen.has(bug.id)) continue
seen.add(bug.id)
if (bug.url) {
const match = bug.url.match(/#(\d+)$/)
if (match) bugMap.set(Number(match[1]), bug.id)
}
}
}
// Create bugs for issues that don't have one yet
for (const issue of todo.issues) {
if (bugMap.has(issue.id)) continue
if (dryRun) {
console.log(`[dry-run] Would create bug for issue #${issue.id}: ${issue.title}`)
continue
}
try {
const resolved = resolveProductComponent(issue, todo.bugzilla)
const product = resolved?.product ?? config.product
const component = resolved?.component ?? config.component
const url = buildOriginUrl(remoteUrl, gitBranch, "TODO", issue.id)
const payload = issueToBugzillaCreate(issue, product, component, url)
const bugId = await client.createBug(payload)
bugMap.set(issue.id, bugId)
console.log(`Created bug #${bugId} for issue #${issue.id}`)
} catch (err: any) {
console.error(`Error creating bug for issue #${issue.id}: ${err.message}`)
}
}
return bugMap
}
// Fetch comment metadata: which commit hashes are already tagged + whether done exists
private async fetchCommentMeta(
client: BugzillaClient,
bugId: number
): Promise<{ hashes: Set<string>; hasDone: boolean }> {
const hashes = new Set<string>()
let hasDone = false
try {
const comments = await client.getComments(bugId)
for (const c of comments) {
for (const tag of c.tags ?? []) {
if (tag.startsWith("git-")) {
hashes.add(tag.slice(4)) // short hash without "git-" prefix
}
}
if (/^\*\*todo\(\d+\): done\*\*$/m.test(c.text)) hasDone = true
}
} catch { /* non-fatal */ }
return { hashes, hasDone }
}
// Post a comment, returns the comment ID for later tagging
private async postComment(
client: BugzillaClient,
bugId: number,
commit: GitCommit,
commitUrl: string
): Promise<number> {
const comment = this.formatComment(commit, commitUrl)
return await client.addComment(bugId, comment)
}
// Tag a comment as pushed — call only after all side effects succeed
private async tagAsPushed(
client: BugzillaClient,
commentId: number,
commitHash: string
): Promise<void> {
await client.tagComment(commentId, [`git-${shortHash(commitHash)}`])
}
// Format a commit as a markdown Bugzilla comment
private formatComment(commit: GitCommit, commitUrl: string): string {
const lines: string[] = []
lines.push(`**${commit.subject}**`)
if (commit.body) {
lines.push("")
lines.push(commit.body)
}
lines.push("")
lines.push(`[\`${shortHash(commit.hash)}\`](${commitUrl})`)
return lines.join("\n")
}
}

View file

@ -0,0 +1,17 @@
import type { ArgumentsCamelCase } from "yargs"
import { CLICommand } from "../cli/CLICommand"
import { parseTodoFile } from "../file"
export class SprintsCommand extends CLICommand {
readonly name = "sprints"
readonly help = "List all sprints"
readonly description = "List all sprints in the TODO file"
async execute(args: ArgumentsCamelCase): Promise<number> {
const todo = await parseTodoFile()
for (const sprint of todo.sprints) {
console.log(`${sprint.name}: ${sprint.start}..${sprint.end}`)
}
return 0
}
}

View file

@ -0,0 +1,60 @@
import type { Argv, ArgumentsCamelCase } from "yargs"
import { CLICommand } from "../cli/CLICommand"
import { parseTodoFile, writeTodoFile } from "../file"
import { getCurrentBranch, branchExists, commitFileWithBody } from "../git"
import { validateStatusTransition } from "../issue"
export class StartCommand extends CLICommand {
readonly name = "start <id>"
readonly help = "Start work on an issue"
readonly description = "Set issue to in-progress (must be on develop)"
addArguments(yargs: Argv): Argv {
return yargs
.positional("id", { type: "number", demandOption: true })
.option("plan", {
type: "string",
demandOption: true,
description: "High-level description of planned approach",
})
}
async execute(args: ArgumentsCamelCase): Promise<number> {
const branch = getCurrentBranch()
if (branch !== "develop") {
console.error(`Must be on develop to start an issue (currently on ${branch})`)
return 1
}
const todo = await parseTodoFile()
const issue = todo.issues.find(i => i.id === args.id)
if (!issue) {
console.error(`Issue #${args.id} not found`)
return 1
}
const err = validateStatusTransition(issue.status, "in-progress")
if (err) {
console.error(err)
return 1
}
// Check that the issue branch doesn't already exist
const issueBranch = `${issue.type}/${issue.id}`
if (branchExists(issueBranch)) {
console.error(`Branch ${issueBranch} already exists`)
return 1
}
issue.status = "in-progress"
writeTodoFile(todo)
commitFileWithBody(
"TODO",
`todo(${issue.id}): in-progress`,
args.plan as string
)
console.log(`Issue #${issue.id} is now in-progress`)
console.log(`Create the issue branch: git checkout -b ${issueBranch}`)
return 0
}
}

144
lib/file.schema.json Normal file
View file

@ -0,0 +1,144 @@
{
"$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" }
},
"modules": {
"type": "array",
"items": { "$ref": "#/definitions/Module" }
},
"bugzilla": {
"$ref": "#/definitions/BugzillaTracker"
}
},
"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}$"
},
"module": {
"type": "string"
},
"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
},
"Module": {
"type": "object",
"properties": {
"name": { "type": "string" },
"path": { "type": "string" }
},
"required": ["name"],
"additionalProperties": false
},
"BugzillaTrackerMapping": {
"type": "object",
"properties": {
"module": { "type": "string" },
"product": { "type": "string" },
"component": { "type": "string" }
},
"required": ["module", "product", "component"],
"additionalProperties": false
},
"BugzillaTracker": {
"type": "object",
"properties": {
"url": { "type": "string" },
"mappings": {
"type": "array",
"items": { "$ref": "#/definitions/BugzillaTrackerMapping" },
"minItems": 1
}
},
"required": ["url", "mappings"],
"additionalProperties": false
}
}
}

199
lib/file.ts Normal file
View 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)
}

95
lib/git.ts Normal file
View file

@ -0,0 +1,95 @@
// Git helpers for branch validation and commit operations
import { execSync } from "child_process"
export function getCurrentBranch(cwd = process.cwd()): string {
return execSync("git rev-parse --abbrev-ref HEAD", { cwd, encoding: "utf-8" }).trim()
}
export function isClean(cwd = process.cwd()): boolean {
const status = execSync("git status --porcelain", { cwd, encoding: "utf-8" }).trim()
return status === ""
}
export function commitFile(path: string, message: string, cwd = process.cwd()): void {
execSync(`git add ${path}`, { cwd })
execSync(`git commit -m ${JSON.stringify(message)}`, { cwd })
}
export function commitFileWithBody(path: string, header: string, body: string, cwd = process.cwd()): void {
execSync(`git add ${path}`, { cwd })
const msg = `${header}\n\n${body}`
execSync(`git commit -m ${JSON.stringify(msg)}`, { cwd })
}
export function branchExists(branch: string, cwd = process.cwd()): boolean {
try {
execSync(`git rev-parse --verify ${branch}`, { cwd, stdio: "pipe" })
return true
} catch {
return false
}
}
// Parse the issue branch name into type and ID
export function parseIssueBranch(branch: string): { type: string; id: number } | null {
const match = branch.match(/^(feature|bugfix|hotfix)\/(\d+)$/)
if (!match) return null
return { type: match[1], id: Number(match[2]) }
}
export interface GitCommit {
hash: string
subject: string
body: string
}
// Get commits on current branch since it diverged from a base branch
export function getCommitsSinceDiverge(base = "develop", cwd = process.cwd()): GitCommit[] {
return parseGitLog(`${base}..HEAD`, cwd)
}
// Get commits from a ref spec (e.g. HEAD~3..HEAD)
export function getCommitsFromRef(refSpec: string, cwd = process.cwd()): GitCommit[] {
return parseGitLog(refSpec, cwd)
}
// Get all commits on a branch
export function getAllCommits(branch: string, cwd = process.cwd()): GitCommit[] {
return parseGitLog(branch, cwd)
}
function parseGitLog(range: string, cwd: string): GitCommit[] {
const SEP = "---COMMIT-SEP---"
const format = `%H%n%s%n%b%n${SEP}`
let output: string
try {
output = execSync(
`git log ${range} --format=${JSON.stringify(format)}`,
{ cwd, encoding: "utf-8" }
)
} catch {
return []
}
const commits: GitCommit[] = []
const entries = output.split(SEP).filter(e => e.trim())
for (const entry of entries) {
const lines = entry.trim().split("\n")
if (lines.length < 2) continue
commits.push({
hash: lines[0],
subject: lines[1],
body: lines.slice(2).join("\n").trim(),
})
}
return commits
}
// Check if a commit subject is a todo transition
export function parseTodoTransition(subject: string): { issueId: number; status: string } | null {
const match = subject.match(/^todo\((\d+)\):\s*(\S+)/)
if (!match) return null
return { issueId: Number(match[1]), status: match[2] }
}

117
lib/issue.ts Normal file
View file

@ -0,0 +1,117 @@
// 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
module?: string // must reference a module defined in application/modules
relationships: IssueRelationships
dueStart?: string // YYYY-MM-DD
dueEnd?: string // YYYY-MM-DD
description: string
body: string
}
// Valid status transitions
const VALID_TRANSITIONS: Record<IssueStatus, IssueStatus[]> = {
"open": ["in-progress", "hold", "cancelled"],
"in-progress": ["done", "hold", "open", "cancelled"],
"hold": ["open", "in-progress", "cancelled"],
"done": ["open"],
"cancelled": ["open"],
}
export function validateStatusTransition(from: IssueStatus, to: IssueStatus): string | null {
if (from === to) return null // no-op is fine
const allowed = VALID_TRANSITIONS[from]
if (!allowed?.includes(to)) {
return `Invalid status transition: ${from}${to}. Allowed from ${from}: ${allowed?.join(", ") ?? "none"}`
}
return null
}
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("Module:")) issue.module = line.slice("Module:".length).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
}

2
lib/out.txt Normal file
View file

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

161
lib/serializer.ts Normal file
View file

@ -0,0 +1,161 @@
// Inverse of the parser — serializes TodoFile back to MIME TODO format
import type { TodoFile } from "./file"
import type { Issue } from "./issue"
import type { Sprint } from "./sprint"
import type { Module, BugzillaTracker } from "./tracker"
// Word-wrap text to fit within maxCol, respecting prefix widths
// Returns array of lines (without prefix/indent — caller adds those)
export function wordWrap(
text: string,
maxCol: number,
firstPrefixLen: number,
contIndentLen: number
): string[] {
const result: string[] = []
// Split on existing newlines first, then wrap each paragraph
const paragraphs = text.split("\n")
for (const para of paragraphs) {
const words = para.split(/\s+/).filter(Boolean)
if (words.length === 0) {
result.push("")
continue
}
let line = ""
const width = result.length === 0 ? maxCol - firstPrefixLen : maxCol - contIndentLen
for (const word of words) {
const lineWidth = result.length === 0 && line === ""
? maxCol - firstPrefixLen
: maxCol - contIndentLen
if (line === "") {
line = word
} else if (line.length + 1 + word.length <= lineWidth) {
line += " " + word
} else {
result.push(line)
line = word
}
}
if (line) result.push(line)
}
return result.length > 0 ? result : [""]
}
export function serializeRelationships(rels: Issue["relationships"]): string {
const parts: string[] = []
for (const [kind, ids] of Object.entries(rels)) {
if (ids?.length) {
parts.push(`${kind}:${ids.join(" ")}`)
}
}
return parts.join(", ")
}
export function serializeSprints(sprints: Sprint[]): string {
if (sprints.length === 0) return "Sprints:"
const lines = ["Sprints:"]
for (const sprint of sprints) {
lines.push(` - Name: ${sprint.name}`)
lines.push(` Range: ${sprint.start}..${sprint.end}`)
}
return lines.join("\n")
}
export function serializeIssue(issue: Issue): string {
const lines: string[] = []
lines.push(`ID: ${issue.id}`)
lines.push(`Type: ${issue.type}`)
lines.push(`Title: ${issue.title}`)
lines.push(`Status: ${issue.status}`)
lines.push(`Priority: ${issue.priority}`)
lines.push(`Created: ${issue.created}`)
if (issue.module) lines.push(`Module: ${issue.module}`)
if (issue.dueStart) lines.push(`DueStart: ${issue.dueStart}`)
if (issue.dueEnd) lines.push(`DueEnd: ${issue.dueEnd}`)
lines.push(`Relationships: ${serializeRelationships(issue.relationships)}`)
// Description with word-wrap at 80 columns, continuation indented to column 14
const INDENT = " " // 13 spaces — aligns with text after "Description: "
const MAX_COL = 80
const firstPrefix = "Description: "
if (issue.description) {
const wrapped = wordWrap(issue.description, MAX_COL, firstPrefix.length, INDENT.length)
lines.push(`${firstPrefix}${wrapped[0]}`)
for (let i = 1; i < wrapped.length; i++) {
lines.push(`${INDENT}${wrapped[i]}`)
}
} else {
lines.push("Description:")
}
if (issue.body) {
lines.push("")
lines.push(issue.body)
}
return lines.join("\n")
}
export function serializeModules(modules: Module[]): string {
const lines = ["Modules:"]
for (const m of modules) {
lines.push(` - Name: ${m.name}`)
if (m.path) lines.push(` Path: ${m.path}`)
}
return lines.join("\n")
}
export function serializeBugzillaTracker(tracker: BugzillaTracker): string {
const lines: string[] = []
lines.push(`URL: ${tracker.url}`)
lines.push("Mappings:")
for (const m of tracker.mappings) {
lines.push(` - Module: ${m.module}`)
lines.push(` Product: ${m.product}`)
lines.push(` Component: ${m.component}`)
}
return lines.join("\n")
}
export function serializeTodoFile(todo: TodoFile): string {
const parts: string[] = []
// Sprints part
parts.push("--ISSUE")
parts.push("Content-Type: application/sprints")
parts.push(serializeSprints(todo.sprints))
// Modules part
if (todo.modules?.length) {
parts.push("")
parts.push("--ISSUE")
parts.push("Content-Type: application/modules")
parts.push(serializeModules(todo.modules))
}
// Bugzilla tracker part
if (todo.bugzilla) {
parts.push("")
parts.push("--ISSUE")
parts.push("Content-Type: application/bugzilla")
parts.push(serializeBugzillaTracker(todo.bugzilla))
}
// Issue parts
for (const issue of todo.issues) {
parts.push("")
parts.push("--ISSUE")
parts.push("Content-Type: application/issue")
parts.push(serializeIssue(issue))
}
return parts.join("\n") + "\n"
}

1
lib/spec-notes.md Normal file
View file

@ -0,0 +1 @@
# Placeholder for spec extension

42
lib/sprint.ts Normal file
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
}

99
lib/tracker.ts Normal file
View file

@ -0,0 +1,99 @@
// Module and tracker part parsers
// application/modules — defines the repo's logical module structure
// application/bugzilla — maps modules to Bugzilla products/components
export interface Module {
name: string
path?: string
}
export interface BugzillaTrackerMapping {
module: string // references a module name
product: string // Bugzilla product
component: string // Bugzilla component
}
export interface BugzillaTracker {
url: string
mappings: BugzillaTrackerMapping[]
}
export function parseModules(text: string): Module[] {
const lines = text.split(/\r?\n/)
const modules: Module[] = []
let current: Partial<Module> | null = null
for (const line of lines) {
// Skip the "Modules:" header
if (/^\s*Modules:\s*$/.test(line)) continue
// Start of module entry
if (/^\s*-\s*(Name:.*)?$/.test(line)) {
if (current?.name) {
modules.push(current as Module)
}
current = {}
const match = line.match(/^\s*-\s*Name:\s*(.*)$/)
if (match) current.name = match[1]
continue
}
const kv = line.match(/^\s+([A-Za-z][A-Za-z0-9]*):\s*(.*)$/)
if (kv && current) {
if (kv[1] === "Name") current.name = kv[2]
else if (kv[1] === "Path") current.path = kv[2]
}
}
if (current?.name) {
modules.push(current as Module)
}
return modules
}
export function parseBugzillaTracker(text: string): BugzillaTracker {
const lines = text.split(/\r?\n/)
const tracker: Partial<BugzillaTracker> = {}
const mappings: BugzillaTrackerMapping[] = []
let inMappings = false
let current: Partial<BugzillaTrackerMapping> | null = null
for (const line of lines) {
if (!inMappings) {
if (line.startsWith("URL:")) {
tracker.url = line.slice("URL:".length).trim()
} else if (line.startsWith("Mappings:")) {
inMappings = true
}
continue
}
if (/^\s*-\s*(Module:.*)?$/.test(line)) {
if (current?.module && current?.product && current?.component) {
mappings.push(current as BugzillaTrackerMapping)
}
current = {}
const match = line.match(/^\s*-\s*Module:\s*(.*)$/)
if (match) current.module = match[1]
continue
}
const kv = line.match(/^\s+([A-Za-z][A-Za-z0-9]*):\s*(.*)$/)
if (kv && current) {
if (kv[1] === "Module") current.module = kv[2]
else if (kv[1] === "Product") current.product = kv[2]
else if (kv[1] === "Component") current.component = kv[2]
}
}
if (current?.module && current?.product && current?.component) {
mappings.push(current as BugzillaTrackerMapping)
}
tracker.mappings = mappings
return tracker as BugzillaTracker
}