This commit is contained in:
Tiara Rodney 2026-03-15 03:02:41 +01:00
commit 932d4ad420
No known key found for this signature in database
GPG key ID: 5CD8EC1D46106723
46 changed files with 5800 additions and 0 deletions

148
lib/bugzilla/client.ts Normal file
View file

@ -0,0 +1,148 @@
// Bugzilla 5.0+ REST API client — thin wrapper around fetch
import type { BugzillaConfig } from "./config"
import type {
BugzillaBug,
BugzillaComment,
BugzillaProduct,
BugzillaComponent,
BugzillaSearchParams,
BugzillaCreatePayload,
BugzillaUpdatePayload,
BugzillaCreateProductPayload,
BugzillaCreateComponentPayload,
BugzillaSearchResponse,
BugzillaCreateResponse,
BugzillaUpdateResponse,
BugzillaCommentsResponse,
BugzillaProductResponse,
} from "./types"
export class BugzillaClient {
private baseUrl: string
private apiKey: string
constructor(config: BugzillaConfig) {
this.baseUrl = config.baseUrl.replace(/\/+$/, "")
this.apiKey = config.apiKey
}
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
// Auth: api_key in query for GET, in body for POST/PUT
let url: string
if (method === "GET") {
const sep = path.includes("?") ? "&" : "?"
url = `${this.baseUrl}${path}${sep}api_key=${encodeURIComponent(this.apiKey)}`
} else {
url = `${this.baseUrl}${path}`
const bodyObj = (body ?? {}) as Record<string, unknown>
body = { ...bodyObj, api_key: this.apiKey }
}
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
})
if (!res.ok) {
const text = await res.text()
throw new Error(`Bugzilla API ${method} ${path} failed (${res.status}): ${text}`)
}
return res.json() as Promise<T>
}
async getBug(id: number): Promise<BugzillaBug> {
const data = await this.request<BugzillaSearchResponse>("GET", `/bug/${id}`)
if (!data.bugs?.length) throw new Error(`Bug ${id} not found`)
return data.bugs[0]
}
async searchBugs(params: BugzillaSearchParams): Promise<BugzillaBug[]> {
const query = new URLSearchParams()
if (params.product) query.set("product", params.product)
if (params.component) query.set("component", params.component)
if (params.summary) query.set("summary", params.summary)
if (params.limit) query.set("limit", String(params.limit))
if (params.offset) query.set("offset", String(params.offset))
if (params.last_change_time) query.set("last_change_time", params.last_change_time)
if (params.status) {
const statuses = Array.isArray(params.status) ? params.status : [params.status]
for (const s of statuses) query.append("status", s)
}
if (params.id) {
const ids = Array.isArray(params.id) ? params.id : [params.id]
for (const id of ids) query.append("id", String(id))
}
if (params.alias) {
const aliases = Array.isArray(params.alias) ? params.alias : [params.alias]
for (const a of aliases) query.append("alias", a)
}
if (params.url) query.set("url", params.url)
const qs = query.toString()
const path = `/bug${qs ? `?${qs}` : ""}`
const data = await this.request<BugzillaSearchResponse>("GET", path)
return data.bugs ?? []
}
async createBug(payload: BugzillaCreatePayload): Promise<number> {
const data = await this.request<BugzillaCreateResponse>("POST", "/bug", payload)
return data.id
}
async updateBug(id: number, payload: BugzillaUpdatePayload): Promise<BugzillaUpdateResponse> {
return this.request<BugzillaUpdateResponse>("PUT", `/bug/${id}`, payload)
}
async getComments(bugId: number): Promise<BugzillaComment[]> {
const data = await this.request<BugzillaCommentsResponse>("GET", `/bug/${bugId}/comment`)
return data.bugs?.[String(bugId)]?.comments ?? []
}
async addComment(bugId: number, comment: string): Promise<number> {
const data = await this.request<{ id: number }>("POST", `/bug/${bugId}/comment`, { comment })
return data.id
}
async tagComment(commentId: number, tags: string[]): Promise<void> {
await this.request("PUT", `/bug/comment/${commentId}/tags`, { add: tags })
}
// Product operations
async getProduct(nameOrId: string | number): Promise<BugzillaProduct> {
const data = await this.request<BugzillaProductResponse>(
"GET",
`/product/${encodeURIComponent(String(nameOrId))}`
)
if (!data.products?.length) throw new Error(`Product "${nameOrId}" not found`)
return data.products[0]
}
async getAccessibleProducts(): Promise<BugzillaProduct[]> {
const data = await this.request<BugzillaProductResponse>("GET", "/product?type=accessible")
return data.products ?? []
}
async createProduct(payload: BugzillaCreateProductPayload): Promise<number> {
const data = await this.request<{ id: number }>("POST", "/product", payload)
return data.id
}
// Component operations
async getComponents(product: string): Promise<BugzillaComponent[]> {
const prod = await this.getProduct(product)
return prod.components ?? []
}
async createComponent(payload: BugzillaCreateComponentPayload): Promise<number> {
const data = await this.request<{ id: number }>("POST", "/component", payload)
return data.id
}
}

43
lib/bugzilla/config.ts Normal file
View file

@ -0,0 +1,43 @@
// Bugzilla configuration — loads from .bugzilla.json or environment variables
import * as fs from "fs"
import * as path from "path"
export interface BugzillaConfig {
baseUrl: string
apiKey: string
product: string
component: string
}
const CONFIG_FILE = ".bugzilla.json"
export function loadConfig(cwd = process.cwd()): BugzillaConfig {
const filePath = path.join(cwd, CONFIG_FILE)
if (fs.existsSync(filePath)) {
const raw = fs.readFileSync(filePath, "utf-8")
const json = JSON.parse(raw)
return {
baseUrl: json.baseUrl ?? json.base_url ?? "",
apiKey: json.apiKey ?? json.api_key,
product: json.product ?? "",
component: json.component ?? "",
}
}
const baseUrl = process.env.BUGZILLA_URL
const apiKey = process.env.BUGZILLA_API_KEY
if (!baseUrl || !apiKey) {
throw new Error(
`Bugzilla config not found. Create ${CONFIG_FILE} or set BUGZILLA_URL and BUGZILLA_API_KEY`
)
}
return {
baseUrl: baseUrl.replace(/\/+$/, ""),
apiKey,
product: process.env.BUGZILLA_PRODUCT ?? "",
component: process.env.BUGZILLA_COMPONENT ?? "",
}
}

92
lib/bugzilla/fieldmap.ts Normal file
View file

@ -0,0 +1,92 @@
// Field mapping between MIME TODO issues and Bugzilla bugs
import type { Issue, IssueType, IssueStatus, IssuePriority } from "../issue"
import type { BugzillaCreatePayload, BugzillaUpdatePayload } from "./types"
// Status mapping: TODO → Bugzilla
const STATUS_TO_BZ: Record<IssueStatus, { status: string; resolution?: string }> = {
"open": { status: "CONFIRMED" },
"in-progress": { status: "IN_PROGRESS" },
"done": { status: "RESOLVED", resolution: "FIXED" },
"hold": { status: "RESOLVED", resolution: "LATER" },
"cancelled": { status: "RESOLVED", resolution: "WONTFIX" },
}
// Priority mapping
const PRIORITY_TO_BZ: Record<IssuePriority, string> = {
"low": "Low",
"medium": "Normal",
"high": "Highest",
}
// Type → Severity mapping
const TYPE_TO_SEVERITY: Record<IssueType, string> = {
"feature": "enhancement",
"bugfix": "normal",
"hotfix": "critical",
}
export function statusToBugzilla(status: IssueStatus): { status: string; resolution?: string } {
return STATUS_TO_BZ[status] ?? { status: "NEW" }
}
export function priorityToBugzilla(priority: IssuePriority): string {
return PRIORITY_TO_BZ[priority] ?? "Normal"
}
export function typeToBugzilla(type: IssueType): string {
return TYPE_TO_SEVERITY[type] ?? "normal"
}
export function resolveProductComponent(
issue: Issue,
bugzilla?: import("../tracker").BugzillaTracker
): { product: string; component: string } | null {
if (!bugzilla) return null
if (issue.module) {
const mapping = bugzilla.mappings.find(m => m.module === issue.module)
if (mapping) return { product: mapping.product, component: mapping.component }
}
// Fall back to first mapping
return bugzilla.mappings.length > 0
? { product: bugzilla.mappings[0].product, component: bugzilla.mappings[0].component }
: null
}
export function issueToBugzillaCreate(
issue: Issue,
product: string,
component: string,
url?: string
): BugzillaCreatePayload {
const bz = statusToBugzilla(issue.status)
return {
product,
component,
version: "unspecified",
summary: issue.title,
url,
description: issue.description || undefined,
priority: priorityToBugzilla(issue.priority),
severity: typeToBugzilla(issue.type),
op_sys: "All",
rep_platform: "All",
depends_on: issue.relationships.dependsOn ?? [],
blocks: issue.relationships.blocks ?? [],
see_also: (issue.relationships.relatesTo ?? []).map(String),
}
}
export function issueToBugzillaUpdate(issue: Issue): BugzillaUpdatePayload {
const bz = statusToBugzilla(issue.status)
const payload: BugzillaUpdatePayload = {
summary: issue.title,
status: bz.status,
priority: priorityToBugzilla(issue.priority),
severity: typeToBugzilla(issue.type),
depends_on: { set: issue.relationships.dependsOn ?? [] },
blocks: { set: issue.relationships.blocks ?? [] },
}
if (bz.resolution) payload.resolution = bz.resolution
return payload
}

7
lib/bugzilla/index.ts Normal file
View file

@ -0,0 +1,7 @@
// Re-export all Bugzilla modules for convenient imports
export { BugzillaClient } from "./client"
export { loadConfig } from "./config"
export type { BugzillaConfig } from "./config"
export * from "./types"
export * from "./fieldmap"
export * from "./origin"

80
lib/bugzilla/origin.ts Normal file
View file

@ -0,0 +1,80 @@
// Origin URL — builds a resolvable URL pointing to a TODO issue in the repo's web UI
// Format varies by host:
// Bitbucket: <base>/src/<branch>/<path>#<id>
// GitHub: <base>/blob/<branch>/<path>#<id>
// GitLab: <base>/-/blob/<branch>/<path>#<id>
import { execSync } from "child_process"
export function getGitRemoteUrl(cwd = process.cwd()): string {
try {
return execSync("git remote get-url origin", { cwd, encoding: "utf-8" }).trim()
} catch {
throw new Error("Could not determine git remote URL. Is this a git repository with an 'origin' remote?")
}
}
export function getGitBranch(cwd = process.cwd()): string {
try {
return execSync("git rev-parse --abbrev-ref HEAD", { cwd, encoding: "utf-8" }).trim()
} catch {
return "main"
}
}
export function normalizeRemoteUrl(url: string): string {
let normalized = url
const sshMatch = normalized.match(/^git@([^:]+):(.+?)(?:\.git)?$/)
if (sshMatch) {
normalized = `https://${sshMatch[1]}/${sshMatch[2]}`
}
normalized = normalized.replace(/\.git$/, "")
normalized = normalized.replace(/\/+$/, "")
return normalized
}
function detectHost(url: string): "bitbucket" | "github" | "gitlab" | "unknown" {
if (url.includes("bitbucket.org")) return "bitbucket"
if (url.includes("github.com")) return "github"
if (url.includes("gitlab.com") || url.includes("gitlab")) return "gitlab"
return "unknown"
}
export function buildOriginUrl(
remoteUrl: string,
branch: string,
todoPath: string,
issueId: number
): string {
const base = normalizeRemoteUrl(remoteUrl)
const host = detectHost(base)
switch (host) {
case "github":
return `${base}/blob/${branch}/${todoPath}#${issueId}`
case "gitlab":
return `${base}/-/blob/${branch}/${todoPath}#${issueId}`
case "bitbucket":
default:
return `${base}/src/${branch}/${todoPath}#${issueId}`
}
}
export function buildCommitUrl(remoteUrl: string, commitHash: string): string {
const base = normalizeRemoteUrl(remoteUrl)
const host = detectHost(base)
switch (host) {
case "github":
return `${base}/commit/${commitHash}`
case "gitlab":
return `${base}/-/commit/${commitHash}`
case "bitbucket":
default:
return `${base}/commits/${commitHash}`
}
}
export function shortHash(hash: string): string {
return hash.slice(0, 7)
}

130
lib/bugzilla/types.ts Normal file
View file

@ -0,0 +1,130 @@
// Bugzilla 5.0+ REST API type definitions
export interface BugzillaBug {
id: number
alias: string[]
summary: string
status: string
resolution: string
priority: string
severity: string
product: string
component: string
description?: string // comment 0 text (not always in bug response)
creation_time: string
last_change_time: string
depends_on: number[]
blocks: number[]
url: string
see_also: string[]
assigned_to: string
creator: string
is_open: boolean
}
export interface BugzillaComment {
id: number
bug_id: number
text: string
creator: string
time: string
count: number
is_private: boolean
tags: string[]
}
export interface BugzillaSearchParams {
product?: string
component?: string
status?: string | string[]
id?: number | number[]
alias?: string | string[]
url?: string
summary?: string
limit?: number
offset?: number
last_change_time?: string
}
export interface BugzillaCreatePayload {
product: string
component: string
summary: string
version?: string
url?: string
description?: string
status?: string
priority?: string
severity?: string
op_sys?: string
rep_platform?: string
depends_on?: number[]
blocks?: number[]
see_also?: string[]
}
export interface BugzillaUpdatePayload {
summary?: string
status?: string
resolution?: string
priority?: string
severity?: string
depends_on?: { set: number[] }
blocks?: { set: number[] }
see_also?: { add?: string[]; remove?: string[] }
}
export interface BugzillaSearchResponse {
bugs: BugzillaBug[]
}
export interface BugzillaCreateResponse {
id: number
}
export interface BugzillaUpdateResponse {
bugs: Array<{
id: number
changes: Record<string, { removed: string; added: string }>
}>
}
export interface BugzillaCommentsResponse {
bugs: Record<string, { comments: BugzillaComment[] }>
}
// Product and component types
export interface BugzillaComponent {
id: number
name: string
description: string
default_assigned_to: string
is_active: boolean
}
export interface BugzillaProduct {
id: number
name: string
description: string
is_active: boolean
components: BugzillaComponent[]
}
export interface BugzillaProductResponse {
products: BugzillaProduct[]
}
export interface BugzillaCreateProductPayload {
name: string
description: string
version: string
is_open?: boolean
}
export interface BugzillaCreateComponentPayload {
product: string
name: string
description: string
default_assignee: string
}