diff --git a/src/backend/repositories/index.test.ts b/src/backend/repositories/index.test.ts new file mode 100644 index 0000000..b35916c --- /dev/null +++ b/src/backend/repositories/index.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, test } from "bun:test"; +import { HttpResponse, http } from "msw"; +import { BITBUCKET_BASE, server, setupMsw } from "../../test/msw/server.ts"; +import { + getRepositoryCloneLinks, + listRepositories, + type Repository, + RepositoryError, +} from "./index.ts"; + +setupMsw(); + +const creds = { email: "a@b.co", token: "t" }; + +const REPO_LIST_PATH = `${BITBUCKET_BASE}/repositories/ws`; +const REPO_DETAIL_PATH = `${BITBUCKET_BASE}/repositories/ws/my-repo`; + +function makeRepo( + overrides: Record = {}, +): Record { + return { + type: "repository", + slug: "my-repo", + name: "My Repo", + full_name: "ws/my-repo", + description: "A repo", + is_private: true, + language: "typescript", + updated_on: "2026-04-20T10:00:00Z", + links: { + html: { href: "https://bitbucket.org/ws/my-repo" }, + clone: [ + { name: "https", href: "https://bitbucket.org/ws/my-repo.git" }, + { name: "ssh", href: "git@bitbucket.org:ws/my-repo.git" }, + ], + }, + ...overrides, + }; +} + +describe("listRepositories", () => { + test("default query: sort=-updated_on, pagelen=50", async () => { + const calls: URLSearchParams[] = []; + server.use( + http.get(REPO_LIST_PATH, ({ request }) => { + calls.push(new URL(request.url).searchParams); + return HttpResponse.json({ values: [makeRepo()] }); + }), + ); + + const result = await listRepositories(creds, "ws", { limit: 30 }); + + expect(calls).toHaveLength(1); + expect(calls[0]?.get("sort")).toBe("-updated_on"); + expect(calls[0]?.get("pagelen")).toBe("50"); + expect(calls[0]?.has("q")).toBe(false); + expect(result).toHaveLength(1); + expect(result[0]?.slug).toBe("my-repo"); + }); + + test("query option builds BBQL name filter", async () => { + const calls: URLSearchParams[] = []; + server.use( + http.get(REPO_LIST_PATH, ({ request }) => { + calls.push(new URL(request.url).searchParams); + return HttpResponse.json({ values: [] }); + }), + ); + + await listRepositories(creds, "ws", { limit: 30, query: "frontend" }); + + expect(calls[0]?.get("q")).toBe('name ~ "frontend"'); + }); + + test("maps API fields to Repository shape", async () => { + server.use( + http.get(REPO_LIST_PATH, () => + HttpResponse.json({ + values: [ + makeRepo({ + slug: "api-lib", + name: "API Lib", + full_name: "ws/api-lib", + description: "Core API library", + is_private: false, + language: "python", + updated_on: "2026-04-15T12:00:00Z", + links: { + html: { href: "https://bitbucket.org/ws/api-lib" }, + }, + }), + ], + }), + ), + ); + + const result = await listRepositories(creds, "ws", { limit: 10 }); + + expect(result[0]).toEqual({ + slug: "api-lib", + name: "API Lib", + fullName: "ws/api-lib", + description: "Core API library", + isPrivate: false, + language: "python", + updatedOn: "2026-04-15T12:00:00Z", + url: "https://bitbucket.org/ws/api-lib", + }); + }); + + test("follows next cursor until limit reached", async () => { + const calls: URLSearchParams[] = []; + server.use( + http.get(REPO_LIST_PATH, ({ request }) => { + const params = new URL(request.url).searchParams; + calls.push(params); + const page = params.get("page") ?? "1"; + const values = + page === "1" + ? [makeRepo({ slug: "r1" }), makeRepo({ slug: "r2" })] + : [makeRepo({ slug: "r3" }), makeRepo({ slug: "r4" })]; + const next = + page === "1" + ? `${REPO_LIST_PATH}?page=2&sort=-updated_on&pagelen=50` + : undefined; + return HttpResponse.json({ values, next }); + }), + ); + + const result = await listRepositories(creds, "ws", { limit: 3 }); + + expect(result.map((r) => r.slug)).toEqual(["r1", "r2", "r3"]); + expect(calls).toHaveLength(2); + }); + + test("throws RepositoryError on non-ok response", async () => { + server.use( + http.get(REPO_LIST_PATH, () => + HttpResponse.json({ type: "error" }, { status: 404 }), + ), + ); + + const err = await listRepositories(creds, "ws", { limit: 30 }).catch( + (e) => e, + ); + + expect(err).toBeInstanceOf(RepositoryError); + expect((err as RepositoryError).status).toBe(404); + }); + + test("escapes quotes in query string", async () => { + const calls: URLSearchParams[] = []; + server.use( + http.get(REPO_LIST_PATH, ({ request }) => { + calls.push(new URL(request.url).searchParams); + return HttpResponse.json({ values: [] }); + }), + ); + + await listRepositories(creds, "ws", { + limit: 30, + query: 'my "repo"', + }); + + expect(calls[0]?.get("q")).toBe('name ~ "my \\"repo\\""'); + }); +}); + +describe("getRepositoryCloneLinks", () => { + test("returns ssh and https clone links", async () => { + server.use(http.get(REPO_DETAIL_PATH, () => HttpResponse.json(makeRepo()))); + + const links = await getRepositoryCloneLinks(creds, { + workspace: "ws", + slug: "my-repo", + }); + + expect(links).toEqual({ + ssh: "git@bitbucket.org:ws/my-repo.git", + https: "https://bitbucket.org/ws/my-repo.git", + }); + }); + + test("returns undefined for missing clone links", async () => { + server.use( + http.get(REPO_DETAIL_PATH, () => + HttpResponse.json(makeRepo({ links: { html: { href: "" } } })), + ), + ); + + const links = await getRepositoryCloneLinks(creds, { + workspace: "ws", + slug: "my-repo", + }); + + expect(links).toEqual({ ssh: undefined, https: undefined }); + }); + + test("throws RepositoryError on 404", async () => { + server.use( + http.get(REPO_DETAIL_PATH, () => + HttpResponse.json({ type: "error" }, { status: 404 }), + ), + ); + + const err = await getRepositoryCloneLinks(creds, { + workspace: "ws", + slug: "my-repo", + }).catch((e) => e); + + expect(err).toBeInstanceOf(RepositoryError); + expect((err as RepositoryError).status).toBe(404); + }); +}); diff --git a/src/backend/repositories/index.ts b/src/backend/repositories/index.ts new file mode 100644 index 0000000..8ac4915 --- /dev/null +++ b/src/backend/repositories/index.ts @@ -0,0 +1,132 @@ +import type { components } from "../../shared/bitbucket-http/generated"; +import { + type Credentials, + createBitbucketClient, +} from "../../shared/bitbucket-http/index.ts"; +import { + PaginationError, + withPagination, +} from "../../shared/bitbucket-http/paginate.ts"; + +type RawRepository = components["schemas"]["repository"]; + +export type Repository = { + slug: string; + name: string; + fullName: string; + description: string; + isPrivate: boolean; + language: string; + updatedOn: string; + url: string; +}; + +export class RepositoryError extends Error { + readonly status: number | undefined; + + constructor(message: string, status?: number) { + super(message); + this.name = "RepositoryError"; + this.status = status; + } +} + +export type ListRepositoriesOptions = { + limit: number; + query?: string; +}; + +const PAGELEN = 50; + +export async function listRepositories( + credentials: Credentials, + workspace: string, + options: ListRepositoriesOptions, +): Promise { + const client = createBitbucketClient(credentials); + + const query: Record = { + sort: "-updated_on", + pagelen: PAGELEN, + }; + if (options.query) { + query.q = `name ~ "${escapeBbql(options.query)}"`; + } + + try { + const raw = await withPagination( + () => + client.GET("/repositories/{workspace}", { + params: { + path: { workspace }, + query, + }, + }), + credentials, + { limit: options.limit }, + ); + return raw.map(toRepository); + } catch (err) { + if (err instanceof PaginationError) { + throw new RepositoryError(err.message, err.status); + } + throw err; + } +} + +/** + * Fetches clone links for a single repository. Returns the SSH and HTTPS + * URLs from the `links.clone` array. Used by `bb repo clone` to resolve + * `workspace/repo` shorthand into a git-cloneable URL. + */ +export async function getRepositoryCloneLinks( + credentials: Credentials, + ref: { workspace: string; slug: string }, +): Promise<{ ssh?: string; https?: string }> { + 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, + ); + } + + const raw = data as Record; + const cloneLinks: Array<{ name?: string; href?: string }> = + raw.links?.clone ?? []; + + let ssh: string | undefined; + let https: string | undefined; + for (const link of cloneLinks) { + if (link.name === "ssh") ssh = link.href; + if (link.name === "https") https = link.href; + } + return { ssh, https }; +} + +function toRepository(raw: RawRepository): Repository { + const r = raw as Record; + return { + slug: String(r.slug ?? ""), + name: String(r.name ?? ""), + fullName: String(r.full_name ?? ""), + description: String(r.description ?? ""), + isPrivate: Boolean(r.is_private), + language: String(r.language ?? ""), + updatedOn: String(r.updated_on ?? ""), + url: String(r.links?.html?.href ?? ""), + }; +} + +function escapeBbql(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} diff --git a/src/commands/repo/clone.ts b/src/commands/repo/clone.ts new file mode 100644 index 0000000..04d4ad7 --- /dev/null +++ b/src/commands/repo/clone.ts @@ -0,0 +1,93 @@ +import { $ } from "bun"; +import { + getRepositoryCloneLinks, + RepositoryError, +} from "../../backend/repositories/index.ts"; +import { listWorkspaces } from "../../backend/workspaces/index.ts"; +import { loadConfigOrExit } from "../../shared/config/index.ts"; +import type { Renderer } from "../../shared/renderer/index.ts"; +import { resolveRepository } from "../../shared/repository/index.ts"; +import { + resolveWorkspace, + WorkspaceResolutionError, +} from "../../shared/workspace/index.ts"; + +export type RepoCloneOptions = { + repository?: string; + https?: boolean; +}; + +export async function runRepoClone( + renderer: Renderer, + repo: string, + options: RepoCloneOptions, +): Promise { + const config = await loadConfigOrExit(renderer); + + const { workspace: explicitWs, slug } = parseRepoArg(repo); + + try { + const workspace = await resolveWorkspace( + explicitWs ?? options.repository, + async () => { + try { + return (await resolveRepository({})).workspace; + } catch { + return undefined; + } + }, + async () => (await listWorkspaces(config)).map((w) => w.slug), + ); + + const links = await getRepositoryCloneLinks(config, { + workspace, + slug, + }); + + const cloneUrl = options.https + ? (links.https ?? links.ssh) + : (links.ssh ?? links.https); + + if (!cloneUrl) { + renderer.error( + `No clone URL found for ${workspace}/${slug}. The repository may not exist or you may lack access.`, + ); + process.exit(1); + } + + renderer.message(`Cloning ${workspace}/${slug}...`); + + const result = await $`git clone ${cloneUrl}`.nothrow(); + if (result.exitCode !== 0) { + process.exit(result.exitCode); + } + } catch (err) { + if ( + err instanceof WorkspaceResolutionError || + err instanceof RepositoryError + ) { + renderer.error(err.message); + process.exit(1); + } + throw err; + } +} + +/** + * Parses the repo argument. Supports two forms: + * - `workspace/repo` — returns both parts (lowercased) + * - `repo` — returns slug only; workspace resolved later + */ +function parseRepoArg(repo: string): { + workspace: string | undefined; + slug: string; +} { + const match = /^([^/\s]+)\/([^/\s]+)$/.exec(repo.trim()); + if (match) { + return { + workspace: match[1]!.toLowerCase(), + slug: match[2]!.toLowerCase(), + }; + } + return { workspace: undefined, slug: repo.trim().toLowerCase() }; +} diff --git a/src/commands/repo/index.ts b/src/commands/repo/index.ts index 64a607f..a0def60 100644 --- a/src/commands/repo/index.ts +++ b/src/commands/repo/index.ts @@ -1,6 +1,8 @@ import type { Command } from "commander"; import { withRenderer } from "../../shared/renderer/commander.ts"; +import { runRepoClone } from "./clone.ts"; import { runRepoCurrent } from "./current.ts"; +import { runRepoList } from "./list.ts"; export function registerRepoCommands(program: Command): void { const repo = program @@ -15,4 +17,25 @@ export function registerRepoCommands(program: Command): void { "Override repository detection", ) .action(withRenderer(runRepoCurrent)); + + repo + .command("list") + .description("List repositories in a workspace") + .option( + "-R, --repository ", + "Workspace to list (default: current repo's workspace)", + ) + .option("-l, --limit ", "Maximum number of results", "30") + .option("-q, --query ", "Filter by name (substring match)") + .action(withRenderer(runRepoList)); + + repo + .command("clone ") + .description("Clone a Bitbucket repository") + .option( + "-R, --repository ", + "Workspace (default: current repo's workspace)", + ) + .option("--https", "Use HTTPS instead of SSH") + .action(withRenderer(runRepoClone)); } diff --git a/src/commands/repo/list.ts b/src/commands/repo/list.ts new file mode 100644 index 0000000..9224132 --- /dev/null +++ b/src/commands/repo/list.ts @@ -0,0 +1,95 @@ +import { + listRepositories, + RepositoryError, +} from "../../backend/repositories/index.ts"; +import { listWorkspaces } from "../../backend/workspaces/index.ts"; +import { loadConfigOrExit } from "../../shared/config/index.ts"; +import type { Renderer } from "../../shared/renderer/index.ts"; +import { resolveRepository } from "../../shared/repository/index.ts"; +import { formatRelativeTime } from "../../shared/time/index.ts"; +import { + resolveWorkspace, + WorkspaceResolutionError, +} from "../../shared/workspace/index.ts"; + +export type RepoListOptions = { + repository?: string; + limit?: string; + query?: string; +}; + +const DEFAULT_LIMIT = 30; + +export async function runRepoList( + renderer: Renderer, + options: RepoListOptions, +): 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); + } + + try { + const workspace = await resolveWorkspace( + options.repository, + async () => { + try { + return (await resolveRepository({})).workspace; + } catch { + return undefined; + } + }, + async () => (await listWorkspaces(config)).map((w) => w.slug), + ); + + const repos = await listRepositories(config, workspace, { + limit, + query: options.query, + }); + + if (repos.length === 0) { + renderer.message("No repositories found."); + return; + } + + renderer.list(repos, [ + { header: "NAME", value: (r) => r.fullName, flex: true }, + { + header: "VISIBILITY", + value: (r) => (r.isPrivate ? "private" : "public"), + style: "muted", + }, + { + header: "LANGUAGE", + value: (r) => r.language || "-", + style: "muted", + }, + { + header: "UPDATED", + value: (r) => formatRelativeTime(r.updatedOn), + style: "muted", + }, + ]); + } catch (err) { + if ( + err instanceof WorkspaceResolutionError || + err instanceof RepositoryError + ) { + 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; +} diff --git a/src/shared/repository/parse-url.test.ts b/src/shared/repository/parse-url.test.ts index 98da4ca..c8c1808 100644 --- a/src/shared/repository/parse-url.test.ts +++ b/src/shared/repository/parse-url.test.ts @@ -12,6 +12,10 @@ describe("parseBitbucketRemoteUrl", () => { ["ssh://git@bitbucket.org/ws/repo.git", "ws", "repo"], // case insensitivity on host + slug normalization ["https://BITBUCKET.ORG/WS/Repo.git", "ws", "repo"], + // bitbucket.com alternate domain + ["git@bitbucket.com:ws/repo.git", "ws", "repo"], + ["https://bitbucket.com/ws/repo.git", "ws", "repo"], + ["ssh://git@bitbucket.com/ws/repo.git", "ws", "repo"], // trailing whitespace [" git@bitbucket.org:ws/repo.git ", "ws", "repo"], ]; diff --git a/src/shared/repository/parse-url.ts b/src/shared/repository/parse-url.ts index 61ff630..a0a7701 100644 --- a/src/shared/repository/parse-url.ts +++ b/src/shared/repository/parse-url.ts @@ -7,15 +7,18 @@ export type RepositoryRef = { workspace: string; slug: string }; * - ssh://git@bitbucket.org/ws/repo(.git)? * - https://bitbucket.org/ws/repo(.git)? * - https://user@bitbucket.org/ws/repo(.git)? - * The host must be exactly `bitbucket.org` — subdomains and other hosts are - * rejected. Returns null when the URL doesn't match (caller decides the error). + * The host must be `bitbucket.org` or `bitbucket.com` — subdomains and other + * hosts are rejected. Returns null when the URL doesn't match (caller decides + * the error). */ export function parseBitbucketRemoteUrl(url: string): RepositoryRef | null { const trimmed = url.trim(); if (!trimmed) return null; - // scp-like SSH: git@bitbucket.org:ws/repo(.git)? - const scp = /^git@bitbucket\.org:([^/]+)\/(.+?)(?:\.git)?\/?$/.exec(trimmed); + // scp-like SSH: git@bitbucket.org:ws/repo(.git)? (also bitbucket.com) + const scp = /^git@bitbucket\.(?:org|com):([^/]+)\/(.+?)(?:\.git)?\/?$/.exec( + trimmed, + ); if (scp) return normalize(scp[1]!, scp[2]!); // URL-shaped: ssh://, https://, http:// with optional user info. @@ -26,7 +29,8 @@ export function parseBitbucketRemoteUrl(url: string): RepositoryRef | null { return null; } - if (parsed.hostname.toLowerCase() !== "bitbucket.org") return null; + const host = parsed.hostname.toLowerCase(); + if (host !== "bitbucket.org" && host !== "bitbucket.com") return null; if (!["https:", "http:", "ssh:"].includes(parsed.protocol)) return null; const segments = parsed.pathname.split("/").filter(Boolean); diff --git a/src/shared/workspace/index.ts b/src/shared/workspace/index.ts new file mode 100644 index 0000000..ac534a1 --- /dev/null +++ b/src/shared/workspace/index.ts @@ -0,0 +1 @@ +export { resolveWorkspace, WorkspaceResolutionError } from "./resolve.ts"; diff --git a/src/shared/workspace/resolve.test.ts b/src/shared/workspace/resolve.test.ts new file mode 100644 index 0000000..0617ef9 --- /dev/null +++ b/src/shared/workspace/resolve.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from "bun:test"; +import { resolveWorkspace, WorkspaceResolutionError } from "./resolve.ts"; + +const noRepo = async () => undefined; +const noWorkspaces = async () => [] as string[]; +const oneWorkspace = async () => ["my-ws"]; +const multipleWorkspaces = async () => ["ws-a", "ws-b", "ws-c"]; +const repoWorkspace = async () => "detected-ws"; + +describe("resolveWorkspace", () => { + test("returns override when provided", async () => { + const result = await resolveWorkspace("explicit-ws", noRepo, noWorkspaces); + expect(result).toBe("explicit-ws"); + }); + + test("extracts first segment from workspace/repo override", async () => { + const result = await resolveWorkspace( + "my-ws/my-repo", + noRepo, + noWorkspaces, + ); + expect(result).toBe("my-ws"); + }); + + test("does not call detectFromRepo or fetchWorkspaceSlugs when override is set", async () => { + let repoCalled = false; + let wsCalled = false; + + await resolveWorkspace( + "explicit", + async () => { + repoCalled = true; + return undefined; + }, + async () => { + wsCalled = true; + return []; + }, + ); + + expect(repoCalled).toBe(false); + expect(wsCalled).toBe(false); + }); + + test("falls back to current repo workspace", async () => { + const result = await resolveWorkspace( + undefined, + repoWorkspace, + noWorkspaces, + ); + expect(result).toBe("detected-ws"); + }); + + test("does not call fetchWorkspaceSlugs when repo detection succeeds", async () => { + let wsCalled = false; + + await resolveWorkspace(undefined, repoWorkspace, async () => { + wsCalled = true; + return []; + }); + + expect(wsCalled).toBe(false); + }); + + test("auto-selects sole workspace when repo detection fails", async () => { + const result = await resolveWorkspace(undefined, noRepo, oneWorkspace); + expect(result).toBe("my-ws"); + }); + + test("throws when no workspaces exist", async () => { + const err = await resolveWorkspace(undefined, noRepo, noWorkspaces).catch( + (e) => e, + ); + + expect(err).toBeInstanceOf(WorkspaceResolutionError); + expect(err.message).toBe("No workspaces found for your account."); + }); + + test("throws with workspace list when multiple exist", async () => { + const err = await resolveWorkspace( + undefined, + noRepo, + multipleWorkspaces, + ).catch((e) => e); + + expect(err).toBeInstanceOf(WorkspaceResolutionError); + expect(err.message).toContain("Multiple workspaces available"); + expect(err.message).toContain("ws-a"); + expect(err.message).toContain("ws-b"); + expect(err.message).toContain("ws-c"); + }); + + test("propagates errors from detectFromRepo", async () => { + const err = await resolveWorkspace( + undefined, + async () => { + throw new Error("git exploded"); + }, + noWorkspaces, + ).catch((e) => e); + + expect(err.message).toBe("git exploded"); + }); + + test("propagates errors from fetchWorkspaceSlugs", async () => { + const err = await resolveWorkspace(undefined, noRepo, async () => { + throw new Error("API down"); + }).catch((e) => e); + + expect(err.message).toBe("API down"); + }); +}); diff --git a/src/shared/workspace/resolve.ts b/src/shared/workspace/resolve.ts new file mode 100644 index 0000000..afc7146 --- /dev/null +++ b/src/shared/workspace/resolve.ts @@ -0,0 +1,45 @@ +/** + * Resolves a workspace slug using a fallback chain: + * 1. Explicit override (e.g. from -R flag) — first segment before `/` + * 2. Auto-detected from current repo's origin + * 3. If the user has exactly one workspace, use it + * 4. Error with the list of available workspaces + * + * Dependencies are injected so this module stays in `shared/` without + * importing `backend/`. Callers wire the real implementations. + */ + +export class WorkspaceResolutionError extends Error { + override name = "WorkspaceResolutionError"; +} + +/** + * @param override Explicit workspace or workspace/repo from a CLI flag. + * @param detectFromRepo Returns the workspace slug from the current git + * repo's origin, or undefined if not inside a Bitbucket repo. + * @param fetchWorkspaceSlugs Returns all workspace slugs the user has + * access to. Called only when the first two strategies fail. + */ +export async function resolveWorkspace( + override: string | undefined, + detectFromRepo: () => Promise, + fetchWorkspaceSlugs: () => Promise, +): Promise { + if (override) { + return override.split("/")[0]!; + } + + const fromRepo = await detectFromRepo(); + if (fromRepo) return fromRepo; + + const slugs = await fetchWorkspaceSlugs(); + if (slugs.length === 1) { + return slugs[0]!; + } + if (slugs.length === 0) { + throw new WorkspaceResolutionError("No workspaces found for your account."); + } + throw new WorkspaceResolutionError( + `Multiple workspaces available. Pass -R to specify one:\n${slugs.map((s) => ` ${s}`).join("\n")}`, + ); +}