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
48 changes: 47 additions & 1 deletion bin/bitbucket-client
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Configuration (CLI args override env vars):
--repo / BITBUCKET_REPO Bitbucket repository as <workspace>/<repo_slug>

Commands:
get-authenticated-actor
get-repository
get-app-installation
get-git-remote
Expand All @@ -33,6 +34,10 @@ Commands:
get-pull-request-commits <id>
get-pull-request-diff <id>
get-pull-request-url <id>
get-pull-request-comments <pr_id> [--cursor <cursor>] [--per-page <n>]
create-pull-request-comment <pr_id> <body>
delete-pull-request-comment <pr_id> <comment_id>
get-pull-request-review-threads <pr_id>
"""

import argparse
Expand All @@ -47,7 +52,10 @@ from scm import Secret
from scm.manager import SourceCodeManager
from scm.types import (
CompareCommitsProtocol,
CreatePullRequestCommentProtocol,
DeletePullRequestCommentProtocol,
GetAppInstallationProtocol,
GetAuthenticatedActorProtocol,
GetCommitChangesProtocol,
GetCommitProtocol,
GetCommitsByPathProtocol,
Expand All @@ -58,10 +66,12 @@ from scm.types import (
GetFileContentProtocol,
GetFileUrlProtocol,
GetGitRemoteProtocol,
GetPullRequestCommentsProtocol,
GetPullRequestCommitsProtocol,
GetPullRequestDiffProtocol,
GetPullRequestFilesProtocol,
GetPullRequestProtocol,
GetPullRequestReviewThreadsProtocol,
GetPullRequestsProtocol,
GetPullRequestTemplateProtocol,
GetPullRequestUrlProtocol,
Expand Down Expand Up @@ -123,6 +133,7 @@ def main():
)

sub = parser.add_subparsers(dest="command")
sub.add_parser("get-authenticated-actor")
sub.add_parser("get-repository")
sub.add_parser("get-app-installation")
sub.add_parser("get-git-remote")
Expand Down Expand Up @@ -196,6 +207,21 @@ def main():
p = sub.add_parser("get-pull-request-url")
p.add_argument("id")

p = sub.add_parser("get-pull-request-comments")
p.add_argument("pr_id")
add_pagination(p)

p = sub.add_parser("create-pull-request-comment")
p.add_argument("pr_id")
p.add_argument("body")

p = sub.add_parser("delete-pull-request-comment")
p.add_argument("pr_id")
p.add_argument("comment_id")

p = sub.add_parser("get-pull-request-review-threads")
p.add_argument("pr_id")

args = parser.parse_args()
if not args.command:
parser.print_help()
Expand All @@ -212,7 +238,11 @@ def main():
**kwargs,
)

if args.command == "get-repository":
if args.command == "get-authenticated-actor":
assert isinstance(scm, GetAuthenticatedActorProtocol)
dump(scm.get_authenticated_actor())

elif args.command == "get-repository":
assert isinstance(scm, GetRepositoryProtocol)
dump(scm.get_repository())

Expand Down Expand Up @@ -296,6 +326,22 @@ def main():
assert isinstance(scm, GetPullRequestUrlProtocol)
dump(scm.get_pull_request_url(args.id))

elif args.command == "get-pull-request-comments":
assert isinstance(scm, GetPullRequestCommentsProtocol)
dump(scm.get_pull_request_comments(args.pr_id, pagination=pagination_args(args)))

elif args.command == "create-pull-request-comment":
assert isinstance(scm, CreatePullRequestCommentProtocol)
dump(scm.create_pull_request_comment(args.pr_id, args.body))

elif args.command == "delete-pull-request-comment":
assert isinstance(scm, DeletePullRequestCommentProtocol)
scm.delete_pull_request_comment(args.pr_id, args.comment_id)

elif args.command == "get-pull-request-review-threads":
assert isinstance(scm, GetPullRequestReviewThreadsProtocol)
dump(scm.get_pull_request_review_threads(args.pr_id))


if __name__ == "__main__":
logging.basicConfig(level=logging.DEBUG)
Expand Down
169 changes: 157 additions & 12 deletions src/scm/providers/bitbucket/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
ResourceForbidden,
ResourceNotFound,
SCMCodedError,
UnexpectedResponseFormat,
error_class_for_status,
)
from scm.helpers import iter_all_pages
Expand All @@ -27,11 +28,13 @@
AppInstallation,
Author,
BranchName,
Comment,
Commit,
CommitAuthor,
CommitComparison,
CommitFile,
CommitWithChanges,
CoPilotChatExtension,
CredentialsSet,
FileContent,
FileContentType,
Expand All @@ -48,6 +51,8 @@
PullRequestState,
Repository,
RequestOptions,
ReviewThread,
ReviewThreadComment,
)

PROVIDER_TYPE: ProviderName = "bitbucket"
Expand Down Expand Up @@ -529,16 +534,9 @@ def get_pull_request_commits(

``pagination`` is unused.
"""
path = f"{self._pull_request_path(pull_request_id)}/commits"
values: list[dict[str, Any]] = []
page: PaginationParams = {"per_page": MAX_PAGE_SIZE}
while True:
response = self.get(path, pagination=page, request_options=request_options)
raw = response.json()
values.extend(raw.get("values", []))
if not (cursor := next_cursor(raw)):
break
page = {"per_page": MAX_PAGE_SIZE, "cursor": cursor}
values, response = self._get_every_page(
f"{self._pull_request_path(pull_request_id)}/commits", request_options=request_options
)
# Once merged, Bitbucket lists its own merge commit first; other providers leave it out.
# A squash commit has one parent, so it stays.
if values and len(values[0]["parents"]) > 1:
Expand Down Expand Up @@ -589,6 +587,84 @@ def _full_hash(self, repository_path: str, short_hash: str, request_options: Req
return short_hash
return response.json()["hash"]

def get_authenticated_actor(self) -> ActionResult[Author]:
response = self.get("/2.0/user")
return map_action(response, map_authenticated_actor)

def get_pull_request_comments(
self,
pull_request_id: str,
pagination: PaginationParams | None = None,
request_options: RequestOptions | None = None,
) -> PaginatedActionResult[list[Comment]]:
"""Return the general comments, leaving out inline ones."""
response = self.get(
self._comments_path(pull_request_id),
# Filter on the server so a page of inline comments doesn't come back empty.
params={"q": "inline.path = null AND deleted = false"},
pagination=pagination,
request_options=request_options,
)
# Pending comments are the caller's own unpublished drafts, and Bitbucket can't filter on them.
return map_paginated_action(response, map_comment, keep=lambda raw: not raw.get("pending"))

def create_pull_request_comment(
self,
pull_request_id: str,
body: str,
extensions: list[CoPilotChatExtension] | None = None,
) -> ActionResult[Comment]:
response = self.post(self._comments_path(pull_request_id), data={"content": {"raw": body}})
return map_action(response, map_comment)

def delete_pull_request_comment(self, pull_request_id: str, comment_id: str) -> None:
self.delete(f"{self._comments_path(pull_request_id)}/{comment_id}")

def get_pull_request_review_threads(
self,
pull_request_id: str,
pagination: PaginationParams | None = None,
request_options: RequestOptions | None = None,
*,
include_reactions: bool = False,
) -> PaginatedActionResult[list[ReviewThread]]:
"""Return the inline comment threads, reading every page since a thread can span pages.

``pagination`` and ``include_reactions`` are unused; Bitbucket has no comment reactions.
"""
comments, response = self._get_every_page(
self._comments_path(pull_request_id),
# The list leaves out resolution and outdated unless asked for.
params={"q": "inline.path != null", "fields": "+values.resolution.type,+values.inline.*"},
request_options=request_options,
)
return {
"data": map_review_threads(comments),
"type": PROVIDER_TYPE,
"raw": {"data": comments, "headers": dict(response.headers)},
"meta": {"next_cursor": None},
}

def _get_every_page(
self,
path: str,
params: dict[str, Any] | None = None,
request_options: RequestOptions | None = None,
) -> tuple[list[dict[str, Any]], requests.Response]:
"""Return the values from every page, and the last page's response."""
values: list[dict[str, Any]] = []
page: PaginationParams = {"per_page": MAX_PAGE_SIZE}
while True:
response = self.get(path, params=params, pagination=page, request_options=request_options)
raw = response.json()
values.extend(raw.get("values", []))
if not (cursor := next_cursor(raw)):
return values, response
page = {"per_page": MAX_PAGE_SIZE, "cursor": cursor}

def _comments_path(self, pull_request_id: str) -> str:
return f"{self._pull_request_path(pull_request_id)}/comments"


def parse_scopes(header: str) -> set[str]:
return {scope.strip() for scope in header.split(",") if scope.strip()}
Expand Down Expand Up @@ -757,6 +833,73 @@ def map_pull_request_commit(raw: dict[str, Any]) -> PullRequestCommit:
)


def map_user(raw: dict[str, Any] | None) -> Author | None:
return map_author(raw) if raw and raw.get("uuid") else None


def map_authenticated_actor(raw: dict[str, Any]) -> Author:
if not (author := map_user(raw)):
raise UnexpectedResponseFormat(detail="Bitbucket /2.0/user response has no uuid")
return author


def map_comment(raw: dict[str, Any]) -> Comment:
return Comment(
id=str(raw["id"]),
body=raw["content"]["raw"],
author=map_user(raw.get("user")),
created_at=raw["created_on"],
author_association=None,
)


def map_review_thread_comment(raw: dict[str, Any]) -> ReviewThreadComment:
return ReviewThreadComment(
id=str(raw["id"]),
unique_id=str(raw["id"]),
body=raw["content"]["raw"],
author=map_user(raw.get("user")),
is_bot=(raw.get("user") or {}).get("type") == "app_user",
created_at=raw["created_on"],
updated_at=raw["updated_on"],
is_minimized=False,
commit_sha=raw["inline"].get("src_rev") or "",
)


def map_review_threads(comments: list[dict[str, Any]]) -> list[ReviewThread]:
"""Group inline comments under their top-level comment, which holds the thread's state."""
by_id = {raw["id"]: raw for raw in comments}

def root(raw: dict[str, Any]) -> dict[str, Any]:
while (parent := raw.get("parent")) and parent["id"] in by_id:
raw = by_id[parent["id"]]
return raw

# A deleted top-level comment still anchors the replies under it.
grouped: dict[int, list[dict[str, Any]]] = {}
for raw in comments:
if not raw.get("deleted") and not raw.get("pending"):
grouped.setdefault(root(raw)["id"], []).append(raw)

threads = []
for root_id, thread_comments in grouped.items():
head = by_id[root_id]
inline = head["inline"]
threads.append(
ReviewThread(
id=str(root_id),
is_resolved=bool(head.get("resolution")),
is_outdated=bool(inline.get("outdated")),
file_path=inline["path"],
line=inline.get("to") or inline.get("from"),
start_line=inline.get("start_to") or inline.get("start_from"),
comments=[map_review_thread_comment(raw) for raw in thread_comments],
)
)
return threads


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 All @@ -776,11 +919,13 @@ def map_action[T](response: requests.Response, fn: Callable[[dict[str, Any]], T]


def map_paginated_action[T](
response: requests.Response, fn: Callable[[dict[str, Any]], T]
response: requests.Response,
fn: Callable[[dict[str, Any]], T],
keep: Callable[[dict[str, Any]], bool] = lambda _: True,
) -> PaginatedActionResult[list[T]]:
raw = response.json()
return {
"data": [fn(item) for item in raw.get("values", [])],
"data": [fn(item) for item in raw.get("values", []) if keep(item)],
"type": PROVIDER_TYPE,
"raw": {"data": raw, "headers": dict(response.headers)},
"meta": {"next_cursor": next_cursor(raw)},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
{
"body": {
"content": {
"html": "<p>Test created comment</p>",
"markup": "markdown",
"raw": "Test created comment",
"type": "rendered"
},
"created_on": "2026-11-05T20:45:56.879502+00:00",
"deleted": false,
"id": 897199302,
"links": {
"html": {
"href": "https://bitbucket.org/test-workspace/test-repo/pull-requests/1/_/diff#comment-897199302"
},
"self": {
"href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/1/comments/897199302"
}
},
"pending": false,
"pullrequest": {
"draft": false,
"id": 1,
"links": {
"html": {
"href": "https://bitbucket.org/test-workspace/test-repo/pull-requests/1"
},
"self": {
"href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/1"
}
},
"queued": false,
"title": "Test pull request",
"type": "pullrequest"
},
"type": "pullrequest_comment",
"updated_on": "2026-11-05T20:45:56.887497+00:00",
"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}"
}
},
"headers": {},
"status": 201
}
Loading
Loading