diff --git a/src/backend/pullrequests/index.test.ts b/src/backend/pullrequests/index.test.ts index e30b5f4..279d038 100644 --- a/src/backend/pullrequests/index.test.ts +++ b/src/backend/pullrequests/index.test.ts @@ -3,12 +3,14 @@ import { HttpResponse, http } from "msw"; import { BITBUCKET_BASE, server, setupMsw } from "../../test/msw/server.ts"; import { approvePullRequest, + type CommitStatus, createPullRequest, createPullRequestComment, findOpenPullRequestForBranch, getPullRequest, getPullRequestDiff, listEffectiveDefaultReviewers, + listPullRequestStatuses, listPullRequests, type PullRequest, type PullRequestDetail, @@ -959,3 +961,132 @@ describe("getPullRequestDiff", () => { expect((err as PullRequestError).status).toBe(404); }); }); + +describe("listPullRequestStatuses", () => { + const STATUSES_PATH = (id: number) => + `${BITBUCKET_BASE}/repositories/ws/repo/pullrequests/${id}/statuses`; + + function makeStatus( + overrides: Record = {}, + ): Record { + return { + type: "commitstatus", + key: "ci/build", + name: "CI Build", + description: "Unit tests", + state: "SUCCESSFUL", + url: "https://ci.example.com/build/1", + created_on: "2026-04-20T10:00:00Z", + updated_on: "2026-04-20T10:05:00Z", + ...overrides, + }; + } + + test("returns all statuses for a PR", async () => { + server.use( + http.get(STATUSES_PATH(42), () => + HttpResponse.json({ + values: [ + makeStatus({ key: "ci/build", state: "SUCCESSFUL" }), + makeStatus({ key: "ci/lint", state: "FAILED", name: "Lint" }), + ], + }), + ), + ); + + const result = await listPullRequestStatuses(creds, ref, 42); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + key: "ci/build", + name: "CI Build", + description: "Unit tests", + state: "SUCCESSFUL", + url: "https://ci.example.com/build/1", + }); + expect(result[1]?.state).toBe("FAILED"); + expect(result[1]?.name).toBe("Lint"); + }); + + test("returns empty array when no statuses exist", async () => { + server.use( + http.get(STATUSES_PATH(42), () => HttpResponse.json({ values: [] })), + ); + + const result = await listPullRequestStatuses(creds, ref, 42); + expect(result).toEqual([]); + }); + + test("maps all four state values", async () => { + server.use( + http.get(STATUSES_PATH(42), () => + HttpResponse.json({ + values: [ + makeStatus({ key: "a", state: "SUCCESSFUL" }), + makeStatus({ key: "b", state: "FAILED" }), + makeStatus({ key: "c", state: "INPROGRESS" }), + makeStatus({ key: "d", state: "STOPPED" }), + ], + }), + ), + ); + + const result = await listPullRequestStatuses(creds, ref, 42); + expect(result.map((s) => s.state)).toEqual([ + "SUCCESSFUL", + "FAILED", + "INPROGRESS", + "STOPPED", + ]); + }); + + test("falls back to key when name is missing", async () => { + server.use( + http.get(STATUSES_PATH(42), () => + HttpResponse.json({ + values: [makeStatus({ key: "ci/deploy", name: undefined })], + }), + ), + ); + + const result = await listPullRequestStatuses(creds, ref, 42); + expect(result[0]?.name).toBe("ci/deploy"); + }); + + test("handles missing optional fields gracefully", async () => { + server.use( + http.get(STATUSES_PATH(42), () => + HttpResponse.json({ + values: [ + { + type: "commitstatus", + key: "minimal", + state: "SUCCESSFUL", + }, + ], + }), + ), + ); + + const result = await listPullRequestStatuses(creds, ref, 42); + expect(result[0]).toEqual({ + key: "minimal", + name: "minimal", + description: "", + state: "SUCCESSFUL", + url: "", + }); + }); + + test("throws PullRequestError on 404", async () => { + server.use( + http.get(STATUSES_PATH(99), () => + HttpResponse.json({ type: "error" }, { status: 404 }), + ), + ); + + const err = await listPullRequestStatuses(creds, ref, 99).catch((e) => e); + expect(err).toBeInstanceOf(PullRequestError); + expect((err as PullRequestError).status).toBe(404); + }); +}); diff --git a/src/backend/pullrequests/index.ts b/src/backend/pullrequests/index.ts index 0d7236f..5968c5e 100644 --- a/src/backend/pullrequests/index.ts +++ b/src/backend/pullrequests/index.ts @@ -12,6 +12,7 @@ import { type RawPullRequest = components["schemas"]["pullrequest"]; type RawParticipant = components["schemas"]["participant"]; +type RawCommitStatus = components["schemas"]["commitstatus"]; export type PullRequestStateFilter = "open" | "merged" | "declined" | "all"; @@ -651,3 +652,70 @@ function toReviewState(raw: unknown): ReviewState { if (raw === "changes_requested") return "changes_requested"; return "pending"; } + +export type CommitStatusState = + | "SUCCESSFUL" + | "FAILED" + | "INPROGRESS" + | "STOPPED"; + +export type CommitStatus = { + key: string; + name: string; + description: string; + state: CommitStatusState; + url: string; +}; + +/** + * Fetches all commit statuses for a pull request's head commit. This is + * the "is it green?" truth — covers any CI vendor (BB Pipelines, Jenkins, + * external webhooks), not just Bitbucket Pipelines. + * + * Uses the PR-scoped `/statuses` endpoint so we don't need to resolve the + * head SHA separately. + */ +export async function listPullRequestStatuses( + credentials: Credentials, + ref: { workspace: string; slug: string }, + pullRequestId: number, +): Promise { + const client = createBitbucketClient(credentials); + + try { + const raw = await withPagination( + () => + client.GET( + "/repositories/{workspace}/{repo_slug}/pullrequests/{pull_request_id}/statuses", + { + params: { + path: { + workspace: ref.workspace, + repo_slug: ref.slug, + pull_request_id: pullRequestId, + }, + }, + }, + ), + credentials, + { limit: 100 }, + ); + return raw.map(toCommitStatus); + } catch (err) { + if (err instanceof PaginationError) { + throw new PullRequestError(err.message, err.status); + } + throw err; + } +} + +function toCommitStatus(raw: RawCommitStatus): CommitStatus { + const r = raw as Record; + return { + key: String(r.key ?? ""), + name: String(r.name ?? r.key ?? ""), + description: String(r.description ?? ""), + state: String(r.state ?? "INPROGRESS") as CommitStatusState, + url: String(r.url ?? ""), + }; +} diff --git a/src/commands/pullrequest/checks.ts b/src/commands/pullrequest/checks.ts new file mode 100644 index 0000000..0387d51 --- /dev/null +++ b/src/commands/pullrequest/checks.ts @@ -0,0 +1,107 @@ +import { + type CommitStatus, + type CommitStatusState, + listPullRequestStatuses, + PullRequestError, +} from "../../backend/pullrequests/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 { resolveCurrentPullRequestId } from "./current.ts"; + +export type PullRequestChecksOptions = { + repository?: string; +}; + +type Summary = { passed: number; failed: number; pending: number }; + +const STATE_ICON: Record = { + SUCCESSFUL: "\u2713", // ✓ + FAILED: "\u2717", // ✗ + INPROGRESS: "*", + STOPPED: "\u2717", // ✗ +}; + +export async function runPullRequestChecks( + renderer: Renderer, + idArg: string | undefined, + options: PullRequestChecksOptions, +): Promise { + const config = await loadConfigOrExit(renderer); + + try { + const ref = await resolveRepository({ override: options.repository }); + const id = await resolveCurrentPullRequestId(idArg, { + renderer, + config, + ref, + commandName: "checks", + }); + + const statuses = await listPullRequestStatuses(config, ref, id); + const summary = summarize(statuses); + + if (renderer.json) { + renderer.detail({ checks: statuses, summary }, []); + return; + } + + if (statuses.length === 0) { + renderer.message("No CI checks reported for this pull request."); + process.exit(0); + } + + renderer.list(statuses, [ + { + header: "", + value: (s) => `${STATE_ICON[s.state]}`, + style: undefined, + }, + { header: "NAME", value: (s) => s.name, flex: true }, + { header: "DESCRIPTION", value: (s) => s.description, style: "muted" }, + { header: "URL", value: (s) => s.url, style: "muted" }, + ]); + + renderer.message( + `${summary.passed} passed, ${summary.failed} failed, ${summary.pending} pending`, + ); + + process.exit(exitCode(summary)); + } catch (err) { + if ( + err instanceof RepositoryResolutionError || + err instanceof PullRequestError + ) { + renderer.error(err.message); + process.exit(1); + } + throw err; + } +} + +function summarize(statuses: CommitStatus[]): Summary { + let passed = 0; + let failed = 0; + let pending = 0; + for (const s of statuses) { + if (s.state === "SUCCESSFUL") passed++; + else if (s.state === "FAILED" || s.state === "STOPPED") failed++; + else pending++; + } + return { passed, failed, pending }; +} + +/** + * Exit codes per the spec: + * - 0: all SUCCESSFUL (or empty) + * - 1: any FAILED or STOPPED + * - 2: any INPROGRESS with no failures + */ +function exitCode(summary: Summary): number { + if (summary.failed > 0) return 1; + if (summary.pending > 0) return 2; + return 0; +} diff --git a/src/commands/pullrequest/index.ts b/src/commands/pullrequest/index.ts index eb4197f..d8865b7 100644 --- a/src/commands/pullrequest/index.ts +++ b/src/commands/pullrequest/index.ts @@ -1,5 +1,6 @@ import type { Command } from "commander"; import { withRenderer } from "../../shared/renderer/commander.ts"; +import { runPullRequestChecks } from "./checks.ts"; import { runPullRequestComment } from "./comment.ts"; import { runPullRequestCreate } from "./create.ts"; import { runPullRequestDiff } from "./diff.ts"; @@ -86,6 +87,17 @@ export function registerPullRequestCommands(program: Command): void { ) .action(withRenderer(runPullRequestDiff)); + pr.command("checks") + .description( + "Show CI check statuses for a pull request (defaults to the PR for the current branch)", + ) + .argument("[id]", "Pull request number") + .option( + "-R, --repository ", + "Override repository detection", + ) + .action(withRenderer(runPullRequestChecks)); + pr.command("review") .description( "Submit a review on a pull request (defaults to the PR for the current branch)",