From 5468be1262058afac926448aaf13d8bc7ea93152 Mon Sep 17 00:00:00 2001 From: Nicolas Medda Date: Wed, 22 Apr 2026 09:53:59 +0000 Subject: [PATCH] BBC2-19 add bb repo view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `bb repo view [workspace/repo]` that fetches a single repository via GET /repositories/{workspace}/{repo_slug} and shows name, owner, visibility, default branch, primary language, creation/update timestamps, URL, and description. Defaults to the auto-detected repo; a positional argument overrides (same semantics as -R). If both are passed, the positional wins — users type `bb repo view some/repo` and expect it to act on `some/repo` without learning which flag takes priority. Adds a new src/backend/repositories/ module with the Repository shape and RepositoryError class, mirroring the pullrequests backend pattern. The generated `repository` schema nests optionals several layers deep; toRepository flattens to a surface the renderer can consume without optional-chain gymnastics. --- src/backend/repositories/index.test.ts | 112 +++++++++++++++++++++++++ src/backend/repositories/index.ts | 82 ++++++++++++++++++ src/commands/repo/index.ts | 11 +++ src/commands/repo/view.ts | 81 ++++++++++++++++++ 4 files changed, 286 insertions(+) create mode 100644 src/backend/repositories/index.test.ts create mode 100644 src/backend/repositories/index.ts create mode 100644 src/commands/repo/view.ts diff --git a/src/backend/repositories/index.test.ts b/src/backend/repositories/index.test.ts new file mode 100644 index 0000000..5fa5307 --- /dev/null +++ b/src/backend/repositories/index.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from "bun:test"; +import { HttpResponse, http } from "msw"; +import { BITBUCKET_BASE, server, setupMsw } from "../../test/msw/server.ts"; +import { getRepository, type Repository, RepositoryError } from "./index.ts"; + +setupMsw(); + +const creds = { email: "a@b.co", token: "t" }; +const ref = { workspace: "acme", slug: "widgets" }; +const REPO_PATH = `${BITBUCKET_BASE}/repositories/acme/widgets`; + +function makeRepo( + overrides: Record = {}, +): Record { + return { + type: "repository", + full_name: "acme/widgets", + name: "widgets", + description: "The widgets service", + is_private: true, + language: "typescript", + created_on: "2024-01-10T10:00:00Z", + updated_on: "2026-04-20T10:00:00Z", + size: 12345, + owner: { display_name: "ACME", nickname: "acme" }, + mainbranch: { name: "main", type: "branch" }, + links: { html: { href: "https://bitbucket.org/acme/widgets" } }, + ...overrides, + }; +} + +describe("getRepository", () => { + test("maps the repository response to the flat Repository shape", async () => { + server.use(http.get(REPO_PATH, () => HttpResponse.json(makeRepo()))); + + const result = await getRepository(creds, ref); + + expect(result).toEqual({ + fullName: "acme/widgets", + name: "widgets", + owner: "ACME", + description: "The widgets service", + defaultBranch: "main", + language: "typescript", + isPrivate: true, + createdOn: "2024-01-10T10:00:00Z", + updatedOn: "2026-04-20T10:00:00Z", + size: 12345, + url: "https://bitbucket.org/acme/widgets", + }); + }); + + test("falls back through owner display_name → nickname → username", async () => { + server.use( + http.get(REPO_PATH, () => + HttpResponse.json(makeRepo({ owner: { username: "legacyuser" } })), + ), + ); + + const result = await getRepository(creds, ref); + expect(result.owner).toBe("legacyuser"); + }); + + test("handles missing optional fields with empty/zero defaults", async () => { + // Strip everything optional. Keeps the mapping function from reaching + // through undefined without guards. + server.use( + http.get(REPO_PATH, () => + HttpResponse.json({ type: "repository", full_name: "acme/widgets" }), + ), + ); + + const result = await getRepository(creds, ref); + expect(result).toEqual({ + fullName: "acme/widgets", + name: "", + owner: "", + description: "", + defaultBranch: "", + language: "", + isPrivate: false, + createdOn: "", + updatedOn: "", + size: 0, + url: "", + }); + }); + + test("throws RepositoryError on 404", async () => { + server.use( + http.get(REPO_PATH, () => + HttpResponse.json({ type: "error" }, { status: 404 }), + ), + ); + + const err = await getRepository(creds, ref).catch((e) => e); + expect(err).toBeInstanceOf(RepositoryError); + expect((err as RepositoryError).status).toBe(404); + }); + + test("throws RepositoryError on 403 (private, no access)", async () => { + server.use( + http.get(REPO_PATH, () => + HttpResponse.json({ type: "error" }, { status: 403 }), + ), + ); + + const err = await getRepository(creds, ref).catch((e) => e); + expect(err).toBeInstanceOf(RepositoryError); + expect((err as RepositoryError).status).toBe(403); + }); +}); diff --git a/src/backend/repositories/index.ts b/src/backend/repositories/index.ts new file mode 100644 index 0000000..1470edb --- /dev/null +++ b/src/backend/repositories/index.ts @@ -0,0 +1,82 @@ +import type { components } from "../../shared/bitbucket-http/generated"; +import { + type Credentials, + createBitbucketClient, +} from "../../shared/bitbucket-http/index.ts"; + +type RawRepository = components["schemas"]["repository"]; + +export type Repository = { + /** `workspace/slug` (matches what Bitbucket uses in URLs and BBQL). */ + fullName: string; + name: string; + /** Username or team name; may be empty when the API omits `owner`. */ + owner: string; + description: string; + /** Default/main branch name, or empty when the repo is empty. */ + defaultBranch: string; + language: string; + isPrivate: boolean; + createdOn: string; + updatedOn: string; + size: number; + url: string; +}; + +export class RepositoryError extends Error { + readonly status: number | undefined; + + constructor(message: string, status?: number) { + super(message); + this.name = "RepositoryError"; + this.status = status; + } +} + +/** + * Fetches a single repository by workspace/slug. Maps the sparse + * optional fields of the generated `repository` schema down to a flat + * surface the UI can render without defensive guards. + */ +export async function getRepository( + credentials: Credentials, + ref: { workspace: string; slug: string }, +): Promise { + const client = createBitbucketClient(credentials); + const { data, response } = await client.GET( + "/repositories/{workspace}/{repo_slug}", + { + params: { + path: { workspace: ref.workspace, repo_slug: ref.slug }, + }, + }, + ); + + if (!response.ok || !data) { + throw new RepositoryError( + `Failed to fetch repository ${ref.workspace}/${ref.slug}: HTTP ${response.status}.`, + response.status, + ); + } + + return toRepository(data as RawRepository); +} + +function toRepository(raw: RawRepository): Repository { + const r = raw as Record; + return { + fullName: String(r.full_name ?? ""), + name: String(r.name ?? ""), + owner: String( + r.owner?.display_name ?? r.owner?.nickname ?? r.owner?.username ?? "", + ), + description: String(r.description ?? ""), + defaultBranch: String(r.mainbranch?.name ?? ""), + language: String(r.language ?? ""), + isPrivate: Boolean(r.is_private ?? false), + createdOn: String(r.created_on ?? ""), + updatedOn: String(r.updated_on ?? ""), + size: Number(r.size ?? 0), + url: String(r.links?.html?.href ?? ""), + }; +} diff --git a/src/commands/repo/index.ts b/src/commands/repo/index.ts index 64a607f..7a91fd9 100644 --- a/src/commands/repo/index.ts +++ b/src/commands/repo/index.ts @@ -1,6 +1,7 @@ import type { Command } from "commander"; import { withRenderer } from "../../shared/renderer/commander.ts"; import { runRepoCurrent } from "./current.ts"; +import { runRepoView } from "./view.ts"; export function registerRepoCommands(program: Command): void { const repo = program @@ -15,4 +16,14 @@ export function registerRepoCommands(program: Command): void { "Override repository detection", ) .action(withRenderer(runRepoCurrent)); + + repo + .command("view") + .description("Show a repository's metadata (defaults to the current repo)") + .argument("[repository]", "Repository in workspace/repo form") + .option( + "-R, --repository ", + "Override repository detection (same as the positional arg)", + ) + .action(withRenderer(runRepoView)); } diff --git a/src/commands/repo/view.ts b/src/commands/repo/view.ts new file mode 100644 index 0000000..9e25654 --- /dev/null +++ b/src/commands/repo/view.ts @@ -0,0 +1,81 @@ +import { + getRepository, + type Repository, + RepositoryError, +} from "../../backend/repositories/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 RepoViewOptions = { + repository?: string; +}; + +/** + * Shows metadata for a single repository. Argument precedence when both a + * positional ref and `--repository` are given: positional wins — users + * typing `bb repo view some/repo` expect that to act on `some/repo` + * without having to remember which flag takes priority. + */ +export async function runRepoView( + renderer: Renderer, + refArg: string | undefined, + options: RepoViewOptions, +): Promise { + const config = await loadConfigOrExit(renderer); + + try { + const override = refArg ?? options.repository; + const ref = await resolveRepository({ override }); + const repo = await getRepository(config, ref); + render(renderer, repo); + } catch (err) { + if ( + err instanceof RepositoryResolutionError || + err instanceof RepositoryError + ) { + renderer.error(err.message); + process.exit(1); + } + throw err; + } +} + +function render(renderer: Renderer, repo: Repository): void { + renderer.detail(repo, [ + { label: "NAME", value: (r) => r.fullName, style: "bold" }, + { label: "OWNER", value: (r) => r.owner || "(unknown)", style: "muted" }, + { + label: "VISIBILITY", + value: (r) => (r.isPrivate ? "private" : "public"), + }, + { + label: "DEFAULT BRANCH", + value: (r) => r.defaultBranch || "(empty repo)", + }, + { + label: "LANGUAGE", + value: (r) => r.language || "(unset)", + style: "muted", + }, + { + label: "CREATED", + value: (r) => (r.createdOn ? formatRelativeTime(r.createdOn) : ""), + style: "muted", + }, + { + label: "UPDATED", + value: (r) => (r.updatedOn ? formatRelativeTime(r.updatedOn) : ""), + style: "muted", + }, + { label: "URL", value: (r) => r.url, style: "muted" }, + ]); + + renderer.message(""); + renderer.message("DESCRIPTION"); + renderer.message(repo.description.trim() || "(no description)"); +}