commit 932d4ad42060f6be6a5aed2058e187ad6964bf08 Author: Tiara Rodney Date: Sun Mar 15 03:02:41 2026 +0100 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d0ce78b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/node_modules/ +/.npmrc +/devel/ diff --git a/bin/bugzilla.ts b/bin/bugzilla.ts new file mode 100644 index 0000000..b926325 --- /dev/null +++ b/bin/bugzilla.ts @@ -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 [options]") + + .command( + "get ", + "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 ", + "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 = {} + 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 ", + "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 ", + "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 ", + "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 ", + "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() diff --git a/bin/main.ts b/bin/main.ts new file mode 100644 index 0000000..d7c43b9 --- /dev/null +++ b/bin/main.ts @@ -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, +]) diff --git a/lib/bugzilla/client.ts b/lib/bugzilla/client.ts new file mode 100644 index 0000000..19f3fcb --- /dev/null +++ b/lib/bugzilla/client.ts @@ -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(method: string, path: string, body?: unknown): Promise { + // 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 + 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 + } + + async getBug(id: number): Promise { + const data = await this.request("GET", `/bug/${id}`) + if (!data.bugs?.length) throw new Error(`Bug ${id} not found`) + return data.bugs[0] + } + + async searchBugs(params: BugzillaSearchParams): Promise { + 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("GET", path) + return data.bugs ?? [] + } + + async createBug(payload: BugzillaCreatePayload): Promise { + const data = await this.request("POST", "/bug", payload) + return data.id + } + + async updateBug(id: number, payload: BugzillaUpdatePayload): Promise { + return this.request("PUT", `/bug/${id}`, payload) + } + + async getComments(bugId: number): Promise { + const data = await this.request("GET", `/bug/${bugId}/comment`) + return data.bugs?.[String(bugId)]?.comments ?? [] + } + + async addComment(bugId: number, comment: string): Promise { + const data = await this.request<{ id: number }>("POST", `/bug/${bugId}/comment`, { comment }) + return data.id + } + + async tagComment(commentId: number, tags: string[]): Promise { + await this.request("PUT", `/bug/comment/${commentId}/tags`, { add: tags }) + } + + // Product operations + + async getProduct(nameOrId: string | number): Promise { + const data = await this.request( + "GET", + `/product/${encodeURIComponent(String(nameOrId))}` + ) + if (!data.products?.length) throw new Error(`Product "${nameOrId}" not found`) + return data.products[0] + } + + async getAccessibleProducts(): Promise { + const data = await this.request("GET", "/product?type=accessible") + return data.products ?? [] + } + + async createProduct(payload: BugzillaCreateProductPayload): Promise { + const data = await this.request<{ id: number }>("POST", "/product", payload) + return data.id + } + + // Component operations + + async getComponents(product: string): Promise { + const prod = await this.getProduct(product) + return prod.components ?? [] + } + + async createComponent(payload: BugzillaCreateComponentPayload): Promise { + const data = await this.request<{ id: number }>("POST", "/component", payload) + return data.id + } +} diff --git a/lib/bugzilla/config.ts b/lib/bugzilla/config.ts new file mode 100644 index 0000000..818b4d8 --- /dev/null +++ b/lib/bugzilla/config.ts @@ -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 ?? "", + } +} diff --git a/lib/bugzilla/fieldmap.ts b/lib/bugzilla/fieldmap.ts new file mode 100644 index 0000000..6c9e0d5 --- /dev/null +++ b/lib/bugzilla/fieldmap.ts @@ -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 = { + "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 = { + "low": "Low", + "medium": "Normal", + "high": "Highest", +} + +// Type → Severity mapping +const TYPE_TO_SEVERITY: Record = { + "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 +} + diff --git a/lib/bugzilla/index.ts b/lib/bugzilla/index.ts new file mode 100644 index 0000000..77fe99a --- /dev/null +++ b/lib/bugzilla/index.ts @@ -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" diff --git a/lib/bugzilla/origin.ts b/lib/bugzilla/origin.ts new file mode 100644 index 0000000..93d5b9e --- /dev/null +++ b/lib/bugzilla/origin.ts @@ -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: /src//# +// GitHub: /blob//# +// GitLab: /-/blob//# + +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) +} diff --git a/lib/bugzilla/types.ts b/lib/bugzilla/types.ts new file mode 100644 index 0000000..a767955 --- /dev/null +++ b/lib/bugzilla/types.ts @@ -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 + }> +} + +export interface BugzillaCommentsResponse { + bugs: Record +} + +// 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 +} diff --git a/lib/cli/CLI.ts b/lib/cli/CLI.ts new file mode 100644 index 0000000..0fd85de --- /dev/null +++ b/lib/cli/CLI.ts @@ -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 [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() + } +} diff --git a/lib/cli/CLICommand.ts b/lib/cli/CLICommand.ts new file mode 100644 index 0000000..b2a3489 --- /dev/null +++ b/lib/cli/CLICommand.ts @@ -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 { + return 0 + } +} diff --git a/lib/commands/CancelCommand.ts b/lib/commands/CancelCommand.ts new file mode 100644 index 0000000..0caa31a --- /dev/null +++ b/lib/commands/CancelCommand.ts @@ -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 " + 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 { + 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 + } +} diff --git a/lib/commands/CreateCommand.ts b/lib/commands/CreateCommand.ts new file mode 100644 index 0000000..0fab8bd --- /dev/null +++ b/lib/commands/CreateCommand.ts @@ -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 { + 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 + } +} diff --git a/lib/commands/DoneCommand.ts b/lib/commands/DoneCommand.ts new file mode 100644 index 0000000..2237907 --- /dev/null +++ b/lib/commands/DoneCommand.ts @@ -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 " + 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 { + 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 + } +} diff --git a/lib/commands/HoldCommand.ts b/lib/commands/HoldCommand.ts new file mode 100644 index 0000000..37b2399 --- /dev/null +++ b/lib/commands/HoldCommand.ts @@ -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 " + 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 { + 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 + } +} diff --git a/lib/commands/InitCommand.ts b/lib/commands/InitCommand.ts new file mode 100644 index 0000000..53155a6 --- /dev/null +++ b/lib/commands/InitCommand.ts @@ -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 { + 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>() + 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 + } +} diff --git a/lib/commands/IssueListCommand.ts b/lib/commands/IssueListCommand.ts new file mode 100644 index 0000000..866a3fa --- /dev/null +++ b/lib/commands/IssueListCommand.ts @@ -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 { + const todo = await parseTodoFile() + for (const issue of todo.issues) { + console.log(`#${issue.id} [${issue.type}] (${issue.status}) ${issue.title}`) + } + return 0 + } +} diff --git a/lib/commands/IssueShowCommand.ts b/lib/commands/IssueShowCommand.ts new file mode 100644 index 0000000..e7d0c1c --- /dev/null +++ b/lib/commands/IssueShowCommand.ts @@ -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 " + 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 { + 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 + } +} diff --git a/lib/commands/IssuesInSprintCommand.ts b/lib/commands/IssuesInSprintCommand.ts new file mode 100644 index 0000000..6f3fcef --- /dev/null +++ b/lib/commands/IssuesInSprintCommand.ts @@ -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 " + 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 { + 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 + } +} diff --git a/lib/commands/PushCommand.ts b/lib/commands/PushCommand.ts new file mode 100644 index 0000000..52e24ae --- /dev/null +++ b/lib/commands/PushCommand.ts @@ -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 { + 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 { + 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 { + // 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 { + // 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> { + const bugMap = new Map() + + 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() + 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; hasDone: boolean }> { + const hashes = new Set() + 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 { + 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 { + 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") + } +} diff --git a/lib/commands/SprintsCommand.ts b/lib/commands/SprintsCommand.ts new file mode 100644 index 0000000..bc50da2 --- /dev/null +++ b/lib/commands/SprintsCommand.ts @@ -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 { + const todo = await parseTodoFile() + for (const sprint of todo.sprints) { + console.log(`${sprint.name}: ${sprint.start}..${sprint.end}`) + } + return 0 + } +} diff --git a/lib/commands/StartCommand.ts b/lib/commands/StartCommand.ts new file mode 100644 index 0000000..52ae526 --- /dev/null +++ b/lib/commands/StartCommand.ts @@ -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 " + 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 { + 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 + } +} diff --git a/lib/file.schema.json b/lib/file.schema.json new file mode 100644 index 0000000..b07c8f9 --- /dev/null +++ b/lib/file.schema.json @@ -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 + } + } +} diff --git a/lib/file.ts b/lib/file.ts new file mode 100644 index 0000000..a777bfc --- /dev/null +++ b/lib/file.ts @@ -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 { + 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) +} diff --git a/lib/git.ts b/lib/git.ts new file mode 100644 index 0000000..cc20f8b --- /dev/null +++ b/lib/git.ts @@ -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] } +} diff --git a/lib/issue.ts b/lib/issue.ts new file mode 100644 index 0000000..725ff25 --- /dev/null +++ b/lib/issue.ts @@ -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 = { + "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 = {} + + 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 +} diff --git a/lib/out.txt b/lib/out.txt new file mode 100644 index 0000000..2a39fb1 --- /dev/null +++ b/lib/out.txt @@ -0,0 +1,2 @@ +//**/* + diff --git a/lib/serializer.ts b/lib/serializer.ts new file mode 100644 index 0000000..cdc1cba --- /dev/null +++ b/lib/serializer.ts @@ -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" +} diff --git a/lib/spec-notes.md b/lib/spec-notes.md new file mode 100644 index 0000000..f476b85 --- /dev/null +++ b/lib/spec-notes.md @@ -0,0 +1 @@ +# Placeholder for spec extension diff --git a/lib/sprint.ts b/lib/sprint.ts new file mode 100644 index 0000000..ecc3f28 --- /dev/null +++ b/lib/sprint.ts @@ -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 | 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 +} diff --git a/lib/tracker.ts b/lib/tracker.ts new file mode 100644 index 0000000..aadb25e --- /dev/null +++ b/lib/tracker.ts @@ -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 | 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 = {} + const mappings: BugzillaTrackerMapping[] = [] + + let inMappings = false + let current: Partial | 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 +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5882ad2 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2318 @@ +{ + "name": "mime-todo", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mime-todo", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@types/yargs": "^17.0.35", + "ajv": "^8.17.1", + "mailparser": "^3.9.3", + "tsx": "^3.7.0", + "yargs": "^18.0.0" + }, + "devDependencies": { + "vitest": "^4.0.18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@selderee/plugin-htmlparser2": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", + "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==", + "dependencies": { + "domhandler": "^5.0.3", + "selderee": "^0.11.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==" + }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@zone-eu/mailsplit": { + "version": "5.4.8", + "resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz", + "integrity": "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==", + "dependencies": { + "libbase64": "1.3.0", + "libmime": "5.3.7", + "libqp": "2.1.1" + } + }, + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ] + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==" + }, + "node_modules/encoding-japanese": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz", + "integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==", + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.3", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.3.tgz", + "integrity": "sha512-vp8Cj/+9Q/ibZUrq1rhy8mCTQpCk31A3uu9wc1C50yAb3x2pFHOsGdAZQ7jD86ARayyxZUViYeIztW+GE8dcrg==", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "bin": { + "he": "bin/he" + } + }, + "node_modules/html-to-text": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", + "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==", + "dependencies": { + "@selderee/plugin-htmlparser2": "^0.11.0", + "deepmerge": "^4.3.1", + "dom-serializer": "^2.0.0", + "htmlparser2": "^8.0.2", + "selderee": "^0.11.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "node_modules/leac": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", + "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "node_modules/libbase64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz", + "integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==" + }, + "node_modules/libmime": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz", + "integrity": "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==", + "dependencies": { + "encoding-japanese": "2.2.0", + "iconv-lite": "0.6.3", + "libbase64": "1.3.0", + "libqp": "2.1.1" + } + }, + "node_modules/libmime/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/libqp": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz", + "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==" + }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mailparser": { + "version": "3.9.3", + "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.3.tgz", + "integrity": "sha512-AnB0a3zROum6fLaa52L+/K2SoRJVyFDk78Ea6q1D0ofcZLxWEWDtsS1+OrVqKbV7r5dulKL/AwYQccFGAPpuYQ==", + "dependencies": { + "@zone-eu/mailsplit": "5.4.8", + "encoding-japanese": "2.2.0", + "he": "1.2.0", + "html-to-text": "9.0.5", + "iconv-lite": "0.7.2", + "libmime": "5.3.7", + "linkify-it": "5.0.0", + "nodemailer": "7.0.13", + "punycode.js": "2.3.1", + "tlds": "1.261.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/nodemailer": { + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", + "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ] + }, + "node_modules/parseley": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", + "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==", + "dependencies": { + "leac": "^0.6.0", + "peberminta": "^0.9.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true + }, + "node_modules/peberminta": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", + "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/selderee": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", + "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==", + "dependencies": { + "parseley": "^0.12.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tlds": { + "version": "1.261.0", + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz", + "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==", + "bin": { + "tlds": "bin.js" + } + }, + "node_modules/tsx": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-3.14.0.tgz", + "integrity": "sha512-xHtFaKtHxM9LOklMmJdI3BEnQq/D5F73Of2E1GDrITi9sgoVkvIsrQUTY1G8FlmGtA+awCI4EBlTRRYxkL2sRg==", + "dependencies": { + "esbuild": "~0.18.20", + "get-tsconfig": "^4.7.2", + "source-map-support": "^0.5.21" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==" + }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/vitest/node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e0f8319 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/tests/_mocks/todo-basic.txt b/tests/_mocks/todo-basic.txt new file mode 100644 index 0000000..b64d9c0 --- /dev/null +++ b/tests/_mocks/todo-basic.txt @@ -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. diff --git a/tests/_mocks/todo-issues-only.txt b/tests/_mocks/todo-issues-only.txt new file mode 100644 index 0000000..025cfea --- /dev/null +++ b/tests/_mocks/todo-issues-only.txt @@ -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. diff --git a/tests/_mocks/todo-multiple-sprints.txt b/tests/_mocks/todo-multiple-sprints.txt new file mode 100644 index 0000000..c63eba7 --- /dev/null +++ b/tests/_mocks/todo-multiple-sprints.txt @@ -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. diff --git a/tests/_mocks/todo-no-sprints.txt b/tests/_mocks/todo-no-sprints.txt new file mode 100644 index 0000000..f8adab7 --- /dev/null +++ b/tests/_mocks/todo-no-sprints.txt @@ -0,0 +1 @@ +Sprints: \ No newline at end of file diff --git a/tests/_mocks/todo-with-tracker.txt b/tests/_mocks/todo-with-tracker.txt new file mode 100644 index 0000000..c915e58 --- /dev/null +++ b/tests/_mocks/todo-with-tracker.txt @@ -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. diff --git a/tests/lib/bugzilla/fieldmap.test.ts b/tests/lib/bugzilla/fieldmap.test.ts new file mode 100644 index 0000000..f612857 --- /dev/null +++ b/tests/lib/bugzilla/fieldmap.test.ts @@ -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() + }) +}) diff --git a/tests/lib/bugzilla/origin.test.ts b/tests/lib/bugzilla/origin.test.ts new file mode 100644 index 0000000..73f4b08 --- /dev/null +++ b/tests/lib/bugzilla/origin.test.ts @@ -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/", () => { + 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/", () => { + 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/", () => { + expect(buildOriginUrl("https://gitlab.com/org/repo", "develop", "TODO", 5)) + .toBe("https://gitlab.com/org/repo/-/blob/develop/TODO#5") + }) +}) diff --git a/tests/lib/cli.test.ts b/tests/lib/cli.test.ts new file mode 100644 index 0000000..b8fc1dd --- /dev/null +++ b/tests/lib/cli.test.ts @@ -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 { + 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() + } + }) +}) diff --git a/tests/lib/file.test.ts b/tests/lib/file.test.ts new file mode 100644 index 0000000..47270e8 --- /dev/null +++ b/tests/lib/file.test.ts @@ -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") + }) +}) diff --git a/tests/lib/issue.test.ts b/tests/lib/issue.test.ts new file mode 100644 index 0000000..1d1788b --- /dev/null +++ b/tests/lib/issue.test.ts @@ -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() + }) +}) diff --git a/tests/lib/serializer.test.ts b/tests/lib/serializer.test.ts new file mode 100644 index 0000000..c7ec184 --- /dev/null +++ b/tests/lib/serializer.test.ts @@ -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) + } + }) +}) diff --git a/tests/lib/sprint.test.ts b/tests/lib/sprint.test.ts new file mode 100644 index 0000000..d09c688 --- /dev/null +++ b/tests/lib/sprint.test.ts @@ -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) + }) +}) diff --git a/tests/lib/tracker.test.ts b/tests/lib/tracker.test.ts new file mode 100644 index 0000000..fff47ac --- /dev/null +++ b/tests/lib/tracker.test.ts @@ -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) + } + }) +})