From a5ad562e0d477fdef5bdbb6ad24bc58088feefe2 Mon Sep 17 00:00:00 2001 From: Nicolas Medda Date: Mon, 27 Apr 2026 14:54:05 +0200 Subject: [PATCH 1/2] BBC2-20 BBC2-26 add bb repo list and bb repo clone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bb repo list: paginated listing with name filter (-q), limit (-l), workspace auto-detection (current repo or sole workspace from API) - bb repo clone : resolve workspace/repo to SSH (default) or HTTPS (--https) clone URL via API, delegate to git clone - Shared resolve-workspace helper with fallback chain: -R flag → current repo → sole workspace → error with list - Fix parse-url to accept bitbucket.com alongside bitbucket.org - Backend with tests (listRepositories, getRepositoryCloneLinks) --- src/backend/repositories/index.test.ts | 214 ++++++++++++++++++++++++ src/backend/repositories/index.ts | 132 +++++++++++++++ src/commands/repo/clone.ts | 78 +++++++++ src/commands/repo/index.ts | 23 +++ src/commands/repo/list.ts | 81 +++++++++ src/commands/repo/resolve-workspace.ts | 56 +++++++ src/shared/repository/parse-url.test.ts | 4 + src/shared/repository/parse-url.ts | 14 +- 8 files changed, 597 insertions(+), 5 deletions(-) create mode 100644 src/backend/repositories/index.test.ts create mode 100644 src/backend/repositories/index.ts create mode 100644 src/commands/repo/clone.ts create mode 100644 src/commands/repo/list.ts create mode 100644 src/commands/repo/resolve-workspace.ts 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..8252688 --- /dev/null +++ b/src/commands/repo/clone.ts @@ -0,0 +1,78 @@ +import { $ } from "bun"; +import { + getRepositoryCloneLinks, + RepositoryError, +} from "../../backend/repositories/index.ts"; +import { loadConfigOrExit } from "../../shared/config/index.ts"; +import type { Renderer } from "../../shared/renderer/index.ts"; +import { resolveWorkspaceOrExit } from "./resolve-workspace.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); + const workspace = await resolveWorkspaceOrExit( + renderer, + config, + explicitWs ?? options.repository, + ); + + let cloneUrl: string; + try { + const links = await getRepositoryCloneLinks(config, { + workspace, + slug, + }); + + if (options.https) { + cloneUrl = links.https ?? links.ssh ?? ""; + } else { + cloneUrl = 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); + } + } catch (err) { + if (err instanceof RepositoryError) { + renderer.error(err.message); + process.exit(1); + } + throw err; + } + + renderer.message(`Cloning ${workspace}/${slug}...`); + + const result = await $`git clone ${cloneUrl}`.nothrow(); + if (result.exitCode !== 0) { + process.exit(result.exitCode); + } +} + +/** + * Parses the repo argument. Supports two forms: + * - `workspace/repo` — returns both parts + * - `repo` — returns slug only; workspace resolved later + */ +function parseRepoArg(repo: string): { + workspace: string | undefined; + slug: string; +} { + const parts = repo.split("/"); + if (parts.length === 2 && parts[0] && parts[1]) { + return { workspace: parts[0], slug: parts[1] }; + } + return { workspace: undefined, slug: repo }; +} 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..ffafdc7 --- /dev/null +++ b/src/commands/repo/list.ts @@ -0,0 +1,81 @@ +import { + listRepositories, + RepositoryError, +} from "../../backend/repositories/index.ts"; +import { loadConfigOrExit } from "../../shared/config/index.ts"; +import type { Renderer } from "../../shared/renderer/index.ts"; +import { formatRelativeTime } from "../../shared/time/index.ts"; +import { resolveWorkspaceOrExit } from "./resolve-workspace.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); + } + + const workspace = await resolveWorkspaceOrExit( + renderer, + config, + options.repository, + ); + + try { + 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 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/commands/repo/resolve-workspace.ts b/src/commands/repo/resolve-workspace.ts new file mode 100644 index 0000000..b24a8c2 --- /dev/null +++ b/src/commands/repo/resolve-workspace.ts @@ -0,0 +1,56 @@ +import { + listWorkspaces, + WorkspaceError, +} from "../../backend/workspaces/index.ts"; +import type { Credentials } from "../../shared/bitbucket-http/index.ts"; +import type { Renderer } from "../../shared/renderer/index.ts"; +import { resolveRepository } from "../../shared/repository/index.ts"; + +/** + * Resolves a workspace slug using the following fallback chain: + * 1. Explicit override (from -R flag) + * 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 + * + * Exits the process on failure — callers don't need to handle errors. + */ +export async function resolveWorkspaceOrExit( + renderer: Renderer, + credentials: Credentials, + override?: string, +): Promise { + if (override) { + return override.split("/")[0]!; + } + + // Try current repo + try { + const ref = await resolveRepository({}); + return ref.workspace; + } catch { + // Not inside a BB repo — fall through + } + + // Try workspace list + try { + const workspaces = await listWorkspaces(credentials); + if (workspaces.length === 1) { + return workspaces[0]!.slug; + } + if (workspaces.length === 0) { + renderer.error("No workspaces found for your account."); + process.exit(1); + } + renderer.error( + `Multiple workspaces available. Pass -R to specify one:\n${workspaces.map((w) => ` ${w.slug}`).join("\n")}`, + ); + process.exit(1); + } catch (err) { + if (err instanceof WorkspaceError) { + renderer.error(err.message); + process.exit(1); + } + throw err; + } +} 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); From 591ff2a7ba932ebb4e19121cfc4a4e64a2d93a63 Mon Sep 17 00:00:00 2001 From: Nicolas Medda Date: Mon, 27 Apr 2026 15:09:51 +0200 Subject: [PATCH 2/2] refactor: move resolveWorkspace to shared, throw instead of exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move workspace resolution from commands/repo/ to shared/workspace/ so it's reusable by any workspace-scoped command - Throw WorkspaceResolutionError instead of calling process.exit() (matches resolveRepository pattern in shared/repository/) - Inject dependencies (detectFromRepo, fetchWorkspaceSlugs) so shared/ doesn't import backend/ — respects boundary rules - Add 10 tests covering all paths: override, repo detection, sole workspace, no workspaces, multiple workspaces, error propagation - Fix git clone output: drop .quiet() so git streams naturally - Lowercase workspace/slug in parseRepoArg (matches parseOverride) --- src/commands/repo/clone.ts | 65 ++++++++------ src/commands/repo/list.ts | 30 +++++-- src/commands/repo/resolve-workspace.ts | 56 ------------- src/shared/workspace/index.ts | 1 + src/shared/workspace/resolve.test.ts | 112 +++++++++++++++++++++++++ src/shared/workspace/resolve.ts | 45 ++++++++++ 6 files changed, 220 insertions(+), 89 deletions(-) delete mode 100644 src/commands/repo/resolve-workspace.ts create mode 100644 src/shared/workspace/index.ts create mode 100644 src/shared/workspace/resolve.test.ts create mode 100644 src/shared/workspace/resolve.ts diff --git a/src/commands/repo/clone.ts b/src/commands/repo/clone.ts index 8252688..04d4ad7 100644 --- a/src/commands/repo/clone.ts +++ b/src/commands/repo/clone.ts @@ -3,9 +3,14 @@ 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 { resolveWorkspaceOrExit } from "./resolve-workspace.ts"; +import { resolveRepository } from "../../shared/repository/index.ts"; +import { + resolveWorkspace, + WorkspaceResolutionError, +} from "../../shared/workspace/index.ts"; export type RepoCloneOptions = { repository?: string; @@ -20,24 +25,28 @@ export async function runRepoClone( const config = await loadConfigOrExit(renderer); const { workspace: explicitWs, slug } = parseRepoArg(repo); - const workspace = await resolveWorkspaceOrExit( - renderer, - config, - explicitWs ?? options.repository, - ); - let cloneUrl: string; 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, }); - if (options.https) { - cloneUrl = links.https ?? links.ssh ?? ""; - } else { - cloneUrl = links.ssh ?? links.https ?? ""; - } + const cloneUrl = options.https + ? (links.https ?? links.ssh) + : (links.ssh ?? links.https); if (!cloneUrl) { renderer.error( @@ -45,34 +54,40 @@ export async function runRepoClone( ); 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 RepositoryError) { + if ( + err instanceof WorkspaceResolutionError || + err instanceof RepositoryError + ) { renderer.error(err.message); process.exit(1); } throw err; } - - renderer.message(`Cloning ${workspace}/${slug}...`); - - const result = await $`git clone ${cloneUrl}`.nothrow(); - if (result.exitCode !== 0) { - process.exit(result.exitCode); - } } /** * Parses the repo argument. Supports two forms: - * - `workspace/repo` — returns both parts + * - `workspace/repo` — returns both parts (lowercased) * - `repo` — returns slug only; workspace resolved later */ function parseRepoArg(repo: string): { workspace: string | undefined; slug: string; } { - const parts = repo.split("/"); - if (parts.length === 2 && parts[0] && parts[1]) { - return { workspace: parts[0], slug: parts[1] }; + const match = /^([^/\s]+)\/([^/\s]+)$/.exec(repo.trim()); + if (match) { + return { + workspace: match[1]!.toLowerCase(), + slug: match[2]!.toLowerCase(), + }; } - return { workspace: undefined, slug: repo }; + return { workspace: undefined, slug: repo.trim().toLowerCase() }; } diff --git a/src/commands/repo/list.ts b/src/commands/repo/list.ts index ffafdc7..9224132 100644 --- a/src/commands/repo/list.ts +++ b/src/commands/repo/list.ts @@ -2,10 +2,15 @@ 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 { resolveWorkspaceOrExit } from "./resolve-workspace.ts"; +import { + resolveWorkspace, + WorkspaceResolutionError, +} from "../../shared/workspace/index.ts"; export type RepoListOptions = { repository?: string; @@ -29,13 +34,19 @@ export async function runRepoList( process.exit(1); } - const workspace = await resolveWorkspaceOrExit( - renderer, - config, - options.repository, - ); - 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, @@ -65,7 +76,10 @@ export async function runRepoList( }, ]); } catch (err) { - if (err instanceof RepositoryError) { + if ( + err instanceof WorkspaceResolutionError || + err instanceof RepositoryError + ) { renderer.error(err.message); process.exit(1); } diff --git a/src/commands/repo/resolve-workspace.ts b/src/commands/repo/resolve-workspace.ts deleted file mode 100644 index b24a8c2..0000000 --- a/src/commands/repo/resolve-workspace.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { - listWorkspaces, - WorkspaceError, -} from "../../backend/workspaces/index.ts"; -import type { Credentials } from "../../shared/bitbucket-http/index.ts"; -import type { Renderer } from "../../shared/renderer/index.ts"; -import { resolveRepository } from "../../shared/repository/index.ts"; - -/** - * Resolves a workspace slug using the following fallback chain: - * 1. Explicit override (from -R flag) - * 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 - * - * Exits the process on failure — callers don't need to handle errors. - */ -export async function resolveWorkspaceOrExit( - renderer: Renderer, - credentials: Credentials, - override?: string, -): Promise { - if (override) { - return override.split("/")[0]!; - } - - // Try current repo - try { - const ref = await resolveRepository({}); - return ref.workspace; - } catch { - // Not inside a BB repo — fall through - } - - // Try workspace list - try { - const workspaces = await listWorkspaces(credentials); - if (workspaces.length === 1) { - return workspaces[0]!.slug; - } - if (workspaces.length === 0) { - renderer.error("No workspaces found for your account."); - process.exit(1); - } - renderer.error( - `Multiple workspaces available. Pass -R to specify one:\n${workspaces.map((w) => ` ${w.slug}`).join("\n")}`, - ); - process.exit(1); - } catch (err) { - if (err instanceof WorkspaceError) { - renderer.error(err.message); - process.exit(1); - } - throw err; - } -} 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")}`, + ); +}