Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions src/backend/pullrequests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown> = {},
): Record<string, unknown> {
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<CommitStatus>({
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<CommitStatus>({
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);
});
});
68 changes: 68 additions & 0 deletions src/backend/pullrequests/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<CommitStatus[]> {
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<string, any>;
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 ?? ""),
};
}
107 changes: 107 additions & 0 deletions src/commands/pullrequest/checks.ts
Original file line number Diff line number Diff line change
@@ -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<CommitStatusState, string> = {
SUCCESSFUL: "\u2713", // ✓
FAILED: "\u2717", // ✗
INPROGRESS: "*",
STOPPED: "\u2717", // ✗
};

export async function runPullRequestChecks(
renderer: Renderer,
idArg: string | undefined,
options: PullRequestChecksOptions,
): Promise<void> {
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;
}
12 changes: 12 additions & 0 deletions src/commands/pullrequest/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 <workspace/repo>",
"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)",
Expand Down
Loading