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") } }