From 45bf19b8988ac6789b197c5be35cb65b8a17eda7 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Mon, 9 Nov 2026 12:11:50 -0800 Subject: [PATCH 1/3] feat(bitbucket): Add tree reads --- bin/bitbucket-client | 35 ++++ src/scm/providers/bitbucket/provider.py | 137 ++++++++++++++++ .../bitbucket/commits_branch_head.json | 13 ++ .../fixtures/bitbucket/git_commit.json | 95 +++++++++++ .../bitbucket/src_recursive_page_1.json | 50 ++++++ .../bitbucket/src_recursive_page_2.json | 37 +++++ .../fixtures/bitbucket/src_special.json | 43 +++++ .../fixtures/bitbucket/src_subdirectory.json | 35 ++++ tests/unit/provider/test_bitbucket.py | 154 ++++++++++++++++++ 9 files changed, 599 insertions(+) create mode 100644 tests/unit/provider/fixtures/bitbucket/commits_branch_head.json create mode 100644 tests/unit/provider/fixtures/bitbucket/git_commit.json create mode 100644 tests/unit/provider/fixtures/bitbucket/src_recursive_page_1.json create mode 100644 tests/unit/provider/fixtures/bitbucket/src_recursive_page_2.json create mode 100644 tests/unit/provider/fixtures/bitbucket/src_special.json create mode 100644 tests/unit/provider/fixtures/bitbucket/src_subdirectory.json diff --git a/bin/bitbucket-client b/bin/bitbucket-client index 551e92be..699c7667 100755 --- a/bin/bitbucket-client +++ b/bin/bitbucket-client @@ -27,6 +27,9 @@ Commands: get-commit-url get-commits-url [--file-path ] [--since YYYY-MM-DD] [--until YYYY-MM-DD] compare-commits [--include-behind] + get-git-commit + get-tree [--non-recursive] [--cursor ] [--per-page ] + get-full-tree [--non-recursive] """ import argparse @@ -51,10 +54,13 @@ from scm.types import ( GetDirectoryContentsProtocol, GetFileContentProtocol, GetFileUrlProtocol, + GetFullTreeProtocol, + GetGitCommitProtocol, GetGitRemoteProtocol, GetPullRequestTemplateProtocol, GetReadmeProtocol, GetRepositoryProtocol, + GetTreeProtocol, PaginationParams, ) @@ -163,6 +169,18 @@ def main(): p.add_argument("start_sha") p.add_argument("end_sha") p.add_argument("--include-behind", action="store_true", dest="include_behind") + p = sub.add_parser("get-git-commit") + p.add_argument("sha") + + p = sub.add_parser("get-tree") + p.add_argument("tree_sha") + p.add_argument("--non-recursive", action="store_true") + p.add_argument("--cursor", default=None) + p.add_argument("--per-page", type=int, default=None) + + p = sub.add_parser("get-full-tree") + p.add_argument("tree_sha") + p.add_argument("--non-recursive", action="store_true") args = parser.parse_args() if not args.command: @@ -240,6 +258,23 @@ def main(): assert isinstance(scm, CompareCommitsProtocol) dump(scm.compare_commits(args.start_sha, args.end_sha, include_behind=args.include_behind)) + elif args.command == "get-git-commit": + assert isinstance(scm, GetGitCommitProtocol) + dump(scm.get_git_commit(args.sha)) + + elif args.command == "get-tree": + assert isinstance(scm, GetTreeProtocol) + pagination: PaginationParams = {} + if args.cursor is not None: + pagination["cursor"] = args.cursor + if args.per_page is not None: + pagination["per_page"] = args.per_page + dump(scm.get_tree(args.tree_sha, recursive=not args.non_recursive, pagination=pagination or None)) + + elif args.command == "get-full-tree": + assert isinstance(scm, GetFullTreeProtocol) + dump(scm.get_full_tree(args.tree_sha, recursive=not args.non_recursive)) + if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) diff --git a/src/scm/providers/bitbucket/provider.py b/src/scm/providers/bitbucket/provider.py index a12e2809..653b6dd0 100644 --- a/src/scm/providers/bitbucket/provider.py +++ b/src/scm/providers/bitbucket/provider.py @@ -32,13 +32,19 @@ FileContent, FileContentType, FileStatus, + GitCommitObject, + GitCommitTree, GitRemote, GitRepository, + GitTree, PaginatedActionResult, PaginationParams, ProviderName, Repository, RequestOptions, + TreeEntry, + TreeEntryMode, + TreeEntryType, ) PROVIDER_TYPE: ProviderName = "bitbucket" @@ -81,6 +87,11 @@ _RAW_AUTHOR = re.compile(r"^(?P.*?)\s*<(?P[^>]*)>\s*$") +_MAX_TREE_DEPTH = str(2**31 - 1) + +_TREE_FIELDS = "next,values.path,values.type,values.size,values.attributes,values.commit.hash" + + class BitbucketProvider: def __init__( self, @@ -463,6 +474,102 @@ def _get_commits( return result pagination = {**(pagination or {}), "cursor": cursor} + def get_git_commit( + self, + sha: SHA, + request_options: RequestOptions | None = None, + ) -> ActionResult[GitCommitObject]: + """Get a commit as a git object. + + Bitbucket doesn't expose tree SHAs, so the tree is named by the commit SHA, which `get_tree` takes. + """ + response = self.get(f"/2.0/repositories/{self.repository_path}/commit/{sha}", request_options=request_options) + return map_action(response, map_git_commit_object) + + def get_tree( + self, + tree_sha: SHA, + recursive: bool = True, + pagination: PaginationParams | None = None, + request_options: RequestOptions | None = None, + ) -> PaginatedActionResult[GitTree]: + """List one page of the tree named by `` or `:`. + + Bitbucket has no tree SHAs, so each entry's `sha` is its `:` git revision. + """ + commit, path = self._split_tree_sha(tree_sha, request_options) + return self._get_tree_page(commit, path, tree_sha, recursive, pagination, request_options) + + def get_full_tree( + self, + tree_sha: SHA, + recursive: bool = True, + request_options: RequestOptions | None = None, + ) -> ActionResult[GitTree]: + """List a whole tree, walking every page.""" + commit, path = self._split_tree_sha(tree_sha, request_options) + entries: list[TreeEntry] = [] + raw_entries: list[dict[str, Any]] = [] + for page in iter_all_pages( + lambda pagination: self._get_tree_page(commit, path, tree_sha, recursive, pagination, request_options), + per_page=MAX_PAGE_SIZE, + ): + entries.extend(page["data"]["tree"]) + raw_entries.extend(page["raw"]["data"]["values"]) + return { + "data": GitTree(sha=tree_sha, tree=entries, truncated=False), + "type": PROVIDER_TYPE, + "raw": {"data": raw_entries, "headers": None}, + "meta": {}, + } + + def _split_tree_sha(self, tree_sha: SHA, request_options: RequestOptions | None) -> tuple[SHA, str]: + ref, _, path = tree_sha.partition(":") + if "/" not in ref: + return ref, path + # `src` reads the first path segment as the revision, so resolve refs like `feature/x` first. + response = self.get( + f"/2.0/repositories/{self.repository_path}/commits/{quote(ref)}", + params={"pagelen": "1", "fields": "values.hash"}, + request_options=request_options, + ) + if not (values := response.json()["values"]): + raise ResourceNotFound(detail=f"No commit for {ref!r}") + return values[0]["hash"], path + + def _get_tree_page( + self, + commit: SHA, + path: str, + tree_sha: SHA, + recursive: bool, + pagination: PaginationParams | None, + request_options: RequestOptions | None, + ) -> PaginatedActionResult[GitTree]: + params = {"fields": _TREE_FIELDS} + if recursive: + params["max_depth"] = _MAX_TREE_DEPTH + response = self.get( + f"/2.0/repositories/{self.repository_path}/src/{quote(commit)}/{quote(f'{path}/' if path else '')}", + params=params, + pagination=pagination or {"per_page": MAX_PAGE_SIZE}, + request_options=request_options, + ) + if not _is_listing(response): + raise PathIsNotDirectory(detail=path) + raw = response.json() + cursor = next_cursor(raw) + return { + "data": GitTree( + sha=tree_sha, + tree=[map_tree_entry(entry, path) for entry in raw["values"]], + truncated=cursor is not None, + ), + "type": PROVIDER_TYPE, + "raw": {"data": raw, "headers": dict(response.headers)}, + "meta": {"next_cursor": cursor}, + } + def parse_scopes(header: str) -> set[str]: return {scope.strip() for scope in header.split(",") if scope.strip()} @@ -575,6 +682,36 @@ def split_patches(diff: str) -> dict[str, str]: return patches +def map_git_commit_object(raw: dict[str, Any]) -> GitCommitObject: + return GitCommitObject(sha=raw["hash"], tree=GitCommitTree(sha=raw["hash"]), message=raw["message"]) + + +def map_tree_entry(raw: dict[str, Any], tree_path: str) -> TreeEntry: + """Map a `src` listing entry, with its path relative to the listed directory.""" + mode, entry_type = tree_entry_kind(raw) + return TreeEntry( + path=raw["path"].removeprefix(f"{tree_path}/") if tree_path else raw["path"], + mode=mode, + type=entry_type, + sha=f"{raw['commit']['hash']}:{raw['path']}", + # A submodule's size is the length of its commit SHA. + size=raw["size"] if entry_type == "blob" else None, + ) + + +def tree_entry_kind(raw: dict[str, Any]) -> tuple[TreeEntryMode, TreeEntryType]: + if raw["type"] == "commit_directory": + return "040000", "tree" + attributes = raw.get("attributes", []) + if "subrepository" in attributes: + return "160000", "commit" + if "link" in attributes: + return "120000", "blob" + if "executable" in attributes: + return "100755", "blob" + return "100644", "blob" + + def next_cursor(raw: dict[str, Any]) -> str | None: """Read the `page` value out of the `next` link, which is a page number or an opaque token.""" if not (next_url := raw.get("next")): diff --git a/tests/unit/provider/fixtures/bitbucket/commits_branch_head.json b/tests/unit/provider/fixtures/bitbucket/commits_branch_head.json new file mode 100644 index 00000000..a42c4412 --- /dev/null +++ b/tests/unit/provider/fixtures/bitbucket/commits_branch_head.json @@ -0,0 +1,13 @@ +{ + "body": { + "values": [ + { + "hash": "a682892aa0f4502ef7a242f75d72d0b41eaa27b9" + } + ] + }, + "headers": { + "content-type": "application/json; charset=utf-8" + }, + "status": 200 +} diff --git a/tests/unit/provider/fixtures/bitbucket/git_commit.json b/tests/unit/provider/fixtures/bitbucket/git_commit.json new file mode 100644 index 00000000..28eb5a6d --- /dev/null +++ b/tests/unit/provider/fixtures/bitbucket/git_commit.json @@ -0,0 +1,95 @@ +{ + "body": { + "author": { + "raw": "Test User ", + "type": "author", + "user": { + "account_id": "557058:00000000-0000-0000-0000-000000000001", + "display_name": "Test User", + "links": { + "avatar": { + "href": "https://avatar.example.com/test-user.png" + } + }, + "nickname": "test-user", + "type": "user", + "uuid": "{00000000-0000-0000-0000-000000000001}" + } + }, + "date": "2026-11-05T20:00:34+00:00", + "hash": "a682892aa0f4502ef7a242f75d72d0b41eaa27b9", + "links": { + "approve": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/a682892aa0f4502ef7a242f75d72d0b41eaa27b9/approve" + }, + "comments": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/a682892aa0f4502ef7a242f75d72d0b41eaa27b9/comments" + }, + "diff": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/diff/a682892aa0f4502ef7a242f75d72d0b41eaa27b9" + }, + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/commits/a682892aa0f4502ef7a242f75d72d0b41eaa27b9" + }, + "patch": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/patch/a682892aa0f4502ef7a242f75d72d0b41eaa27b9" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/a682892aa0f4502ef7a242f75d72d0b41eaa27b9" + }, + "statuses": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/a682892aa0f4502ef7a242f75d72d0b41eaa27b9/statuses" + } + }, + "message": "Add pull request template\n", + "parents": [ + { + "hash": "db5732a835523e74abba4863806335006466cfd0", + "links": { + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/commits/db5732a835523e74abba4863806335006466cfd0" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/db5732a835523e74abba4863806335006466cfd0" + } + }, + "type": "commit" + } + ], + "participants": [], + "rendered": { + "message": { + "html": "

Add pull request template

", + "markup": "markdown", + "raw": "Add pull request template\n", + "type": "rendered" + } + }, + "repository": { + "full_name": "test-workspace/test-repo", + "links": { + "avatar": { + "href": "https://bytebucket.org/ravatar/%7B06cc78f5-9826-41c4-9bac-b4f44ca67ef4%7D?ts=default" + }, + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo" + } + }, + "name": "test-repo", + "type": "repository", + "uuid": "{06cc78f5-9826-41c4-9bac-b4f44ca67ef4}" + }, + "summary": { + "html": "

Add pull request template

", + "markup": "markdown", + "raw": "Add pull request template\n", + "type": "rendered" + }, + "type": "commit" + }, + "headers": {}, + "status": 200 +} diff --git a/tests/unit/provider/fixtures/bitbucket/src_recursive_page_1.json b/tests/unit/provider/fixtures/bitbucket/src_recursive_page_1.json new file mode 100644 index 00000000..cf5c8aa7 --- /dev/null +++ b/tests/unit/provider/fixtures/bitbucket/src_recursive_page_1.json @@ -0,0 +1,50 @@ +{ + "body": { + "next": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/src/a682892aa0f4502ef7a242f75d72d0b41eaa27b9/?max_depth=2147483647&pagelen=5&fields=next%2Cvalues.path%2Cvalues.type%2Cvalues.size%2Cvalues.attributes%2Cvalues.commit.hash&page=rXtr", + "values": [ + { + "commit": { + "hash": "a682892aa0f4502ef7a242f75d72d0b41eaa27b9" + }, + "path": "src", + "type": "commit_directory" + }, + { + "commit": { + "hash": "a682892aa0f4502ef7a242f75d72d0b41eaa27b9" + }, + "path": "tests", + "type": "commit_directory" + }, + { + "attributes": [], + "commit": { + "hash": "a682892aa0f4502ef7a242f75d72d0b41eaa27b9" + }, + "path": "PULL_REQUEST_TEMPLATE.md", + "size": 25, + "type": "commit_file" + }, + { + "attributes": [], + "commit": { + "hash": "a682892aa0f4502ef7a242f75d72d0b41eaa27b9" + }, + "path": "README.md", + "size": 32, + "type": "commit_file" + }, + { + "commit": { + "hash": "a682892aa0f4502ef7a242f75d72d0b41eaa27b9" + }, + "path": "src/test_app", + "type": "commit_directory" + } + ] + }, + "headers": { + "content-type": "application/json; charset=utf-8" + }, + "status": 200 +} diff --git a/tests/unit/provider/fixtures/bitbucket/src_recursive_page_2.json b/tests/unit/provider/fixtures/bitbucket/src_recursive_page_2.json new file mode 100644 index 00000000..6808e4a8 --- /dev/null +++ b/tests/unit/provider/fixtures/bitbucket/src_recursive_page_2.json @@ -0,0 +1,37 @@ +{ + "body": { + "values": [ + { + "attributes": [], + "commit": { + "hash": "a682892aa0f4502ef7a242f75d72d0b41eaa27b9" + }, + "path": "tests/test_math.py", + "size": 74, + "type": "commit_file" + }, + { + "attributes": [], + "commit": { + "hash": "a682892aa0f4502ef7a242f75d72d0b41eaa27b9" + }, + "path": "src/test_app/__init__.py", + "size": 0, + "type": "commit_file" + }, + { + "attributes": [], + "commit": { + "hash": "a682892aa0f4502ef7a242f75d72d0b41eaa27b9" + }, + "path": "src/test_app/math.py", + "size": 32, + "type": "commit_file" + } + ] + }, + "headers": { + "content-type": "application/json; charset=utf-8" + }, + "status": 200 +} diff --git a/tests/unit/provider/fixtures/bitbucket/src_special.json b/tests/unit/provider/fixtures/bitbucket/src_special.json new file mode 100644 index 00000000..50360d58 --- /dev/null +++ b/tests/unit/provider/fixtures/bitbucket/src_special.json @@ -0,0 +1,43 @@ +{ + "body": { + "values": [ + { + "attributes": [ + "subrepository" + ], + "commit": { + "hash": "01a69636e47e2c25b7000087e2386c393dc46624" + }, + "path": "generated/special/sub", + "size": 40, + "type": "commit_file" + }, + { + "attributes": [ + "link" + ], + "commit": { + "hash": "01a69636e47e2c25b7000087e2386c393dc46624" + }, + "path": "generated/special/link", + "size": 6, + "type": "commit_file" + }, + { + "attributes": [ + "executable" + ], + "commit": { + "hash": "01a69636e47e2c25b7000087e2386c393dc46624" + }, + "path": "generated/special/run.sh", + "size": 18, + "type": "commit_file" + } + ] + }, + "headers": { + "content-type": "application/json; charset=utf-8" + }, + "status": 200 +} diff --git a/tests/unit/provider/fixtures/bitbucket/src_subdirectory.json b/tests/unit/provider/fixtures/bitbucket/src_subdirectory.json new file mode 100644 index 00000000..59c9b981 --- /dev/null +++ b/tests/unit/provider/fixtures/bitbucket/src_subdirectory.json @@ -0,0 +1,35 @@ +{ + "body": { + "values": [ + { + "commit": { + "hash": "a682892aa0f4502ef7a242f75d72d0b41eaa27b9" + }, + "path": "src/test_app", + "type": "commit_directory" + }, + { + "attributes": [], + "commit": { + "hash": "a682892aa0f4502ef7a242f75d72d0b41eaa27b9" + }, + "path": "src/test_app/__init__.py", + "size": 0, + "type": "commit_file" + }, + { + "attributes": [], + "commit": { + "hash": "a682892aa0f4502ef7a242f75d72d0b41eaa27b9" + }, + "path": "src/test_app/math.py", + "size": 32, + "type": "commit_file" + } + ] + }, + "headers": { + "content-type": "application/json; charset=utf-8" + }, + "status": 200 +} diff --git a/tests/unit/provider/test_bitbucket.py b/tests/unit/provider/test_bitbucket.py index 3e21908d..c55316ef 100644 --- a/tests/unit/provider/test_bitbucket.py +++ b/tests/unit/provider/test_bitbucket.py @@ -31,10 +31,13 @@ GetDirectoryContentsProtocol, GetFileContentProtocol, GetFileUrlProtocol, + GetFullTreeProtocol, + GetGitCommitProtocol, GetGitRemoteProtocol, GetPullRequestTemplateProtocol, GetReadmeProtocol, GetRepositoryProtocol, + GetTreeProtocol, Repository, ) @@ -58,6 +61,9 @@ GetCommitsUrlProtocol, GetCommitUrlProtocol, GetRepositoryProtocol, + GetFullTreeProtocol, + GetGitCommitProtocol, + GetTreeProtocol, ] COMMIT_SHA = "e723f15089c22737bb51aee8db06e0eab91e4ba1" HEAD_SHA = "e3ef83d1a831b7b26c838372f668b1627bbf9a1a" @@ -65,6 +71,9 @@ TEST_AUTHOR = {"name": "Test User", "email": "test@example.com", "date": datetime(2026, 11, 5, 20, 16, 34, tzinfo=UTC)} +COMMIT = "a682892aa0f4502ef7a242f75d72d0b41eaa27b9" + + def recorded(name: str) -> requests.Response: """Build a response from a fixture recorded against Bitbucket Cloud.""" fixture = json.loads((FIXTURES / f"{name}.json").read_text()) @@ -632,3 +641,148 @@ def test_split_patches_skips_files_without_hunks(self) -> None: "diff --git a/a.py b/a.py\n--- a/a.py\n+++ b/a.py\n@@ -1 +1 @@\n-x\n+y\n" ) assert split_patches(diff) == {"a.py": "@@ -1 +1 @@\n-x\n+y\n"} + + +class TestGetGitCommit: + def test_names_tree_by_commit(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.return_value = recorded("git_commit") + + result = provider.get_git_commit(COMMIT) + + assert request_kwargs(client)["path"] == f"/2.0/repositories/{REPO}/commit/{COMMIT}" + assert result["data"] == { + "sha": COMMIT, + "tree": {"sha": COMMIT}, + "message": "Add pull request template\n", + } + + +class TestGetTree: + def test_recursive_page(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.return_value = recorded("src_recursive_page_1") + + result = provider.get_tree(COMMIT, pagination={"cursor": "1", "per_page": 5}) + + assert request_kwargs(client)["path"] == f"/2.0/repositories/{REPO}/src/{COMMIT}/" + assert request_kwargs(client)["params"]["max_depth"] == str(2**31 - 1) + assert request_kwargs(client)["params"]["pagelen"] == "5" + assert result["meta"]["next_cursor"] == "rXtr" + assert result["data"]["truncated"] is True + assert result["data"]["sha"] == COMMIT + assert result["data"]["tree"][:3] == [ + {"path": "src", "mode": "040000", "type": "tree", "sha": f"{COMMIT}:src", "size": None}, + {"path": "tests", "mode": "040000", "type": "tree", "sha": f"{COMMIT}:tests", "size": None}, + { + "path": "PULL_REQUEST_TEMPLATE.md", + "mode": "100644", + "type": "blob", + "sha": f"{COMMIT}:PULL_REQUEST_TEMPLATE.md", + "size": 25, + }, + ] + + def test_not_recursive(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.return_value = recorded("src_special") + + provider.get_tree(COMMIT, recursive=False) + + assert "max_depth" not in request_kwargs(client)["params"] + assert request_kwargs(client)["params"]["pagelen"] == "100" + + def test_subtree_paths_are_relative(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.return_value = recorded("src_subdirectory") + + result = provider.get_tree(f"{COMMIT}:src") + + assert request_kwargs(client)["path"] == f"/2.0/repositories/{REPO}/src/{COMMIT}/src/" + assert result["data"]["truncated"] is False + assert [(e["path"], e["sha"]) for e in result["data"]["tree"]] == [ + ("test_app", f"{COMMIT}:src/test_app"), + ("test_app/__init__.py", f"{COMMIT}:src/test_app/__init__.py"), + ("test_app/math.py", f"{COMMIT}:src/test_app/math.py"), + ] + + def test_entry_modes(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.return_value = recorded("src_special") + + result = provider.get_tree("01a69636e47e2c25b7000087e2386c393dc46624:generated/special", recursive=False) + + assert [(e["path"], e["mode"], e["type"], e["size"]) for e in result["data"]["tree"]] == [ + ("sub", "160000", "commit", None), + ("link", "120000", "blob", 6), + ("run.sh", "100755", "blob", 18), + ] + + def test_quotes_path(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.return_value = recorded("src_subdirectory") + + provider.get_tree(f"{COMMIT}:a dir") + + assert request_kwargs(client)["path"] == f"/2.0/repositories/{REPO}/src/{COMMIT}/a%20dir/" + + def test_resolves_ref_with_slash(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.side_effect = [recorded("commits_branch_head"), recorded("src_subdirectory")] + + result = provider.get_tree("feature/x:src") + + paths = [call.kwargs["path"] for call in client.request.call_args_list] + assert paths == [ + f"/2.0/repositories/{REPO}/commits/feature/x", + f"/2.0/repositories/{REPO}/src/{COMMIT}/src/", + ] + assert result["data"]["sha"] == "feature/x:src" + + def test_file_path_is_not_a_tree(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + response = requests.Response() + response.status_code = 200 + response.headers["content-type"] = "text/plain" + response._content = b"#!/bin/sh\necho hi\n" + client.request.return_value = response + + with pytest.raises(PathIsNotDirectory): + provider.get_tree(f"{COMMIT}:run.sh") + + def test_dot_segments_raise(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + with pytest.raises(ResourceBadRequest): + provider.get_tree(f"{COMMIT}:../../../other-repo/src/HEAD/docs") + client.request.assert_not_called() + + +class TestGetFullTree: + def test_walks_every_page(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.side_effect = [recorded("src_recursive_page_1"), recorded("src_recursive_page_2")] + + result = provider.get_full_tree(COMMIT) + + pages = [call.kwargs["params"] for call in client.request.call_args_list] + assert "page" not in pages[0] + assert pages[1]["page"] == "rXtr" + assert result["data"]["truncated"] is False + assert [e["path"] for e in result["data"]["tree"]] == [ + "src", + "tests", + "PULL_REQUEST_TEMPLATE.md", + "README.md", + "src/test_app", + "tests/test_math.py", + "src/test_app/__init__.py", + "src/test_app/math.py", + ] + + def test_resolves_ref_with_slash_once(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.side_effect = [ + recorded("commits_branch_head"), + recorded("src_recursive_page_1"), + recorded("src_recursive_page_2"), + ] + + result = provider.get_full_tree("feature/x") + + paths = [call.kwargs["path"] for call in client.request.call_args_list] + assert paths == [ + f"/2.0/repositories/{REPO}/commits/feature/x", + f"/2.0/repositories/{REPO}/src/{COMMIT}/", + f"/2.0/repositories/{REPO}/src/{COMMIT}/", + ] + assert result["data"]["sha"] == "feature/x" + assert len(result["data"]["tree"]) == 8 From 452df3a9cb7a83847bdb2aea7efc80f5171ce5cf Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Mon, 9 Nov 2026 12:15:29 -0800 Subject: [PATCH 2/3] ref(bitbucket): Reuse the ref and src path helpers for trees --- src/scm/providers/bitbucket/provider.py | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/src/scm/providers/bitbucket/provider.py b/src/scm/providers/bitbucket/provider.py index 653b6dd0..c8c289f0 100644 --- a/src/scm/providers/bitbucket/provider.py +++ b/src/scm/providers/bitbucket/provider.py @@ -85,10 +85,7 @@ "renamed": "renamed", } _RAW_AUTHOR = re.compile(r"^(?P.*?)\s*<(?P[^>]*)>\s*$") - - _MAX_TREE_DEPTH = str(2**31 - 1) - _TREE_FIELDS = "next,values.path,values.type,values.size,values.attributes,values.commit.hash" @@ -525,17 +522,7 @@ def get_full_tree( def _split_tree_sha(self, tree_sha: SHA, request_options: RequestOptions | None) -> tuple[SHA, str]: ref, _, path = tree_sha.partition(":") - if "/" not in ref: - return ref, path - # `src` reads the first path segment as the revision, so resolve refs like `feature/x` first. - response = self.get( - f"/2.0/repositories/{self.repository_path}/commits/{quote(ref)}", - params={"pagelen": "1", "fields": "values.hash"}, - request_options=request_options, - ) - if not (values := response.json()["values"]): - raise ResourceNotFound(detail=f"No commit for {ref!r}") - return values[0]["hash"], path + return self._resolve_ref(ref, request_options), path def _get_tree_page( self, @@ -550,7 +537,7 @@ def _get_tree_page( if recursive: params["max_depth"] = _MAX_TREE_DEPTH response = self.get( - f"/2.0/repositories/{self.repository_path}/src/{quote(commit)}/{quote(f'{path}/' if path else '')}", + self._src_path(commit, path).rstrip("/") + "/", params=params, pagination=pagination or {"per_page": MAX_PAGE_SIZE}, request_options=request_options, @@ -702,7 +689,7 @@ def map_tree_entry(raw: dict[str, Any], tree_path: str) -> TreeEntry: def tree_entry_kind(raw: dict[str, Any]) -> tuple[TreeEntryMode, TreeEntryType]: if raw["type"] == "commit_directory": return "040000", "tree" - attributes = raw.get("attributes", []) + attributes = raw.get("attributes") or [] if "subrepository" in attributes: return "160000", "commit" if "link" in attributes: From 823386769d9ce0a7155e91458790575b71805fe2 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Mon, 9 Nov 2026 12:25:20 -0800 Subject: [PATCH 3/3] test(bitbucket): Cover tree fields and null attributes --- bin/bitbucket-client | 10 ++-------- tests/unit/provider/test_bitbucket.py | 8 ++++++++ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/bin/bitbucket-client b/bin/bitbucket-client index 699c7667..f32c9f95 100755 --- a/bin/bitbucket-client +++ b/bin/bitbucket-client @@ -175,8 +175,7 @@ def main(): p = sub.add_parser("get-tree") p.add_argument("tree_sha") p.add_argument("--non-recursive", action="store_true") - p.add_argument("--cursor", default=None) - p.add_argument("--per-page", type=int, default=None) + add_pagination(p) p = sub.add_parser("get-full-tree") p.add_argument("tree_sha") @@ -264,12 +263,7 @@ def main(): elif args.command == "get-tree": assert isinstance(scm, GetTreeProtocol) - pagination: PaginationParams = {} - if args.cursor is not None: - pagination["cursor"] = args.cursor - if args.per_page is not None: - pagination["per_page"] = args.per_page - dump(scm.get_tree(args.tree_sha, recursive=not args.non_recursive, pagination=pagination or None)) + dump(scm.get_tree(args.tree_sha, recursive=not args.non_recursive, pagination=pagination_args(args))) elif args.command == "get-full-tree": assert isinstance(scm, GetFullTreeProtocol) diff --git a/tests/unit/provider/test_bitbucket.py b/tests/unit/provider/test_bitbucket.py index c55316ef..f3b3ee99 100644 --- a/tests/unit/provider/test_bitbucket.py +++ b/tests/unit/provider/test_bitbucket.py @@ -15,6 +15,7 @@ map_commit_author, map_paginated_action, map_repository, + map_tree_entry, next_cursor, parse_scopes, split_patches, @@ -666,6 +667,9 @@ def test_recursive_page(self, provider: BitbucketProvider, client: unittest.mock assert request_kwargs(client)["path"] == f"/2.0/repositories/{REPO}/src/{COMMIT}/" assert request_kwargs(client)["params"]["max_depth"] == str(2**31 - 1) assert request_kwargs(client)["params"]["pagelen"] == "5" + assert request_kwargs(client)["params"]["fields"] == ( + "next,values.path,values.type,values.size,values.attributes,values.commit.hash" + ) assert result["meta"]["next_cursor"] == "rXtr" assert result["data"]["truncated"] is True assert result["data"]["sha"] == COMMIT @@ -713,6 +717,10 @@ def test_entry_modes(self, provider: BitbucketProvider, client: unittest.mock.Ma ("run.sh", "100755", "blob", 18), ] + def test_null_attributes_is_a_regular_file(self) -> None: + raw = {"path": "a.py", "type": "commit_file", "size": 1, "attributes": None, "commit": {"hash": COMMIT}} + assert map_tree_entry(raw, "")["mode"] == "100644" + def test_quotes_path(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: client.request.return_value = recorded("src_subdirectory")