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
112 changes: 112 additions & 0 deletions src/backend/repositories/index.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {},
): Record<string, unknown> {
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<Repository>({
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<Repository>({
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);
});
});
82 changes: 82 additions & 0 deletions src/backend/repositories/index.ts
Original file line number Diff line number Diff line change
@@ -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<Repository> {
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<string, any>;
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 ?? ""),
};
}
11 changes: 11 additions & 0 deletions src/commands/repo/index.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 <workspace/repo>",
"Override repository detection (same as the positional arg)",
)
.action(withRenderer(runRepoView));
}
81 changes: 81 additions & 0 deletions src/commands/repo/view.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)");
}
Loading