init
This commit is contained in:
commit
932d4ad420
46 changed files with 5800 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
/node_modules/
|
||||
/.npmrc
|
||||
/devel/
|
||||
223
bin/bugzilla.ts
Normal file
223
bin/bugzilla.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
#!/usr/bin/env node
|
||||
// Standalone Bugzilla CLI — interact with Bugzilla REST API directly
|
||||
import yargs from "yargs"
|
||||
import { hideBin } from "yargs/helpers"
|
||||
import { BugzillaClient } from "../lib/bugzilla/client"
|
||||
import { loadConfig } from "../lib/bugzilla/config"
|
||||
|
||||
function getClient() {
|
||||
const config = loadConfig()
|
||||
return new BugzillaClient(config)
|
||||
}
|
||||
|
||||
yargs(hideBin(process.argv))
|
||||
.scriptName("bugzilla")
|
||||
.usage("$0 <command> [options]")
|
||||
|
||||
.command(
|
||||
"get <id>",
|
||||
"Get a bug by ID",
|
||||
(y) => y.positional("id", { type: "number", demandOption: true }),
|
||||
async (argv) => {
|
||||
const client = getClient()
|
||||
const bug = await client.getBug(argv.id as number)
|
||||
console.log(JSON.stringify(bug, null, 2))
|
||||
}
|
||||
)
|
||||
|
||||
.command(
|
||||
"search",
|
||||
"Search for bugs",
|
||||
(y) =>
|
||||
y
|
||||
.option("product", { type: "string", alias: "p" })
|
||||
.option("component", { type: "string", alias: "c" })
|
||||
.option("status", { type: "string", alias: "s" })
|
||||
.option("summary", { type: "string" })
|
||||
.option("limit", { type: "number", default: 50 }),
|
||||
async (argv) => {
|
||||
const client = getClient()
|
||||
const config = loadConfig()
|
||||
const bugs = await client.searchBugs({
|
||||
product: argv.product ?? config.product,
|
||||
component: argv.component ?? config.component,
|
||||
status: argv.status,
|
||||
summary: argv.summary,
|
||||
limit: argv.limit,
|
||||
})
|
||||
for (const bug of bugs) {
|
||||
console.log(`#${bug.id} [${bug.severity}] (${bug.status}) ${bug.summary}`)
|
||||
}
|
||||
console.log(`\n${bugs.length} bug(s) found`)
|
||||
}
|
||||
)
|
||||
|
||||
.command(
|
||||
"create",
|
||||
"Create a new bug",
|
||||
(y) =>
|
||||
y
|
||||
.option("summary", { type: "string", alias: "s", demandOption: true })
|
||||
.option("description", { type: "string", alias: "d" })
|
||||
.option("priority", { type: "string", default: "Normal" })
|
||||
.option("severity", { type: "string", default: "normal" })
|
||||
.option("product", { type: "string", alias: "p" })
|
||||
.option("component", { type: "string", alias: "c" }),
|
||||
async (argv) => {
|
||||
const client = getClient()
|
||||
const config = loadConfig()
|
||||
const id = await client.createBug({
|
||||
product: argv.product ?? config.product,
|
||||
component: argv.component ?? config.component,
|
||||
summary: argv.summary,
|
||||
description: argv.description,
|
||||
priority: argv.priority,
|
||||
severity: argv.severity,
|
||||
})
|
||||
console.log(`Created bug #${id}`)
|
||||
}
|
||||
)
|
||||
|
||||
.command(
|
||||
"update <id>",
|
||||
"Update a bug",
|
||||
(y) =>
|
||||
y
|
||||
.positional("id", { type: "number", demandOption: true })
|
||||
.option("summary", { type: "string", alias: "s" })
|
||||
.option("status", { type: "string" })
|
||||
.option("resolution", { type: "string" })
|
||||
.option("priority", { type: "string" }),
|
||||
async (argv) => {
|
||||
const client = getClient()
|
||||
const payload: Record<string, any> = {}
|
||||
if (argv.summary) payload.summary = argv.summary
|
||||
if (argv.status) payload.status = argv.status
|
||||
if (argv.resolution) payload.resolution = argv.resolution
|
||||
if (argv.priority) payload.priority = argv.priority
|
||||
await client.updateBug(argv.id as number, payload)
|
||||
console.log(`Updated bug #${argv.id}`)
|
||||
}
|
||||
)
|
||||
|
||||
.command(
|
||||
"comments <id>",
|
||||
"List comments on a bug",
|
||||
(y) => y.positional("id", { type: "number", demandOption: true }),
|
||||
async (argv) => {
|
||||
const client = getClient()
|
||||
const comments = await client.getComments(argv.id as number)
|
||||
for (const c of comments) {
|
||||
console.log(`--- Comment #${c.count} by ${c.creator} (${c.time}) ---`)
|
||||
console.log(c.text)
|
||||
console.log()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
.command(
|
||||
"comment <id>",
|
||||
"Add a comment to a bug",
|
||||
(y) =>
|
||||
y
|
||||
.positional("id", { type: "number", demandOption: true })
|
||||
.option("text", { type: "string", alias: "t", demandOption: true }),
|
||||
async (argv) => {
|
||||
const client = getClient()
|
||||
await client.addComment(argv.id as number, argv.text as string)
|
||||
console.log(`Comment added to bug #${argv.id}`)
|
||||
}
|
||||
)
|
||||
|
||||
.command(
|
||||
"products",
|
||||
"List accessible products",
|
||||
() => {},
|
||||
async () => {
|
||||
const client = getClient()
|
||||
const products = await client.getAccessibleProducts()
|
||||
for (const p of products) {
|
||||
const compCount = p.components?.length ?? 0
|
||||
console.log(`#${p.id} ${p.name} (${compCount} components) — ${p.description}`)
|
||||
}
|
||||
console.log(`\n${products.length} product(s)`)
|
||||
}
|
||||
)
|
||||
|
||||
.command(
|
||||
"product <name>",
|
||||
"Get product details and its components",
|
||||
(y) => y.positional("name", { type: "string", demandOption: true }),
|
||||
async (argv) => {
|
||||
const client = getClient()
|
||||
const product = await client.getProduct(argv.name as string)
|
||||
console.log(`Product: ${product.name} (#${product.id})`)
|
||||
console.log(`Description: ${product.description}`)
|
||||
console.log(`Active: ${product.is_active}`)
|
||||
console.log(`\nComponents:`)
|
||||
for (const c of product.components ?? []) {
|
||||
const status = c.is_active ? "active" : "inactive"
|
||||
console.log(` ${c.name} (${status}) — ${c.description}`)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
.command(
|
||||
"create-product",
|
||||
"Create a new product",
|
||||
(y) =>
|
||||
y
|
||||
.option("name", { type: "string", alias: "n", demandOption: true })
|
||||
.option("description", { type: "string", alias: "d", demandOption: true })
|
||||
.option("version", { type: "string", default: "unspecified" }),
|
||||
async (argv) => {
|
||||
const client = getClient()
|
||||
const id = await client.createProduct({
|
||||
name: argv.name,
|
||||
description: argv.description,
|
||||
version: argv.version,
|
||||
})
|
||||
console.log(`Created product #${id}: ${argv.name}`)
|
||||
}
|
||||
)
|
||||
|
||||
.command(
|
||||
"components <product>",
|
||||
"List components for a product",
|
||||
(y) => y.positional("product", { type: "string", demandOption: true }),
|
||||
async (argv) => {
|
||||
const client = getClient()
|
||||
const components = await client.getComponents(argv.product as string)
|
||||
for (const c of components) {
|
||||
const status = c.is_active ? "active" : "inactive"
|
||||
console.log(`#${c.id} ${c.name} (${status}) — ${c.description}`)
|
||||
}
|
||||
console.log(`\n${components.length} component(s)`)
|
||||
}
|
||||
)
|
||||
|
||||
.command(
|
||||
"create-component",
|
||||
"Create a new component",
|
||||
(y) =>
|
||||
y
|
||||
.option("product", { type: "string", alias: "p", demandOption: true })
|
||||
.option("name", { type: "string", alias: "n", demandOption: true })
|
||||
.option("description", { type: "string", alias: "d", demandOption: true })
|
||||
.option("assignee", { type: "string", alias: "a", demandOption: true }),
|
||||
async (argv) => {
|
||||
const client = getClient()
|
||||
const id = await client.createComponent({
|
||||
product: argv.product,
|
||||
name: argv.name,
|
||||
description: argv.description,
|
||||
default_assignee: argv.assignee,
|
||||
})
|
||||
console.log(`Created component #${id}: ${argv.name} (in ${argv.product})`)
|
||||
}
|
||||
)
|
||||
|
||||
.demandCommand(1, "Please specify a command")
|
||||
.strict()
|
||||
.help()
|
||||
.parse()
|
||||
29
bin/main.ts
Normal file
29
bin/main.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
#!/usr/bin/env node
|
||||
// mime-todo CLI — spec-compliant issue lifecycle management
|
||||
import { CLI } from "../lib/cli/CLI"
|
||||
import { CreateCommand } from "../lib/commands/CreateCommand"
|
||||
import { StartCommand } from "../lib/commands/StartCommand"
|
||||
import { DoneCommand } from "../lib/commands/DoneCommand"
|
||||
import { HoldCommand } from "../lib/commands/HoldCommand"
|
||||
import { CancelCommand } from "../lib/commands/CancelCommand"
|
||||
import { IssueListCommand } from "../lib/commands/IssueListCommand"
|
||||
import { IssueShowCommand } from "../lib/commands/IssueShowCommand"
|
||||
import { SprintsCommand } from "../lib/commands/SprintsCommand"
|
||||
import { IssuesInSprintCommand } from "../lib/commands/IssuesInSprintCommand"
|
||||
import { PushCommand } from "../lib/commands/PushCommand"
|
||||
import { InitCommand } from "../lib/commands/InitCommand"
|
||||
|
||||
const cli = new CLI({ prog: "todo", description: "MIME TODO issue tracker" })
|
||||
cli.bootstrap([
|
||||
InitCommand,
|
||||
CreateCommand,
|
||||
StartCommand,
|
||||
DoneCommand,
|
||||
HoldCommand,
|
||||
CancelCommand,
|
||||
IssueListCommand,
|
||||
IssueShowCommand,
|
||||
SprintsCommand,
|
||||
IssuesInSprintCommand,
|
||||
PushCommand,
|
||||
])
|
||||
148
lib/bugzilla/client.ts
Normal file
148
lib/bugzilla/client.ts
Normal 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
43
lib/bugzilla/config.ts
Normal 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
92
lib/bugzilla/fieldmap.ts
Normal 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
7
lib/bugzilla/index.ts
Normal 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
80
lib/bugzilla/origin.ts
Normal 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
130
lib/bugzilla/types.ts
Normal 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
71
lib/cli/CLI.ts
Normal 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
18
lib/cli/CLICommand.ts
Normal 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
|
||||
}
|
||||
}
|
||||
54
lib/commands/CancelCommand.ts
Normal file
54
lib/commands/CancelCommand.ts
Normal 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
|
||||
}
|
||||
}
|
||||
87
lib/commands/CreateCommand.ts
Normal file
87
lib/commands/CreateCommand.ts
Normal 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
|
||||
}
|
||||
}
|
||||
54
lib/commands/DoneCommand.ts
Normal file
54
lib/commands/DoneCommand.ts
Normal 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
|
||||
}
|
||||
}
|
||||
53
lib/commands/HoldCommand.ts
Normal file
53
lib/commands/HoldCommand.ts
Normal 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
157
lib/commands/InitCommand.ts
Normal 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
|
||||
}
|
||||
}
|
||||
17
lib/commands/IssueListCommand.ts
Normal file
17
lib/commands/IssueListCommand.ts
Normal 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
|
||||
}
|
||||
}
|
||||
42
lib/commands/IssueShowCommand.ts
Normal file
42
lib/commands/IssueShowCommand.ts
Normal 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
|
||||
}
|
||||
}
|
||||
33
lib/commands/IssuesInSprintCommand.ts
Normal file
33
lib/commands/IssuesInSprintCommand.ts
Normal 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
394
lib/commands/PushCommand.ts
Normal 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")
|
||||
}
|
||||
}
|
||||
17
lib/commands/SprintsCommand.ts
Normal file
17
lib/commands/SprintsCommand.ts
Normal 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
|
||||
}
|
||||
}
|
||||
60
lib/commands/StartCommand.ts
Normal file
60
lib/commands/StartCommand.ts
Normal 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
144
lib/file.schema.json
Normal 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
199
lib/file.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
// mime-todo/lib/file.ts
|
||||
import * as fs from "fs"
|
||||
|
||||
import { simpleParser } from "mailparser"
|
||||
|
||||
import { parseIssue, Issue } from "./issue"
|
||||
import { parseSprints, Sprint } from "./sprint"
|
||||
import { parseModules, parseBugzillaTracker, Module, BugzillaTracker } from "./tracker"
|
||||
import { serializeTodoFile } from "./serializer"
|
||||
|
||||
import * as schema from "./file.schema.json"
|
||||
|
||||
import Ajv from "ajv"
|
||||
|
||||
const ajv = new Ajv({ allErrors: true })
|
||||
|
||||
const validateFile = ajv.compile(schema)
|
||||
|
||||
export interface TodoFile {
|
||||
sprints: Sprint[]
|
||||
issues: Issue[]
|
||||
modules?: Module[]
|
||||
bugzilla?: BugzillaTracker
|
||||
}
|
||||
|
||||
export async function parseMime(mimeText: string) {
|
||||
return await simpleParser(mimeText)
|
||||
}
|
||||
|
||||
export function preprocessTODO(raw: string): string {
|
||||
const boundary = "ISSUE"
|
||||
|
||||
const rawParts = raw
|
||||
.split(`--${boundary}`)
|
||||
.map(p => p.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
interface Part {
|
||||
type: string
|
||||
body: string
|
||||
}
|
||||
|
||||
const parts: Part[] = []
|
||||
|
||||
for (const rawPart of rawParts) {
|
||||
const lines = rawPart.split(/\r?\n/)
|
||||
const typeLine = lines[0]
|
||||
|
||||
if (!typeLine?.toLowerCase().startsWith("content-type:")) {
|
||||
throw new Error(`Part missing Content-Type header:\n${rawPart}`)
|
||||
}
|
||||
|
||||
const type = typeLine.slice("Content-Type:".length).trim()
|
||||
const body = lines.slice(1).join("\n").trim()
|
||||
|
||||
parts.push({ type, body })
|
||||
}
|
||||
|
||||
// Validate singleton parts
|
||||
for (const ct of ["application/sprints", "application/modules", "application/bugzilla"]) {
|
||||
const count = parts.filter(p => p.type === ct).length
|
||||
if (count > 1) {
|
||||
throw new Error(`Multiple ${ct} parts found`)
|
||||
}
|
||||
}
|
||||
|
||||
const sprintsPart = parts.find(p => p.type === "application/sprints") ?? null
|
||||
const modulesPart = parts.find(p => p.type === "application/modules") ?? null
|
||||
const bugzillaPart = parts.find(p => p.type === "application/bugzilla") ?? null
|
||||
const singletonParts = new Set([sprintsPart, modulesPart, bugzillaPart])
|
||||
|
||||
// Reorder: sprints, modules, bugzilla, then everything else
|
||||
const orderedParts: Part[] = []
|
||||
if (sprintsPart) orderedParts.push(sprintsPart)
|
||||
if (modulesPart) orderedParts.push(modulesPart)
|
||||
if (bugzillaPart) orderedParts.push(bugzillaPart)
|
||||
|
||||
for (const part of parts) {
|
||||
if (!singletonParts.has(part)) orderedParts.push(part)
|
||||
}
|
||||
|
||||
// MIME envelope
|
||||
const out: string[] = []
|
||||
out.push(`MIME-Version: 1.0`)
|
||||
out.push(`Content-Type: multipart/mixed; boundary="${boundary}"`)
|
||||
out.push("")
|
||||
|
||||
for (const part of orderedParts) {
|
||||
out.push(`--${boundary}`)
|
||||
out.push(`Content-Type: ${part.type}`)
|
||||
out.push("")
|
||||
out.push(part.body)
|
||||
out.push("")
|
||||
}
|
||||
|
||||
out.push(`--${boundary}--`)
|
||||
out.push("")
|
||||
|
||||
return out.join("\n")
|
||||
}
|
||||
|
||||
export async function parseTodoFile(path = "TODO"): Promise<TodoFile> {
|
||||
const raw = fs.readFileSync(path, "utf-8")
|
||||
const mimeWrapped = preprocessTODO(raw)
|
||||
const parsed = await parseMime(mimeWrapped)
|
||||
|
||||
const sprints: Sprint[] = []
|
||||
const issues: Issue[] = []
|
||||
let modules: Module[] | undefined
|
||||
let bugzilla: BugzillaTracker | undefined
|
||||
|
||||
const parts =
|
||||
(parsed as any).attachments ??
|
||||
[]
|
||||
|
||||
for (const part of parts) {
|
||||
|
||||
const contentType = String(part.contentType || "").toLowerCase()
|
||||
const body = part.content?.toString("utf-8") ?? ""
|
||||
|
||||
if (contentType.startsWith("application/sprints")) {
|
||||
sprints.push(...parseSprints(body))
|
||||
} else if (contentType.startsWith("application/modules")) {
|
||||
modules = parseModules(body)
|
||||
} else if (contentType.startsWith("application/bugzilla")) {
|
||||
bugzilla = parseBugzillaTracker(body)
|
||||
} else if (contentType.startsWith("application/issue")) {
|
||||
issues.push(parseIssue(body))
|
||||
}
|
||||
}
|
||||
|
||||
const file: TodoFile = { sprints, issues, modules, bugzilla };
|
||||
|
||||
if (!validateFile(file)) {
|
||||
throw new Error(
|
||||
"Schema validation failed: " +
|
||||
JSON.stringify(validateFile.errors, null, 2)
|
||||
)
|
||||
}
|
||||
|
||||
// Validate: issue modules must reference defined modules
|
||||
if (modules) {
|
||||
const validModules = new Set(modules.map(m => m.name))
|
||||
for (const issue of issues) {
|
||||
if (issue.module && !validModules.has(issue.module)) {
|
||||
throw new Error(
|
||||
`Issue #${issue.id} references module "${issue.module}" ` +
|
||||
`which is not defined in application/modules. ` +
|
||||
`Valid modules: ${[...validModules].join(", ")}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate: bugzilla tracker mappings must reference defined modules
|
||||
if (bugzilla && modules) {
|
||||
const validModules = new Set(modules.map(m => m.name))
|
||||
for (const mapping of bugzilla.mappings) {
|
||||
if (!validModules.has(mapping.module)) {
|
||||
throw new Error(
|
||||
`Bugzilla mapping references module "${mapping.module}" ` +
|
||||
`which is not defined in application/modules. ` +
|
||||
`Valid modules: ${[...validModules].join(", ")}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate: relationship targets must reference existing issue IDs
|
||||
const issueIds = new Set(issues.map(i => i.id))
|
||||
const issueMap = new Map(issues.map(i => [i.id, i]))
|
||||
for (const issue of issues) {
|
||||
for (const [kind, targets] of Object.entries(issue.relationships)) {
|
||||
for (const targetId of targets) {
|
||||
if (!issueIds.has(targetId)) {
|
||||
throw new Error(
|
||||
`Issue #${issue.id} has ${kind} relationship to #${targetId}, ` +
|
||||
`but issue #${targetId} does not exist`
|
||||
)
|
||||
}
|
||||
// Warn on stale relationships (target is cancelled)
|
||||
const target = issueMap.get(targetId)
|
||||
if (target?.status === "cancelled") {
|
||||
console.error(
|
||||
`Warning: Issue #${issue.id} has ${kind} relationship to #${targetId}, ` +
|
||||
`but issue #${targetId} is cancelled`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
export function writeTodoFile(todo: TodoFile, path = "TODO"): void {
|
||||
const content = serializeTodoFile(todo)
|
||||
fs.writeFileSync(path, content)
|
||||
}
|
||||
95
lib/git.ts
Normal file
95
lib/git.ts
Normal 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
117
lib/issue.ts
Normal 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
2
lib/out.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
//**/*
|
||||
|
||||
161
lib/serializer.ts
Normal file
161
lib/serializer.ts
Normal 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
1
lib/spec-notes.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Placeholder for spec extension
|
||||
42
lib/sprint.ts
Normal file
42
lib/sprint.ts
Normal 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
99
lib/tracker.ts
Normal 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
|
||||
}
|
||||
2318
package-lock.json
generated
Normal file
2318
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
32
package.json
Normal file
32
package.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"name": "mime-todo",
|
||||
"version": "1.0.0",
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "MIME TODO issue tracker with Bugzilla sync",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"ajv": "^8.17.1",
|
||||
"mailparser": "^3.9.3",
|
||||
"tsx": "^3.7.0",
|
||||
"yargs": "^18.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/yargs": "^17.0.35",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"bin": {
|
||||
"todo": "./bin/main.ts",
|
||||
"bugzilla": "./bin/bugzilla.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"todo": "tsx bin/main.ts",
|
||||
"bugzilla": "tsx bin/bugzilla.ts",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"main": "dist/main.js",
|
||||
"directories": {
|
||||
"lib": "lib",
|
||||
"test": "tests"
|
||||
}
|
||||
}
|
||||
31
tests/_mocks/todo-basic.txt
Normal file
31
tests/_mocks/todo-basic.txt
Normal 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.
|
||||
10
tests/_mocks/todo-issues-only.txt
Normal file
10
tests/_mocks/todo-issues-only.txt
Normal 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.
|
||||
26
tests/_mocks/todo-multiple-sprints.txt
Normal file
26
tests/_mocks/todo-multiple-sprints.txt
Normal 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.
|
||||
1
tests/_mocks/todo-no-sprints.txt
Normal file
1
tests/_mocks/todo-no-sprints.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
Sprints:
|
||||
54
tests/_mocks/todo-with-tracker.txt
Normal file
54
tests/_mocks/todo-with-tracker.txt
Normal 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.
|
||||
87
tests/lib/bugzilla/fieldmap.test.ts
Normal file
87
tests/lib/bugzilla/fieldmap.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
44
tests/lib/bugzilla/origin.test.ts
Normal file
44
tests/lib/bugzilla/origin.test.ts
Normal 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
57
tests/lib/cli.test.ts
Normal 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
55
tests/lib/file.test.ts
Normal 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
57
tests/lib/issue.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
129
tests/lib/serializer.test.ts
Normal file
129
tests/lib/serializer.test.ts
Normal 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
22
tests/lib/sprint.test.ts
Normal 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
205
tests/lib/tracker.test.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue