Skip to content
Draft
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
29 changes: 29 additions & 0 deletions bin/bitbucket-client
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ Commands:
get-commit-url <sha>
get-commits-url <sha> [--file-path <path>] [--since YYYY-MM-DD] [--until YYYY-MM-DD]
compare-commits <start_sha> <end_sha> [--include-behind]
get-git-commit <sha>
get-tree <tree_sha> [--non-recursive] [--cursor <cursor>] [--per-page <n>]
get-full-tree <tree_sha> [--non-recursive]
"""

import argparse
Expand All @@ -51,10 +54,13 @@ from scm.types import (
GetDirectoryContentsProtocol,
GetFileContentProtocol,
GetFileUrlProtocol,
GetFullTreeProtocol,
GetGitCommitProtocol,
GetGitRemoteProtocol,
GetPullRequestTemplateProtocol,
GetReadmeProtocol,
GetRepositoryProtocol,
GetTreeProtocol,
PaginationParams,
)

Expand Down Expand Up @@ -163,6 +169,17 @@ 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")
add_pagination(p)

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:
Expand Down Expand Up @@ -240,6 +257,18 @@ 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)
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)
dump(scm.get_full_tree(args.tree_sha, recursive=not args.non_recursive))


if __name__ == "__main__":
logging.basicConfig(level=logging.DEBUG)
Expand Down
124 changes: 124 additions & 0 deletions src/scm/providers/bitbucket/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,19 @@
FileContent,
FileContentType,
FileStatus,
GitCommitObject,
GitCommitTree,
GitRemote,
GitRepository,
GitTree,
PaginatedActionResult,
PaginationParams,
ProviderName,
Repository,
RequestOptions,
TreeEntry,
TreeEntryMode,
TreeEntryType,
)

PROVIDER_TYPE: ProviderName = "bitbucket"
Expand Down Expand Up @@ -79,6 +85,8 @@
"renamed": "renamed",
}
_RAW_AUTHOR = re.compile(r"^(?P<name>.*?)\s*<(?P<email>[^>]*)>\s*$")
_MAX_TREE_DEPTH = str(2**31 - 1)
_TREE_FIELDS = "next,values.path,values.type,values.size,values.attributes,values.commit.hash"


class BitbucketProvider:
Expand Down Expand Up @@ -463,6 +471,92 @@ 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 `<commit>` or `<commit>:<path>`.

Bitbucket has no tree SHAs, so each entry's `sha` is its `<commit>:<path>` 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(":")
return self._resolve_ref(ref, request_options), 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(
self._src_path(commit, path).rstrip("/") + "/",
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()}
Expand Down Expand Up @@ -575,6 +669,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") or []
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")):
Expand Down
13 changes: 13 additions & 0 deletions tests/unit/provider/fixtures/bitbucket/commits_branch_head.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"body": {
"values": [
{
"hash": "a682892aa0f4502ef7a242f75d72d0b41eaa27b9"
}
]
},
"headers": {
"content-type": "application/json; charset=utf-8"
},
"status": 200
}
95 changes: 95 additions & 0 deletions tests/unit/provider/fixtures/bitbucket/git_commit.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
{
"body": {
"author": {
"raw": "Test User <test@example.com>",
"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": "<p>Add pull request template</p>",
"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": "<p>Add pull request template</p>",
"markup": "markdown",
"raw": "Add pull request template\n",
"type": "rendered"
},
"type": "commit"
},
"headers": {},
"status": 200
}
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading