init
This commit is contained in:
commit
932d4ad420
46 changed files with 5800 additions and 0 deletions
54
lib/commands/CancelCommand.ts
Normal file
54
lib/commands/CancelCommand.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import type { Argv, ArgumentsCamelCase } from "yargs"
|
||||
import { CLICommand } from "../cli/CLICommand"
|
||||
import { parseTodoFile, writeTodoFile } from "../file"
|
||||
import { getCurrentBranch, commitFileWithBody } from "../git"
|
||||
import { validateStatusTransition } from "../issue"
|
||||
|
||||
export class CancelCommand extends CLICommand {
|
||||
readonly name = "cancel <id>"
|
||||
readonly help = "Cancel an issue"
|
||||
readonly description = "Set issue to cancelled (must be on issue branch or develop)"
|
||||
|
||||
addArguments(yargs: Argv): Argv {
|
||||
return yargs
|
||||
.positional("id", { type: "number", demandOption: true })
|
||||
.option("reason", {
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
description: "Reason for cancellation",
|
||||
})
|
||||
}
|
||||
|
||||
async execute(args: ArgumentsCamelCase): Promise<number> {
|
||||
const todo = await parseTodoFile()
|
||||
const issue = todo.issues.find(i => i.id === args.id)
|
||||
if (!issue) {
|
||||
console.error(`Issue #${args.id} not found`)
|
||||
return 1
|
||||
}
|
||||
|
||||
// Cancel allowed from develop (if open) or issue branch (if in-progress/hold)
|
||||
const issueBranch = `${issue.type}/${issue.id}`
|
||||
const branch = getCurrentBranch()
|
||||
if (branch !== "develop" && branch !== issueBranch) {
|
||||
console.error(`Must be on develop or ${issueBranch} to cancel (currently on ${branch})`)
|
||||
return 1
|
||||
}
|
||||
|
||||
const err = validateStatusTransition(issue.status, "cancelled")
|
||||
if (err) {
|
||||
console.error(err)
|
||||
return 1
|
||||
}
|
||||
|
||||
issue.status = "cancelled"
|
||||
writeTodoFile(todo)
|
||||
commitFileWithBody(
|
||||
"TODO",
|
||||
`todo(${issue.id}): cancelled`,
|
||||
args.reason as string
|
||||
)
|
||||
console.log(`Issue #${issue.id} is now cancelled`)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
87
lib/commands/CreateCommand.ts
Normal file
87
lib/commands/CreateCommand.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import type { Argv, ArgumentsCamelCase } from "yargs"
|
||||
import { CLICommand } from "../cli/CLICommand"
|
||||
import { parseTodoFile, writeTodoFile } from "../file"
|
||||
import { getCurrentBranch, commitFileWithBody } from "../git"
|
||||
import type { IssueType, IssuePriority } from "../issue"
|
||||
|
||||
export class CreateCommand extends CLICommand {
|
||||
readonly name = "create"
|
||||
readonly help = "Create a new issue"
|
||||
readonly description = "Add an issue to the TODO file (must be on develop)"
|
||||
|
||||
addArguments(yargs: Argv): Argv {
|
||||
return yargs
|
||||
.option("type", {
|
||||
alias: "t",
|
||||
type: "string",
|
||||
choices: ["feature", "bugfix", "hotfix"] as const,
|
||||
demandOption: true,
|
||||
})
|
||||
.option("title", {
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
})
|
||||
.option("priority", {
|
||||
alias: "p",
|
||||
type: "string",
|
||||
choices: ["low", "medium", "high"] as const,
|
||||
default: "medium",
|
||||
})
|
||||
.option("plan", {
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
description: "Description of what needs to be done",
|
||||
})
|
||||
.option("module", {
|
||||
alias: "m",
|
||||
type: "string",
|
||||
})
|
||||
}
|
||||
|
||||
async execute(args: ArgumentsCamelCase): Promise<number> {
|
||||
const branch = getCurrentBranch()
|
||||
if (branch !== "develop") {
|
||||
console.error(`Must be on develop to create issues (currently on ${branch})`)
|
||||
return 1
|
||||
}
|
||||
|
||||
const todo = await parseTodoFile()
|
||||
|
||||
const mod = args.module as string | undefined
|
||||
if (mod && todo.modules) {
|
||||
const valid = todo.modules.map(m => m.name)
|
||||
if (!valid.includes(mod)) {
|
||||
console.error(`Module "${mod}" not defined. Valid: ${valid.join(", ")}`)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
const nextId = todo.issues.length > 0
|
||||
? Math.max(...todo.issues.map(i => i.id)) + 1
|
||||
: 1
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
|
||||
todo.issues.push({
|
||||
id: nextId,
|
||||
type: args.type as IssueType,
|
||||
title: args.title as string,
|
||||
status: "open",
|
||||
priority: args.priority as IssuePriority,
|
||||
created: today,
|
||||
module: mod,
|
||||
relationships: {},
|
||||
description: args.plan as string,
|
||||
body: "",
|
||||
})
|
||||
|
||||
writeTodoFile(todo)
|
||||
commitFileWithBody(
|
||||
"TODO",
|
||||
`todo(${nextId}): open`,
|
||||
args.plan as string
|
||||
)
|
||||
console.log(`Created issue #${nextId}: ${args.title}`)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
54
lib/commands/DoneCommand.ts
Normal file
54
lib/commands/DoneCommand.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import type { Argv, ArgumentsCamelCase } from "yargs"
|
||||
import { CLICommand } from "../cli/CLICommand"
|
||||
import { parseTodoFile, writeTodoFile } from "../file"
|
||||
import { getCurrentBranch, commitFileWithBody } from "../git"
|
||||
import { validateStatusTransition } from "../issue"
|
||||
|
||||
export class DoneCommand extends CLICommand {
|
||||
readonly name = "done <id>"
|
||||
readonly help = "Mark an issue as done"
|
||||
readonly description = "Set issue to done (must be on issue branch)"
|
||||
|
||||
addArguments(yargs: Argv): Argv {
|
||||
return yargs
|
||||
.positional("id", { type: "number", demandOption: true })
|
||||
.option("summary", {
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
description: "High-level summary of what was delivered",
|
||||
})
|
||||
}
|
||||
|
||||
async execute(args: ArgumentsCamelCase): Promise<number> {
|
||||
const todo = await parseTodoFile()
|
||||
const issue = todo.issues.find(i => i.id === args.id)
|
||||
if (!issue) {
|
||||
console.error(`Issue #${args.id} not found`)
|
||||
return 1
|
||||
}
|
||||
|
||||
// Must be on the correct issue branch
|
||||
const expectedBranch = `${issue.type}/${issue.id}`
|
||||
const branch = getCurrentBranch()
|
||||
if (branch !== expectedBranch) {
|
||||
console.error(`Must be on ${expectedBranch} to mark issue as done (currently on ${branch})`)
|
||||
return 1
|
||||
}
|
||||
|
||||
const err = validateStatusTransition(issue.status, "done")
|
||||
if (err) {
|
||||
console.error(err)
|
||||
return 1
|
||||
}
|
||||
|
||||
issue.status = "done"
|
||||
writeTodoFile(todo)
|
||||
commitFileWithBody(
|
||||
"TODO",
|
||||
`todo(${issue.id}): done`,
|
||||
args.summary as string
|
||||
)
|
||||
console.log(`Issue #${issue.id} is now done`)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
53
lib/commands/HoldCommand.ts
Normal file
53
lib/commands/HoldCommand.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import type { Argv, ArgumentsCamelCase } from "yargs"
|
||||
import { CLICommand } from "../cli/CLICommand"
|
||||
import { parseTodoFile, writeTodoFile } from "../file"
|
||||
import { getCurrentBranch, commitFileWithBody } from "../git"
|
||||
import { validateStatusTransition } from "../issue"
|
||||
|
||||
export class HoldCommand extends CLICommand {
|
||||
readonly name = "hold <id>"
|
||||
readonly help = "Put an issue on hold"
|
||||
readonly description = "Set issue to hold (must be on issue branch)"
|
||||
|
||||
addArguments(yargs: Argv): Argv {
|
||||
return yargs
|
||||
.positional("id", { type: "number", demandOption: true })
|
||||
.option("reason", {
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
description: "Reason for holding",
|
||||
})
|
||||
}
|
||||
|
||||
async execute(args: ArgumentsCamelCase): Promise<number> {
|
||||
const todo = await parseTodoFile()
|
||||
const issue = todo.issues.find(i => i.id === args.id)
|
||||
if (!issue) {
|
||||
console.error(`Issue #${args.id} not found`)
|
||||
return 1
|
||||
}
|
||||
|
||||
const expectedBranch = `${issue.type}/${issue.id}`
|
||||
const branch = getCurrentBranch()
|
||||
if (branch !== expectedBranch) {
|
||||
console.error(`Must be on ${expectedBranch} to hold issue (currently on ${branch})`)
|
||||
return 1
|
||||
}
|
||||
|
||||
const err = validateStatusTransition(issue.status, "hold")
|
||||
if (err) {
|
||||
console.error(err)
|
||||
return 1
|
||||
}
|
||||
|
||||
issue.status = "hold"
|
||||
writeTodoFile(todo)
|
||||
commitFileWithBody(
|
||||
"TODO",
|
||||
`todo(${issue.id}): hold`,
|
||||
args.reason as string
|
||||
)
|
||||
console.log(`Issue #${issue.id} is now on hold`)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
157
lib/commands/InitCommand.ts
Normal file
157
lib/commands/InitCommand.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import type { Argv, ArgumentsCamelCase } from "yargs"
|
||||
import { CLICommand } from "../cli/CLICommand"
|
||||
import { parseTodoFile } from "../file"
|
||||
import { BugzillaClient } from "../bugzilla/client"
|
||||
import { loadConfig } from "../bugzilla/config"
|
||||
|
||||
interface ProductState {
|
||||
name: string
|
||||
exists: boolean
|
||||
components: {
|
||||
name: string
|
||||
exists: boolean
|
||||
}[]
|
||||
}
|
||||
|
||||
export class InitCommand extends CLICommand {
|
||||
readonly name = "init"
|
||||
readonly help = "Initialize Bugzilla products and components"
|
||||
readonly description = "Check and optionally create Bugzilla products/components from TODO tracker config"
|
||||
|
||||
addArguments(yargs: Argv): Argv {
|
||||
return yargs
|
||||
.option("dry-run", {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
description: "Show what would be created without creating",
|
||||
})
|
||||
.option("confirm", {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
description: "Actually create missing products/components (required for writes)",
|
||||
})
|
||||
.option("assignee", {
|
||||
type: "string",
|
||||
description: "Default assignee email for new components",
|
||||
})
|
||||
}
|
||||
|
||||
async execute(args: ArgumentsCamelCase): Promise<number> {
|
||||
const todo = await parseTodoFile()
|
||||
|
||||
if (!todo.bugzilla) {
|
||||
console.error("No application/bugzilla part found in TODO file")
|
||||
return 1
|
||||
}
|
||||
|
||||
const config = loadConfig()
|
||||
const client = new BugzillaClient(config)
|
||||
const dryRun = args.dryRun as boolean
|
||||
const confirm = args.confirm as boolean
|
||||
const assignee = args.assignee as string | undefined
|
||||
|
||||
// Collect unique products and their components from the mappings
|
||||
const productMap = new Map<string, Set<string>>()
|
||||
for (const m of todo.bugzilla.mappings) {
|
||||
if (!productMap.has(m.product)) productMap.set(m.product, new Set())
|
||||
productMap.get(m.product)!.add(m.component)
|
||||
}
|
||||
|
||||
// Check what exists on the server
|
||||
const states: ProductState[] = []
|
||||
const existingProducts = await client.getAccessibleProducts()
|
||||
const existingByName = new Map(existingProducts.map(p => [p.name, p]))
|
||||
|
||||
for (const [productName, componentNames] of productMap) {
|
||||
const existing = existingByName.get(productName)
|
||||
const existingComponents = new Set(
|
||||
existing?.components?.map(c => c.name) ?? []
|
||||
)
|
||||
|
||||
states.push({
|
||||
name: productName,
|
||||
exists: !!existing,
|
||||
components: [...componentNames].map(name => ({
|
||||
name,
|
||||
exists: existingComponents.has(name),
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
// Report
|
||||
let allOk = true
|
||||
for (const product of states) {
|
||||
const pStatus = product.exists ? "ok" : "MISSING"
|
||||
console.log(`Product: ${product.name} [${pStatus}]`)
|
||||
for (const comp of product.components) {
|
||||
const cStatus = comp.exists ? "ok" : "MISSING"
|
||||
console.log(` Component: ${comp.name} [${cStatus}]`)
|
||||
if (!comp.exists) allOk = false
|
||||
}
|
||||
if (!product.exists) allOk = false
|
||||
}
|
||||
|
||||
if (allOk) {
|
||||
console.log("\nAll products and components exist. Nothing to do.")
|
||||
return 0
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
console.log("\n[dry-run] Would create the MISSING items above.")
|
||||
return 0
|
||||
}
|
||||
|
||||
if (!confirm) {
|
||||
console.log("\nMissing items found. Run with --confirm to create them.")
|
||||
return 1
|
||||
}
|
||||
|
||||
// Need assignee for component creation
|
||||
const needsComponents = states.some(p =>
|
||||
p.components.some(c => !c.exists)
|
||||
)
|
||||
if (needsComponents && !assignee) {
|
||||
console.error("\n--assignee is required when creating components")
|
||||
return 1
|
||||
}
|
||||
|
||||
// Create missing products and components
|
||||
let created = 0
|
||||
for (const product of states) {
|
||||
if (!product.exists) {
|
||||
try {
|
||||
const id = await client.createProduct({
|
||||
name: product.name,
|
||||
description: product.name,
|
||||
version: "unspecified",
|
||||
})
|
||||
console.log(`Created product #${id}: ${product.name}`)
|
||||
created++
|
||||
} catch (err: any) {
|
||||
console.error(`Error creating product "${product.name}": ${err.message}`)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
for (const comp of product.components) {
|
||||
if (!comp.exists) {
|
||||
try {
|
||||
const id = await client.createComponent({
|
||||
product: product.name,
|
||||
name: comp.name,
|
||||
description: comp.name,
|
||||
default_assignee: assignee!,
|
||||
})
|
||||
console.log(`Created component #${id}: ${comp.name} (in ${product.name})`)
|
||||
created++
|
||||
} catch (err: any) {
|
||||
console.error(`Error creating component "${comp.name}": ${err.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nInit complete: ${created} items created`)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
17
lib/commands/IssueListCommand.ts
Normal file
17
lib/commands/IssueListCommand.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type { ArgumentsCamelCase } from "yargs"
|
||||
import { CLICommand } from "../cli/CLICommand"
|
||||
import { parseTodoFile } from "../file"
|
||||
|
||||
export class IssueListCommand extends CLICommand {
|
||||
readonly name = "list"
|
||||
readonly help = "List all issues"
|
||||
readonly description = "List all issues in the TODO file"
|
||||
|
||||
async execute(args: ArgumentsCamelCase): Promise<number> {
|
||||
const todo = await parseTodoFile()
|
||||
for (const issue of todo.issues) {
|
||||
console.log(`#${issue.id} [${issue.type}] (${issue.status}) ${issue.title}`)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
42
lib/commands/IssueShowCommand.ts
Normal file
42
lib/commands/IssueShowCommand.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import type { Argv, ArgumentsCamelCase } from "yargs"
|
||||
import { CLICommand } from "../cli/CLICommand"
|
||||
import { parseTodoFile } from "../file"
|
||||
|
||||
export class IssueShowCommand extends CLICommand {
|
||||
readonly name = "show <id>"
|
||||
readonly help = "Show details for a single issue"
|
||||
readonly description = "Print all fields for one issue"
|
||||
|
||||
addArguments(yargs: Argv): Argv {
|
||||
return yargs.positional("id", { type: "number", demandOption: true })
|
||||
}
|
||||
|
||||
async execute(args: ArgumentsCamelCase): Promise<number> {
|
||||
const todo = await parseTodoFile()
|
||||
const issue = todo.issues.find(i => i.id === args.id)
|
||||
if (!issue) {
|
||||
console.error(`Issue #${args.id} not found`)
|
||||
return 1
|
||||
}
|
||||
|
||||
console.log(`ID: ${issue.id}`)
|
||||
console.log(`Type: ${issue.type}`)
|
||||
console.log(`Title: ${issue.title}`)
|
||||
console.log(`Status: ${issue.status}`)
|
||||
console.log(`Priority: ${issue.priority}`)
|
||||
console.log(`Created: ${issue.created}`)
|
||||
if (issue.module) console.log(`Module: ${issue.module}`)
|
||||
if (issue.dueStart) console.log(`DueStart: ${issue.dueStart}`)
|
||||
if (issue.dueEnd) console.log(`DueEnd: ${issue.dueEnd}`)
|
||||
const rels = Object.entries(issue.relationships)
|
||||
.map(([k, v]) => `${k}:${(v as number[]).join(" ")}`)
|
||||
.join(", ")
|
||||
console.log(`Relationships: ${rels}`)
|
||||
console.log(`Description: ${issue.description}`)
|
||||
if (issue.body) {
|
||||
console.log()
|
||||
console.log(issue.body)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
33
lib/commands/IssuesInSprintCommand.ts
Normal file
33
lib/commands/IssuesInSprintCommand.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import type { Argv, ArgumentsCamelCase } from "yargs"
|
||||
import { CLICommand } from "../cli/CLICommand"
|
||||
import { parseTodoFile } from "../file"
|
||||
|
||||
export class IssuesInSprintCommand extends CLICommand {
|
||||
readonly name = "issues-in-sprint <name>"
|
||||
readonly help = "List issues in a sprint"
|
||||
readonly description = "Find issues whose date range overlaps with a sprint"
|
||||
|
||||
addArguments(yargs: Argv): Argv {
|
||||
return yargs.positional("name", { type: "string", demandOption: true })
|
||||
}
|
||||
|
||||
async execute(args: ArgumentsCamelCase): Promise<number> {
|
||||
const todo = await parseTodoFile()
|
||||
const sprint = todo.sprints.find(s => s.name === args.name)
|
||||
if (!sprint) {
|
||||
console.error(`Sprint not found: ${args.name}`)
|
||||
return 1
|
||||
}
|
||||
|
||||
const { start, end } = sprint
|
||||
for (const issue of todo.issues) {
|
||||
const ds = issue.dueStart ?? issue.dueEnd
|
||||
const de = issue.dueEnd ?? issue.dueStart
|
||||
if (!ds || !de) continue
|
||||
if (ds <= end && de >= start) {
|
||||
console.log(`#${issue.id} [${issue.type}] (${issue.status}) ${issue.title}`)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
394
lib/commands/PushCommand.ts
Normal file
394
lib/commands/PushCommand.ts
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
import type { Argv, ArgumentsCamelCase } from "yargs"
|
||||
import { CLICommand } from "../cli/CLICommand"
|
||||
import { parseTodoFile } from "../file"
|
||||
import { BugzillaClient } from "../bugzilla/client"
|
||||
import { loadConfig } from "../bugzilla/config"
|
||||
import {
|
||||
issueToBugzillaCreate,
|
||||
statusToBugzilla,
|
||||
resolveProductComponent,
|
||||
} from "../bugzilla/fieldmap"
|
||||
import {
|
||||
buildOriginUrl,
|
||||
buildCommitUrl,
|
||||
shortHash,
|
||||
getGitRemoteUrl,
|
||||
getGitBranch,
|
||||
} from "../bugzilla/origin"
|
||||
import {
|
||||
getCurrentBranch,
|
||||
parseIssueBranch,
|
||||
getCommitsSinceDiverge,
|
||||
getCommitsFromRef,
|
||||
parseTodoTransition,
|
||||
type GitCommit,
|
||||
} from "../git"
|
||||
|
||||
type Strategy = "smart" | "full"
|
||||
|
||||
export class PushCommand extends CLICommand {
|
||||
readonly name = "push [ref]"
|
||||
readonly help = "Push commits to Bugzilla"
|
||||
readonly description = "Push git commits as Bugzilla comments, transitions as status updates"
|
||||
|
||||
addArguments(yargs: Argv): Argv {
|
||||
return yargs
|
||||
.positional("ref", {
|
||||
type: "string",
|
||||
description: "Git ref specifier (e.g. HEAD~3) — issue branches only",
|
||||
})
|
||||
.option("dry-run", {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
description: "Show what would be pushed without pushing",
|
||||
})
|
||||
.option("strategy", {
|
||||
type: "string",
|
||||
choices: ["smart", "full"] as const,
|
||||
default: "smart",
|
||||
description: "Fetch strategy: smart (only relevant bugs) or full (all bugs)",
|
||||
})
|
||||
}
|
||||
|
||||
async execute(args: ArgumentsCamelCase): Promise<number> {
|
||||
const branch = getCurrentBranch()
|
||||
const issueBranch = parseIssueBranch(branch)
|
||||
const isDevelop = branch === "develop"
|
||||
|
||||
if (!isDevelop && !issueBranch) {
|
||||
console.error(`Must be on develop or an issue branch (currently on ${branch})`)
|
||||
return 1
|
||||
}
|
||||
|
||||
const todo = await parseTodoFile()
|
||||
const config = loadConfig()
|
||||
const client = new BugzillaClient(config)
|
||||
const remoteUrl = getGitRemoteUrl()
|
||||
// Origin URL always uses develop — that's where the TODO lives canonically
|
||||
const gitBranch = "develop"
|
||||
const strategy = args.strategy as Strategy
|
||||
const dryRun = args.dryRun as boolean
|
||||
|
||||
if (issueBranch) {
|
||||
return this.pushFromIssueBranch(
|
||||
client, config, todo, remoteUrl, gitBranch,
|
||||
issueBranch, args.ref as string | undefined, strategy, dryRun
|
||||
)
|
||||
} else {
|
||||
return this.pushFromDevelop(
|
||||
client, config, todo, remoteUrl, gitBranch,
|
||||
args.ref as string | undefined, strategy, dryRun
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Push work comments from an issue branch
|
||||
private async pushFromIssueBranch(
|
||||
client: BugzillaClient,
|
||||
config: import("../bugzilla/config").BugzillaConfig,
|
||||
todo: import("../file").TodoFile,
|
||||
remoteUrl: string,
|
||||
gitBranch: string,
|
||||
issueBranch: { type: string; id: number },
|
||||
ref: string | undefined,
|
||||
strategy: Strategy,
|
||||
dryRun: boolean
|
||||
): Promise<number> {
|
||||
const issue = todo.issues.find(i => i.id === issueBranch.id)
|
||||
if (!issue) {
|
||||
console.error(`Issue #${issueBranch.id} not found in TODO`)
|
||||
return 1
|
||||
}
|
||||
|
||||
// Issue must be in-progress to push work comments
|
||||
if (issue.status !== "in-progress") {
|
||||
console.error(
|
||||
`Issue #${issue.id} is "${issue.status}" — work comments can only be pushed when in-progress`
|
||||
)
|
||||
return 1
|
||||
}
|
||||
|
||||
// Resolve the bug
|
||||
const bugId = await this.findOrCreateBug(
|
||||
client, config, todo, remoteUrl, gitBranch, issue, strategy, dryRun
|
||||
)
|
||||
if (!bugId) return 1
|
||||
|
||||
// Check Bugzilla comments for a done transition — no work after done
|
||||
const existingComments = await this.fetchCommentMeta(client, bugId)
|
||||
if (existingComments.hasDone) {
|
||||
console.error(
|
||||
`Bug #${bugId} already has a [done] transition — no further work comments allowed`
|
||||
)
|
||||
return 1
|
||||
}
|
||||
|
||||
// Get commits to push
|
||||
const commits = ref
|
||||
? getCommitsFromRef(`${ref}..HEAD`)
|
||||
: getCommitsSinceDiverge("develop")
|
||||
|
||||
if (commits.length === 0) {
|
||||
console.log("No commits to push")
|
||||
return 0
|
||||
}
|
||||
|
||||
let pushed = 0
|
||||
for (const commit of commits) {
|
||||
if (existingComments.hashes.has(shortHash(commit.hash))) continue
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`[dry-run] bug #${bugId} ← ${shortHash(commit.hash)} ${commit.subject}`)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const commitUrl = buildCommitUrl(remoteUrl, commit.hash)
|
||||
const commentId = await this.postComment(client, bugId, commit, commitUrl)
|
||||
await this.tagAsPushed(client, commentId, commit.hash)
|
||||
pushed++
|
||||
console.log(`bug #${bugId} ← ${shortHash(commit.hash)} ${commit.subject}`)
|
||||
} catch (err: any) {
|
||||
console.error(`Error pushing ${shortHash(commit.hash)}: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Push complete: ${pushed} comments`)
|
||||
return 0
|
||||
}
|
||||
|
||||
// Push transitions from develop
|
||||
private async pushFromDevelop(
|
||||
client: BugzillaClient,
|
||||
config: import("../bugzilla/config").BugzillaConfig,
|
||||
todo: import("../file").TodoFile,
|
||||
remoteUrl: string,
|
||||
gitBranch: string,
|
||||
ref: string | undefined,
|
||||
strategy: Strategy,
|
||||
dryRun: boolean
|
||||
): Promise<number> {
|
||||
// Get transition commits
|
||||
let allCommits: GitCommit[]
|
||||
if (ref) {
|
||||
allCommits = getCommitsFromRef(`${ref}..HEAD`)
|
||||
} else {
|
||||
allCommits = getCommitsFromRef("HEAD")
|
||||
}
|
||||
const relevantCommits = allCommits.filter(c =>
|
||||
parseTodoTransition(c.subject) !== null
|
||||
)
|
||||
|
||||
if (relevantCommits.length === 0) {
|
||||
console.log("No transitions to push")
|
||||
return 0
|
||||
}
|
||||
|
||||
// Resolve all bugs
|
||||
const bugMap = await this.resolveAllBugs(
|
||||
client, config, todo, remoteUrl, gitBranch, strategy, dryRun
|
||||
)
|
||||
|
||||
let pushed = 0
|
||||
let transitioned = 0
|
||||
|
||||
for (const commit of relevantCommits) {
|
||||
const transition = parseTodoTransition(commit.subject)
|
||||
if (!transition) continue
|
||||
|
||||
const bugId = bugMap.get(transition.issueId)
|
||||
if (!bugId) continue
|
||||
|
||||
// Idempotency check
|
||||
const meta = await this.fetchCommentMeta(client, bugId)
|
||||
if (meta.hashes.has(shortHash(commit.hash))) continue
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`[dry-run] bug #${bugId} ← ${shortHash(commit.hash)} ${commit.subject}`)
|
||||
console.log(`[dry-run] → status: ${transition.status}`)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const commitUrl = buildCommitUrl(remoteUrl, commit.hash)
|
||||
const commentId = await this.postComment(client, bugId, commit, commitUrl)
|
||||
|
||||
// Update status, then tag — tag only after everything succeeds
|
||||
const bz = statusToBugzilla(transition.status as any)
|
||||
const payload: any = { status: bz.status }
|
||||
if (bz.resolution) payload.resolution = bz.resolution
|
||||
await client.updateBug(bugId, payload)
|
||||
|
||||
await this.tagAsPushed(client, commentId, commit.hash)
|
||||
pushed++
|
||||
transitioned++
|
||||
console.log(`bug #${bugId} ← ${shortHash(commit.hash)} [${transition.status}]`)
|
||||
} catch (err: any) {
|
||||
console.error(`Error pushing ${shortHash(commit.hash)} to bug #${bugId}: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Push complete: ${pushed} comments, ${transitioned} transitions`)
|
||||
return 0
|
||||
}
|
||||
|
||||
// Find a bug for a specific issue, or create it
|
||||
private async findOrCreateBug(
|
||||
client: BugzillaClient,
|
||||
config: import("../bugzilla/config").BugzillaConfig,
|
||||
todo: import("../file").TodoFile,
|
||||
remoteUrl: string,
|
||||
gitBranch: string,
|
||||
issue: import("../issue").Issue,
|
||||
strategy: Strategy,
|
||||
dryRun: boolean
|
||||
): Promise<number | null> {
|
||||
// Try to find existing bug by URL
|
||||
const originUrl = buildOriginUrl(remoteUrl, gitBranch, "TODO", issue.id)
|
||||
|
||||
if (strategy === "smart") {
|
||||
const bugs = await client.searchBugs({ url: originUrl })
|
||||
if (bugs.length > 0) return bugs[0].id
|
||||
}
|
||||
|
||||
// Fallback: search all bugs in product/component
|
||||
const searchTargets = todo.bugzilla
|
||||
? todo.bugzilla.mappings.map(m => ({ product: m.product, component: m.component }))
|
||||
: [{ product: config.product, component: config.component }]
|
||||
|
||||
for (const target of searchTargets) {
|
||||
if (!target.product) continue
|
||||
const bugs = await client.searchBugs(target)
|
||||
for (const bug of bugs) {
|
||||
if (bug.url === originUrl) return bug.id
|
||||
}
|
||||
}
|
||||
|
||||
// Not found — create it
|
||||
if (dryRun) {
|
||||
console.log(`[dry-run] Would create bug for issue #${issue.id}: ${issue.title}`)
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const resolved = resolveProductComponent(issue, todo.bugzilla)
|
||||
const product = resolved?.product ?? config.product
|
||||
const component = resolved?.component ?? config.component
|
||||
const payload = issueToBugzillaCreate(issue, product, component, originUrl)
|
||||
const bugId = await client.createBug(payload)
|
||||
console.log(`Created bug #${bugId} for issue #${issue.id}`)
|
||||
return bugId
|
||||
} catch (err: any) {
|
||||
console.error(`Error creating bug for issue #${issue.id}: ${err.message}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve bugs for all issues in the TODO
|
||||
private async resolveAllBugs(
|
||||
client: BugzillaClient,
|
||||
config: import("../bugzilla/config").BugzillaConfig,
|
||||
todo: import("../file").TodoFile,
|
||||
remoteUrl: string,
|
||||
gitBranch: string,
|
||||
strategy: Strategy,
|
||||
dryRun: boolean
|
||||
): Promise<Map<number, number>> {
|
||||
const bugMap = new Map<number, number>()
|
||||
|
||||
const searchTargets = todo.bugzilla
|
||||
? todo.bugzilla.mappings.map(m => ({ product: m.product, component: m.component }))
|
||||
: [{ product: config.product, component: config.component }]
|
||||
|
||||
const seen = new Set<number>()
|
||||
for (const target of searchTargets) {
|
||||
if (!target.product) continue
|
||||
const bugs = await client.searchBugs(target)
|
||||
for (const bug of bugs) {
|
||||
if (seen.has(bug.id)) continue
|
||||
seen.add(bug.id)
|
||||
if (bug.url) {
|
||||
const match = bug.url.match(/#(\d+)$/)
|
||||
if (match) bugMap.set(Number(match[1]), bug.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create bugs for issues that don't have one yet
|
||||
for (const issue of todo.issues) {
|
||||
if (bugMap.has(issue.id)) continue
|
||||
if (dryRun) {
|
||||
console.log(`[dry-run] Would create bug for issue #${issue.id}: ${issue.title}`)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const resolved = resolveProductComponent(issue, todo.bugzilla)
|
||||
const product = resolved?.product ?? config.product
|
||||
const component = resolved?.component ?? config.component
|
||||
const url = buildOriginUrl(remoteUrl, gitBranch, "TODO", issue.id)
|
||||
const payload = issueToBugzillaCreate(issue, product, component, url)
|
||||
const bugId = await client.createBug(payload)
|
||||
bugMap.set(issue.id, bugId)
|
||||
console.log(`Created bug #${bugId} for issue #${issue.id}`)
|
||||
} catch (err: any) {
|
||||
console.error(`Error creating bug for issue #${issue.id}: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
return bugMap
|
||||
}
|
||||
|
||||
// Fetch comment metadata: which commit hashes are already tagged + whether done exists
|
||||
private async fetchCommentMeta(
|
||||
client: BugzillaClient,
|
||||
bugId: number
|
||||
): Promise<{ hashes: Set<string>; hasDone: boolean }> {
|
||||
const hashes = new Set<string>()
|
||||
let hasDone = false
|
||||
try {
|
||||
const comments = await client.getComments(bugId)
|
||||
for (const c of comments) {
|
||||
for (const tag of c.tags ?? []) {
|
||||
if (tag.startsWith("git-")) {
|
||||
hashes.add(tag.slice(4)) // short hash without "git-" prefix
|
||||
}
|
||||
}
|
||||
if (/^\*\*todo\(\d+\): done\*\*$/m.test(c.text)) hasDone = true
|
||||
}
|
||||
} catch { /* non-fatal */ }
|
||||
return { hashes, hasDone }
|
||||
}
|
||||
|
||||
// Post a comment, returns the comment ID for later tagging
|
||||
private async postComment(
|
||||
client: BugzillaClient,
|
||||
bugId: number,
|
||||
commit: GitCommit,
|
||||
commitUrl: string
|
||||
): Promise<number> {
|
||||
const comment = this.formatComment(commit, commitUrl)
|
||||
return await client.addComment(bugId, comment)
|
||||
}
|
||||
|
||||
// Tag a comment as pushed — call only after all side effects succeed
|
||||
private async tagAsPushed(
|
||||
client: BugzillaClient,
|
||||
commentId: number,
|
||||
commitHash: string
|
||||
): Promise<void> {
|
||||
await client.tagComment(commentId, [`git-${shortHash(commitHash)}`])
|
||||
}
|
||||
|
||||
// Format a commit as a markdown Bugzilla comment
|
||||
private formatComment(commit: GitCommit, commitUrl: string): string {
|
||||
const lines: string[] = []
|
||||
lines.push(`**${commit.subject}**`)
|
||||
if (commit.body) {
|
||||
lines.push("")
|
||||
lines.push(commit.body)
|
||||
}
|
||||
lines.push("")
|
||||
lines.push(`[\`${shortHash(commit.hash)}\`](${commitUrl})`)
|
||||
return lines.join("\n")
|
||||
}
|
||||
}
|
||||
17
lib/commands/SprintsCommand.ts
Normal file
17
lib/commands/SprintsCommand.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type { ArgumentsCamelCase } from "yargs"
|
||||
import { CLICommand } from "../cli/CLICommand"
|
||||
import { parseTodoFile } from "../file"
|
||||
|
||||
export class SprintsCommand extends CLICommand {
|
||||
readonly name = "sprints"
|
||||
readonly help = "List all sprints"
|
||||
readonly description = "List all sprints in the TODO file"
|
||||
|
||||
async execute(args: ArgumentsCamelCase): Promise<number> {
|
||||
const todo = await parseTodoFile()
|
||||
for (const sprint of todo.sprints) {
|
||||
console.log(`${sprint.name}: ${sprint.start}..${sprint.end}`)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
60
lib/commands/StartCommand.ts
Normal file
60
lib/commands/StartCommand.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import type { Argv, ArgumentsCamelCase } from "yargs"
|
||||
import { CLICommand } from "../cli/CLICommand"
|
||||
import { parseTodoFile, writeTodoFile } from "../file"
|
||||
import { getCurrentBranch, branchExists, commitFileWithBody } from "../git"
|
||||
import { validateStatusTransition } from "../issue"
|
||||
|
||||
export class StartCommand extends CLICommand {
|
||||
readonly name = "start <id>"
|
||||
readonly help = "Start work on an issue"
|
||||
readonly description = "Set issue to in-progress (must be on develop)"
|
||||
|
||||
addArguments(yargs: Argv): Argv {
|
||||
return yargs
|
||||
.positional("id", { type: "number", demandOption: true })
|
||||
.option("plan", {
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
description: "High-level description of planned approach",
|
||||
})
|
||||
}
|
||||
|
||||
async execute(args: ArgumentsCamelCase): Promise<number> {
|
||||
const branch = getCurrentBranch()
|
||||
if (branch !== "develop") {
|
||||
console.error(`Must be on develop to start an issue (currently on ${branch})`)
|
||||
return 1
|
||||
}
|
||||
|
||||
const todo = await parseTodoFile()
|
||||
const issue = todo.issues.find(i => i.id === args.id)
|
||||
if (!issue) {
|
||||
console.error(`Issue #${args.id} not found`)
|
||||
return 1
|
||||
}
|
||||
|
||||
const err = validateStatusTransition(issue.status, "in-progress")
|
||||
if (err) {
|
||||
console.error(err)
|
||||
return 1
|
||||
}
|
||||
|
||||
// Check that the issue branch doesn't already exist
|
||||
const issueBranch = `${issue.type}/${issue.id}`
|
||||
if (branchExists(issueBranch)) {
|
||||
console.error(`Branch ${issueBranch} already exists`)
|
||||
return 1
|
||||
}
|
||||
|
||||
issue.status = "in-progress"
|
||||
writeTodoFile(todo)
|
||||
commitFileWithBody(
|
||||
"TODO",
|
||||
`todo(${issue.id}): in-progress`,
|
||||
args.plan as string
|
||||
)
|
||||
console.log(`Issue #${issue.id} is now in-progress`)
|
||||
console.log(`Create the issue branch: git checkout -b ${issueBranch}`)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue