From 8da02400349d35a0ffec7156a15931b4486a10af Mon Sep 17 00:00:00 2001 From: Nicolas Medda Date: Mon, 27 Apr 2026 16:12:02 +0200 Subject: [PATCH] BBC2-21 add bb pipeline list List recent pipeline runs for the current repo. - `bb pipeline list` shows build number, status, branch, commit, trigger, duration, creator, and relative time - Filterable by branch (-b) and status (-s: pending, running, success, failed, stopped, error) - Pagination via -L/--limit (default 30) - Handles all Bitbucket pipeline state variants: pending, in-progress (running/paused), completed (success/failed/stopped/error) - Extracts trigger type, truncates commit hash to 12 chars, computes duration from timestamps - Backend with 15 tests covering all states, filters, field mapping, duration computation, error handling --- src/backend/pipelines/index.test.ts | 313 ++++++++++++++++++++++++++++ src/backend/pipelines/index.ts | 164 +++++++++++++++ src/commands/pipeline/index.ts | 25 +++ src/commands/pipeline/list.ts | 102 +++++++++ src/index.ts | 2 + 5 files changed, 606 insertions(+) create mode 100644 src/backend/pipelines/index.test.ts create mode 100644 src/backend/pipelines/index.ts create mode 100644 src/commands/pipeline/index.ts create mode 100644 src/commands/pipeline/list.ts diff --git a/src/backend/pipelines/index.test.ts b/src/backend/pipelines/index.test.ts new file mode 100644 index 0000000..57201ab --- /dev/null +++ b/src/backend/pipelines/index.test.ts @@ -0,0 +1,313 @@ +import { describe, expect, test } from "bun:test"; +import { HttpResponse, http } from "msw"; +import { BITBUCKET_BASE, server, setupMsw } from "../../test/msw/server.ts"; +import { listPipelines, type Pipeline, PipelineError } from "./index.ts"; + +setupMsw(); + +const creds = { email: "a@b.co", token: "t" }; +const ref = { workspace: "ws", slug: "repo" }; + +const PIPELINES_PATH = `${BITBUCKET_BASE}/repositories/ws/repo/pipelines`; + +function makePipeline( + overrides: Record = {}, +): Record { + return { + type: "pipeline", + uuid: "{pipe-uuid}", + build_number: 42, + creator: { display_name: "Alice", nickname: "alice" }, + target: { + type: "pipeline_ref_target", + ref_type: "branch", + ref_name: "main", + commit: { type: "commit", hash: "abc123def456789" }, + }, + trigger: { type: "pipeline_trigger_push" }, + state: { + type: "pipeline_state_completed", + name: "COMPLETED", + result: { + type: "pipeline_state_completed_successful", + name: "SUCCESSFUL", + }, + }, + created_on: "2026-04-20T10:00:00Z", + completed_on: "2026-04-20T10:03:00Z", + build_seconds_used: 180, + ...overrides, + }; +} + +describe("listPipelines", () => { + test("default query: sort=-created_on, pagelen=50", async () => { + const calls: URLSearchParams[] = []; + server.use( + http.get(PIPELINES_PATH, ({ request }) => { + calls.push(new URL(request.url).searchParams); + return HttpResponse.json({ values: [makePipeline()] }); + }), + ); + + const result = await listPipelines(creds, ref, { limit: 30 }); + + expect(calls).toHaveLength(1); + expect(calls[0]?.get("sort")).toBe("-created_on"); + expect(calls[0]?.get("pagelen")).toBe("50"); + expect(result).toHaveLength(1); + expect(result[0]?.buildNumber).toBe(42); + }); + + test("maps completed/successful pipeline to full shape", async () => { + server.use( + http.get(PIPELINES_PATH, () => + HttpResponse.json({ values: [makePipeline()] }), + ), + ); + + const result = await listPipelines(creds, ref, { limit: 10 }); + + expect(result[0]).toEqual({ + buildNumber: 42, + status: "success", + branch: "main", + commitHash: "abc123def456", + trigger: "push", + creator: "Alice", + createdOn: "2026-04-20T10:00:00Z", + durationSeconds: 180, + }); + }); + + test("maps pending state", async () => { + server.use( + http.get(PIPELINES_PATH, () => + HttpResponse.json({ + values: [ + makePipeline({ + state: { type: "pipeline_state_pending", name: "PENDING" }, + completed_on: undefined, + }), + ], + }), + ), + ); + + const result = await listPipelines(creds, ref, { limit: 10 }); + expect(result[0]?.status).toBe("pending"); + expect(result[0]?.durationSeconds).toBeNull(); + }); + + test("maps in-progress/running state", async () => { + server.use( + http.get(PIPELINES_PATH, () => + HttpResponse.json({ + values: [ + makePipeline({ + state: { + type: "pipeline_state_in_progress", + name: "IN_PROGRESS", + stage: { name: "RUNNING" }, + }, + completed_on: undefined, + }), + ], + }), + ), + ); + + const result = await listPipelines(creds, ref, { limit: 10 }); + expect(result[0]?.status).toBe("running"); + }); + + test("maps in-progress/paused state", async () => { + server.use( + http.get(PIPELINES_PATH, () => + HttpResponse.json({ + values: [ + makePipeline({ + state: { + type: "pipeline_state_in_progress", + name: "IN_PROGRESS", + stage: { name: "PAUSED" }, + }, + }), + ], + }), + ), + ); + + const result = await listPipelines(creds, ref, { limit: 10 }); + expect(result[0]?.status).toBe("paused"); + }); + + test("maps completed/failed state", async () => { + server.use( + http.get(PIPELINES_PATH, () => + HttpResponse.json({ + values: [ + makePipeline({ + state: { + type: "pipeline_state_completed", + name: "COMPLETED", + result: { name: "FAILED" }, + }, + }), + ], + }), + ), + ); + + const result = await listPipelines(creds, ref, { limit: 10 }); + expect(result[0]?.status).toBe("failed"); + }); + + test("maps completed/stopped state", async () => { + server.use( + http.get(PIPELINES_PATH, () => + HttpResponse.json({ + values: [ + makePipeline({ + state: { + type: "pipeline_state_completed", + name: "COMPLETED", + result: { name: "STOPPED" }, + }, + }), + ], + }), + ), + ); + + const result = await listPipelines(creds, ref, { limit: 10 }); + expect(result[0]?.status).toBe("stopped"); + }); + + test("maps completed/error state", async () => { + server.use( + http.get(PIPELINES_PATH, () => + HttpResponse.json({ + values: [ + makePipeline({ + state: { + type: "pipeline_state_completed", + name: "COMPLETED", + result: { name: "ERROR" }, + }, + }), + ], + }), + ), + ); + + const result = await listPipelines(creds, ref, { limit: 10 }); + expect(result[0]?.status).toBe("error"); + }); + + test("branch filter sends target.branch query param", async () => { + const calls: URLSearchParams[] = []; + server.use( + http.get(PIPELINES_PATH, ({ request }) => { + calls.push(new URL(request.url).searchParams); + return HttpResponse.json({ values: [] }); + }), + ); + + await listPipelines(creds, ref, { limit: 30, branch: "develop" }); + + expect(calls[0]?.get("target.branch")).toBe("develop"); + }); + + test("status filter maps user-friendly name to API value", async () => { + const calls: URLSearchParams[] = []; + server.use( + http.get(PIPELINES_PATH, ({ request }) => { + calls.push(new URL(request.url).searchParams); + return HttpResponse.json({ values: [] }); + }), + ); + + await listPipelines(creds, ref, { limit: 30, status: "failed" }); + + expect(calls[0]?.get("status")).toBe("FAILED"); + }); + + test("extracts trigger type from pipeline_trigger_ prefix", async () => { + server.use( + http.get(PIPELINES_PATH, () => + HttpResponse.json({ + values: [ + makePipeline({ trigger: { type: "pipeline_trigger_manual" } }), + ], + }), + ), + ); + + const result = await listPipelines(creds, ref, { limit: 10 }); + expect(result[0]?.trigger).toBe("manual"); + }); + + test("truncates commit hash to 12 chars", async () => { + server.use( + http.get(PIPELINES_PATH, () => + HttpResponse.json({ + values: [ + makePipeline({ + target: { + ref_name: "main", + commit: { hash: "abcdef1234567890abcdef1234567890abcdef12" }, + }, + }), + ], + }), + ), + ); + + const result = await listPipelines(creds, ref, { limit: 10 }); + expect(result[0]?.commitHash).toBe("abcdef123456"); + }); + + test("computes duration from created_on and completed_on", async () => { + server.use( + http.get(PIPELINES_PATH, () => + HttpResponse.json({ + values: [ + makePipeline({ + created_on: "2026-04-20T10:00:00Z", + completed_on: "2026-04-20T10:02:30Z", + }), + ], + }), + ), + ); + + const result = await listPipelines(creds, ref, { limit: 10 }); + expect(result[0]?.durationSeconds).toBe(150); + }); + + test("falls back to nickname when display_name is missing", async () => { + server.use( + http.get(PIPELINES_PATH, () => + HttpResponse.json({ + values: [makePipeline({ creator: { nickname: "bob" } })], + }), + ), + ); + + const result = await listPipelines(creds, ref, { limit: 10 }); + expect(result[0]?.creator).toBe("bob"); + }); + + test("throws PipelineError on non-ok response", async () => { + server.use( + http.get(PIPELINES_PATH, () => + HttpResponse.json({ type: "error" }, { status: 404 }), + ), + ); + + const err = await listPipelines(creds, ref, { limit: 30 }).catch((e) => e); + expect(err).toBeInstanceOf(PipelineError); + expect((err as PipelineError).status).toBe(404); + }); +}); diff --git a/src/backend/pipelines/index.ts b/src/backend/pipelines/index.ts new file mode 100644 index 0000000..13a652f --- /dev/null +++ b/src/backend/pipelines/index.ts @@ -0,0 +1,164 @@ +import { + type Credentials, + createBitbucketClient, +} from "../../shared/bitbucket-http/index.ts"; +import { + PaginationError, + withPagination, +} from "../../shared/bitbucket-http/paginate.ts"; + +export type PipelineStatus = + | "pending" + | "running" + | "paused" + | "success" + | "failed" + | "stopped" + | "error"; + +export type Pipeline = { + buildNumber: number; + status: PipelineStatus; + branch: string; + commitHash: string; + trigger: string; + creator: string; + createdOn: string; + durationSeconds: number | null; +}; + +export class PipelineError extends Error { + readonly status: number | undefined; + + constructor(message: string, status?: number) { + super(message); + this.name = "PipelineError"; + this.status = status; + } +} + +/** User-friendly filter values mapped to the API's `status` query param. */ +const STATUS_FILTER_MAP: Record = { + pending: "PENDING", + running: "BUILDING", + success: "PASSED", + failed: "FAILED", + stopped: "STOPPED", + error: "ERROR", +}; + +export const VALID_STATUS_FILTERS = Object.keys(STATUS_FILTER_MAP); + +export type ListPipelinesOptions = { + limit: number; + branch?: string; + status?: string; +}; + +const PAGELEN = 50; + +export async function listPipelines( + credentials: Credentials, + ref: { workspace: string; slug: string }, + options: ListPipelinesOptions, +): Promise { + const client = createBitbucketClient(credentials); + + const query: Record = { + sort: "-created_on", + pagelen: PAGELEN, + }; + if (options.branch) { + query["target.branch"] = options.branch; + } + if (options.status) { + query.status = STATUS_FILTER_MAP[options.status] ?? options.status; + } + + try { + const raw = await withPagination( + () => + client.GET("/repositories/{workspace}/{repo_slug}/pipelines", { + params: { + path: { workspace: ref.workspace, repo_slug: ref.slug }, + query, + }, + }), + credentials, + { limit: options.limit }, + ); + return raw.map(toPipeline); + } catch (err) { + if (err instanceof PaginationError) { + throw new PipelineError(err.message, err.status); + } + throw err; + } +} + +function toPipeline(raw: Record): Pipeline { + const target = raw.target ?? {}; + const commit = target.commit ?? {}; + const hash = typeof commit.hash === "string" ? commit.hash : ""; + const creator = raw.creator ?? {}; + + return { + buildNumber: Number(raw.build_number ?? 0), + status: extractStatus(raw.state), + branch: String(target.ref_name ?? ""), + commitHash: hash.slice(0, 12), + trigger: extractTrigger(raw.trigger), + creator: + typeof creator.display_name === "string" + ? creator.display_name + : typeof creator.nickname === "string" + ? creator.nickname + : "", + createdOn: String(raw.created_on ?? ""), + durationSeconds: computeDuration(raw.created_on, raw.completed_on), + }; +} + +function extractStatus(state: unknown): PipelineStatus { + if (!state || typeof state !== "object") return "pending"; + const s = state as Record; + const name = String(s.name ?? ""); + + if (name === "PENDING") return "pending"; + if (name === "IN_PROGRESS") { + const stageName = s.stage?.name; + if (stageName === "PAUSED") return "paused"; + return "running"; + } + if (name === "COMPLETED") { + const resultName = s.result?.name; + if (resultName === "SUCCESSFUL") return "success"; + if (resultName === "FAILED") return "failed"; + if (resultName === "STOPPED") return "stopped"; + if (resultName === "ERROR") return "error"; + } + return "pending"; +} + +function extractTrigger(trigger: unknown): string { + if (!trigger || typeof trigger !== "object") return ""; + const t = trigger as Record; + const type = String(t.type ?? ""); + // The type field looks like "pipeline_trigger_push" — strip the prefix. + if (type.startsWith("pipeline_trigger_")) { + return type.slice("pipeline_trigger_".length); + } + return type; +} + +function computeDuration( + createdOn: unknown, + completedOn: unknown, +): number | null { + if (typeof createdOn !== "string" || typeof completedOn !== "string") + return null; + const start = new Date(createdOn).getTime(); + const end = new Date(completedOn).getTime(); + if (Number.isNaN(start) || Number.isNaN(end)) return null; + return Math.round((end - start) / 1000); +} diff --git a/src/commands/pipeline/index.ts b/src/commands/pipeline/index.ts new file mode 100644 index 0000000..8a51368 --- /dev/null +++ b/src/commands/pipeline/index.ts @@ -0,0 +1,25 @@ +import type { Command } from "commander"; +import { withRenderer } from "../../shared/renderer/commander.ts"; +import { runPipelineList } from "./list.ts"; + +export function registerPipelineCommands(program: Command): void { + const pipeline = program + .command("pipeline") + .alias("pipe") + .description("Work with Bitbucket Pipelines"); + + pipeline + .command("list") + .description("List recent pipeline runs for the current repo") + .option( + "-R, --repository ", + "Override repository detection", + ) + .option("-b, --branch ", "Filter by branch") + .option( + "-s, --status ", + "Filter by status: pending, running, success, failed, stopped, error", + ) + .option("-L, --limit ", "Maximum results", "30") + .action(withRenderer(runPipelineList)); +} diff --git a/src/commands/pipeline/list.ts b/src/commands/pipeline/list.ts new file mode 100644 index 0000000..5319e26 --- /dev/null +++ b/src/commands/pipeline/list.ts @@ -0,0 +1,102 @@ +import { + listPipelines, + PipelineError, + VALID_STATUS_FILTERS, +} from "../../backend/pipelines/index.ts"; +import { loadConfigOrExit } from "../../shared/config/index.ts"; +import type { Renderer } from "../../shared/renderer/index.ts"; +import { + RepositoryResolutionError, + resolveRepository, +} from "../../shared/repository/index.ts"; +import { formatRelativeTime } from "../../shared/time/index.ts"; + +export type PipelineListOptions = { + repository?: string; + branch?: string; + status?: string; + limit?: string; +}; + +const DEFAULT_LIMIT = 30; + +export async function runPipelineList( + renderer: Renderer, + options: PipelineListOptions, +): Promise { + const config = await loadConfigOrExit(renderer); + + const limit = parseLimit(options.limit); + if (limit === null) { + renderer.error( + `Invalid --limit '${options.limit}'. Expected a positive integer.`, + ); + process.exit(1); + } + + if (options.status && !VALID_STATUS_FILTERS.includes(options.status)) { + renderer.error( + `Invalid --status '${options.status}'. Expected one of: ${VALID_STATUS_FILTERS.join(", ")}.`, + ); + process.exit(1); + } + + try { + const ref = await resolveRepository({ override: options.repository }); + + const pipelines = await listPipelines(config, ref, { + limit, + branch: options.branch, + status: options.status, + }); + + if (pipelines.length === 0) { + renderer.message("No pipeline runs found."); + return; + } + + renderer.list(pipelines, [ + { header: "#", value: (p) => String(p.buildNumber) }, + { header: "STATUS", value: (p) => p.status }, + { header: "BRANCH", value: (p) => p.branch, flex: true }, + { header: "COMMIT", value: (p) => p.commitHash, style: "muted" }, + { header: "TRIGGER", value: (p) => p.trigger, style: "muted" }, + { + header: "DURATION", + value: (p) => formatDuration(p.durationSeconds), + style: "muted", + }, + { header: "CREATOR", value: (p) => p.creator, style: "muted" }, + { + header: "CREATED", + value: (p) => formatRelativeTime(p.createdOn), + style: "muted", + }, + ]); + } catch (err) { + if ( + err instanceof RepositoryResolutionError || + err instanceof PipelineError + ) { + renderer.error(err.message); + process.exit(1); + } + throw err; + } +} + +function parseLimit(raw: string | undefined): number | null { + if (raw === undefined) return DEFAULT_LIMIT; + const n = Number(raw); + if (!Number.isInteger(n) || n <= 0) return null; + return n; +} + +function formatDuration(seconds: number | null): string { + if (seconds === null) return "-"; + if (seconds < 60) return `${seconds}s`; + const m = Math.floor(seconds / 60); + const s = seconds % 60; + if (s === 0) return `${m}m`; + return `${m}m${s}s`; +} diff --git a/src/index.ts b/src/index.ts index 1c7619d..7bd7621 100755 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ #!/usr/bin/env bun import { Command } from "commander"; import { registerAuthCommands } from "./commands/auth/index.ts"; +import { registerPipelineCommands } from "./commands/pipeline/index.ts"; import { registerPullRequestCommands } from "./commands/pullrequest/index.ts"; import { registerRepoCommands } from "./commands/repo/index.ts"; import { registerWorkspaceCommands } from "./commands/workspace/index.ts"; @@ -14,6 +15,7 @@ program .option("--json", "Output machine-readable JSON"); registerAuthCommands(program); +registerPipelineCommands(program); registerPullRequestCommands(program); registerRepoCommands(program); registerWorkspaceCommands(program);