413 lines
13 KiB
TypeScript
413 lines
13 KiB
TypeScript
import type { Argv, ArgumentsCamelCase } from "yargs"
|
|
import { CLICommand } from "../cli/CLICommand.js"
|
|
import { parseTodoFile } from "../file.js"
|
|
import { BugzillaClient } from "../bugzilla/client.js"
|
|
import { loadConfig } from "../bugzilla/config.js"
|
|
import {
|
|
issueToBugzillaCreate,
|
|
statusToBugzilla,
|
|
resolveProductComponent,
|
|
} from "../bugzilla/fieldmap.js"
|
|
import {
|
|
buildOriginUrl,
|
|
buildCommitUrl,
|
|
shortHash,
|
|
getGitRemoteUrl,
|
|
getGitBranch,
|
|
} from "../bugzilla/origin.js"
|
|
import {
|
|
getCurrentBranch,
|
|
parseIssueBranch,
|
|
getCommitsSinceDiverge,
|
|
getCommitsFromRef,
|
|
parseTodoTransition,
|
|
type GitCommit,
|
|
} from "../git.js"
|
|
|
|
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.js").BugzillaConfig,
|
|
todo: import("../file.js").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: import("../issue.js").Issue) => 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.js").BugzillaConfig,
|
|
todo: import("../file.js").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
|
|
).reverse()
|
|
|
|
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.js").BugzillaConfig,
|
|
todo: import("../file.js").TodoFile,
|
|
remoteUrl: string,
|
|
gitBranch: string,
|
|
issue: import("../issue.js").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}`)
|
|
await this.reconcileBugStatus(client, bugId, issue)
|
|
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.js").BugzillaConfig,
|
|
todo: import("../file.js").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}`)
|
|
await this.reconcileBugStatus(client, bugId, issue)
|
|
} 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)}`])
|
|
}
|
|
|
|
// Reconcile bug status after creation if issue is not open
|
|
private async reconcileBugStatus(
|
|
client: BugzillaClient,
|
|
bugId: number,
|
|
issue: import("../issue.js").Issue
|
|
): Promise<void> {
|
|
if (issue.status === "open") return
|
|
try {
|
|
const bz = statusToBugzilla(issue.status)
|
|
const update: Record<string, string> = { status: bz.status }
|
|
if (bz.resolution) update.resolution = bz.resolution
|
|
await client.updateBug(bugId, update)
|
|
} catch (err: any) {
|
|
console.error(`Error reconciling status for bug #${bugId}: ${err.message}`)
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
}
|