diff --git a/bin/bitbucket-client b/bin/bitbucket-client index 7edfa73..cf0310d 100755 --- a/bin/bitbucket-client +++ b/bin/bitbucket-client @@ -12,6 +12,7 @@ Configuration (CLI args override env vars): --repo / BITBUCKET_REPO Bitbucket repository as / Commands: + get-authenticated-actor get-repository get-app-installation get-git-remote @@ -33,6 +34,10 @@ Commands: get-pull-request-commits get-pull-request-diff get-pull-request-url + get-pull-request-comments [--cursor ] [--per-page ] + create-pull-request-comment + delete-pull-request-comment + get-pull-request-review-threads """ import argparse @@ -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, @@ -58,10 +66,12 @@ from scm.types import ( GetFileContentProtocol, GetFileUrlProtocol, GetGitRemoteProtocol, + GetPullRequestCommentsProtocol, GetPullRequestCommitsProtocol, GetPullRequestDiffProtocol, GetPullRequestFilesProtocol, GetPullRequestProtocol, + GetPullRequestReviewThreadsProtocol, GetPullRequestsProtocol, GetPullRequestTemplateProtocol, GetPullRequestUrlProtocol, @@ -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") @@ -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() @@ -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()) @@ -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) diff --git a/src/scm/providers/bitbucket/provider.py b/src/scm/providers/bitbucket/provider.py index c2b8d8d..1341ed7 100644 --- a/src/scm/providers/bitbucket/provider.py +++ b/src/scm/providers/bitbucket/provider.py @@ -17,6 +17,7 @@ ResourceForbidden, ResourceNotFound, SCMCodedError, + UnexpectedResponseFormat, error_class_for_status, ) from scm.helpers import iter_all_pages @@ -27,11 +28,13 @@ AppInstallation, Author, BranchName, + Comment, Commit, CommitAuthor, CommitComparison, CommitFile, CommitWithChanges, + CoPilotChatExtension, CredentialsSet, FileContent, FileContentType, @@ -48,6 +51,8 @@ PullRequestState, Repository, RequestOptions, + ReviewThread, + ReviewThreadComment, ) PROVIDER_TYPE: ProviderName = "bitbucket" @@ -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: @@ -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()} @@ -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")): @@ -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)}, diff --git a/tests/unit/provider/fixtures/bitbucket/pull_request_comment_created.json b/tests/unit/provider/fixtures/bitbucket/pull_request_comment_created.json new file mode 100644 index 0000000..684f55b --- /dev/null +++ b/tests/unit/provider/fixtures/bitbucket/pull_request_comment_created.json @@ -0,0 +1,53 @@ +{ + "body": { + "content": { + "html": "

Test created comment

", + "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 +} diff --git a/tests/unit/provider/fixtures/bitbucket/pull_request_comments.json b/tests/unit/provider/fixtures/bitbucket/pull_request_comments.json new file mode 100644 index 0000000..d091c52 --- /dev/null +++ b/tests/unit/provider/fixtures/bitbucket/pull_request_comments.json @@ -0,0 +1,121 @@ +{ + "body": { + "next": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/1/comments?page=2", + "page": 1, + "pagelen": 10, + "size": 12, + "values": [ + { + "content": { + "html": "

Test comment

", + "markup": "markdown", + "raw": "Test comment", + "type": "rendered" + }, + "created_on": "2026-11-05T20:42:57.357805+00:00", + "deleted": false, + "id": 897197941, + "links": { + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/pull-requests/1/_/diff#comment-897197941" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/1/comments/897197941" + } + }, + "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:42:57.375829+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}" + } + }, + { + "content": { + "html": "

Test reply

", + "markup": "markdown", + "raw": "Test reply", + "type": "rendered" + }, + "created_on": "2026-11-05T20:42:57.876427+00:00", + "deleted": false, + "id": 897197945, + "links": { + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/pull-requests/1/_/diff#comment-897197945" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/1/comments/897197945" + } + }, + "parent": { + "id": 897197941, + "links": { + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/pull-requests/1/_/diff#comment-897197941" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/1/comments/897197941" + } + } + }, + "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:42:57.887019+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": 200 +} diff --git a/tests/unit/provider/fixtures/bitbucket/pull_request_comments_inline.json b/tests/unit/provider/fixtures/bitbucket/pull_request_comments_inline.json new file mode 100644 index 0000000..2c30112 --- /dev/null +++ b/tests/unit/provider/fixtures/bitbucket/pull_request_comments_inline.json @@ -0,0 +1,559 @@ +{ + "body": { + "page": 1, + "pagelen": 100, + "size": 8, + "values": [ + { + "content": { + "html": "

Test inline comment

", + "markup": "markdown", + "raw": "Test inline comment", + "type": "rendered" + }, + "created_on": "2026-11-05T20:42:58.513531+00:00", + "deleted": false, + "id": 897197949, + "inline": { + "base_rev": null, + "context_lines": "--- a/src/test_app/math.py\n+++ b/src/test_app/math.py\n@@ -1,2 +1,4 @@\n def add(a, b):\n return a + b\n+def subtract(a, b):\n+ return a - b\n", + "dest_rev": "a682892aa0f4", + "from": null, + "outdated": false, + "path": "src/test_app/math.py", + "src_rev": "c495d0431cfa", + "start_from": null, + "start_to": null, + "to": 3 + }, + "links": { + "code": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/diff/test-workspace/test-repo:c495d0431cfa..a682892aa0f4?path=src%2Ftest_app%2Fmath.py" + }, + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/pull-requests/1/_/diff#comment-897197949" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/1/comments/897197949" + } + }, + "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" + }, + "resolution": { + "type": "comment_resolution" + }, + "type": "pullrequest_comment", + "updated_on": "2026-11-05T20:43:10.711010+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}" + } + }, + { + "content": { + "html": "

Test inline reply

", + "markup": "markdown", + "raw": "Test inline reply", + "type": "rendered" + }, + "created_on": "2026-11-05T20:42:59.180418+00:00", + "deleted": false, + "id": 897197952, + "inline": { + "base_rev": null, + "context_lines": "", + "dest_rev": "a682892aa0f4", + "from": null, + "outdated": false, + "path": "src/test_app/math.py", + "src_rev": "c495d0431cfa", + "start_from": null, + "start_to": null, + "to": 3 + }, + "links": { + "code": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/diff/test-workspace/test-repo:c495d0431cfa..a682892aa0f4?path=src%2Ftest_app%2Fmath.py" + }, + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/pull-requests/1/_/diff#comment-897197952" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/1/comments/897197952" + } + }, + "parent": { + "id": 897197949, + "links": { + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/pull-requests/1/_/diff#comment-897197949" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/1/comments/897197949" + } + } + }, + "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:42:59.190806+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}" + } + }, + { + "content": { + "html": "", + "markup": "markdown", + "raw": "", + "type": "rendered" + }, + "created_on": "2026-11-05T20:42:59.619424+00:00", + "deleted": true, + "id": 897197953, + "inline": { + "base_rev": null, + "context_lines": "--- a/src/test_app/math.py\n+++ b/src/test_app/math.py\n@@ -0,0 +3,2 @@\n+def subtract(a, b):\n+ return a - b\n", + "dest_rev": "a682892aa0f4", + "from": null, + "outdated": false, + "path": "src/test_app/math.py", + "src_rev": "c495d0431cfa", + "start_from": null, + "start_to": 3, + "to": 4 + }, + "links": { + "code": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/diff/test-workspace/test-repo:c495d0431cfa..a682892aa0f4?path=src%2Ftest_app%2Fmath.py" + }, + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/pull-requests/1/_/diff#comment-897197953" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/1/comments/897197953" + } + }, + "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:42:59.627539+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}" + } + }, + { + "content": { + "html": "", + "markup": "markdown", + "raw": "", + "type": "rendered" + }, + "created_on": "2026-11-05T20:43:22.859610+00:00", + "deleted": true, + "id": 897198125, + "inline": { + "base_rev": null, + "context_lines": "--- a/src/test_app/math.py\n+++ b/src/test_app/math.py\n@@ -1,2 +1,4 @@\n def add(a, b):\n return a + b\n+def subtract(a, b):\n+ return a - b\n", + "dest_rev": "a682892aa0f4", + "from": null, + "outdated": false, + "path": "src/test_app/math.py", + "src_rev": "c495d0431cfa", + "start_from": null, + "start_to": null, + "to": 4 + }, + "links": { + "code": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/diff/test-workspace/test-repo:c495d0431cfa..a682892aa0f4?path=src%2Ftest_app%2Fmath.py" + }, + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/pull-requests/1/_/diff#comment-897198125" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/1/comments/897198125" + } + }, + "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:43:22.865758+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}" + } + }, + { + "content": { + "html": "", + "markup": "markdown", + "raw": "", + "type": "rendered" + }, + "created_on": "2026-11-05T20:43:23.441497+00:00", + "deleted": true, + "id": 897198127, + "inline": { + "base_rev": null, + "context_lines": "", + "dest_rev": "a682892aa0f4", + "from": null, + "outdated": false, + "path": "src/test_app/math.py", + "src_rev": "c495d0431cfa", + "start_from": null, + "start_to": null, + "to": 4 + }, + "links": { + "code": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/diff/test-workspace/test-repo:c495d0431cfa..a682892aa0f4?path=src%2Ftest_app%2Fmath.py" + }, + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/pull-requests/1/_/diff#comment-897198127" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/1/comments/897198127" + } + }, + "parent": { + "id": 897198125, + "links": { + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/pull-requests/1/_/diff#comment-897198125" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/1/comments/897198125" + } + } + }, + "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:43:23.451102+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}" + } + }, + { + "content": { + "html": "

Test multiline comment

", + "markup": "markdown", + "raw": "Test multiline comment", + "type": "rendered" + }, + "created_on": "2026-11-05T20:45:39.431349+00:00", + "deleted": false, + "id": 897199175, + "inline": { + "base_rev": null, + "context_lines": "--- a/src/test_app/math.py\n+++ b/src/test_app/math.py\n@@ -0,0 +3,2 @@\n+def subtract(a, b):\n+ return a - b\n", + "dest_rev": "a682892aa0f4", + "from": null, + "outdated": false, + "path": "src/test_app/math.py", + "src_rev": "c495d0431cfa", + "start_from": null, + "start_to": 3, + "to": 4 + }, + "links": { + "code": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/diff/test-workspace/test-repo:c495d0431cfa..a682892aa0f4?path=src%2Ftest_app%2Fmath.py" + }, + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/pull-requests/1/_/diff#comment-897199175" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/1/comments/897199175" + } + }, + "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:39.436281+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}" + } + }, + { + "content": { + "html": "", + "markup": "markdown", + "raw": "", + "type": "rendered" + }, + "created_on": "2026-11-05T20:45:39.992298+00:00", + "deleted": true, + "id": 897199181, + "inline": { + "base_rev": null, + "context_lines": "--- a/src/test_app/math.py\n+++ b/src/test_app/math.py\n@@ -1,2 +1,4 @@\n def add(a, b):\n return a + b\n+def subtract(a, b):\n+ return a - b\n", + "dest_rev": "a682892aa0f4", + "from": 2, + "outdated": false, + "path": "src/test_app/math.py", + "src_rev": "c495d0431cfa", + "start_from": null, + "start_to": null, + "to": null + }, + "links": { + "code": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/diff/test-workspace/test-repo:c495d0431cfa..a682892aa0f4?path=src%2Ftest_app%2Fmath.py" + }, + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/pull-requests/1/_/diff#comment-897199181" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/1/comments/897199181" + } + }, + "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:39.999192+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}" + } + }, + { + "content": { + "html": "

Test reply to removed comment

", + "markup": "markdown", + "raw": "Test reply to removed comment", + "type": "rendered" + }, + "created_on": "2026-11-05T20:45:40.581756+00:00", + "deleted": false, + "id": 897199187, + "inline": { + "base_rev": null, + "context_lines": "", + "dest_rev": "a682892aa0f4", + "from": 2, + "outdated": false, + "path": "src/test_app/math.py", + "src_rev": "c495d0431cfa", + "start_from": null, + "start_to": null, + "to": null + }, + "links": { + "code": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/diff/test-workspace/test-repo:c495d0431cfa..a682892aa0f4?path=src%2Ftest_app%2Fmath.py" + }, + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/pull-requests/1/_/diff#comment-897199187" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/1/comments/897199187" + } + }, + "parent": { + "id": 897199181, + "links": { + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/pull-requests/1/_/diff#comment-897199181" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/1/comments/897199181" + } + } + }, + "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:40.595014+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": 200 +} diff --git a/tests/unit/provider/fixtures/bitbucket/user.json b/tests/unit/provider/fixtures/bitbucket/user.json new file mode 100644 index 0000000..85bfd17 --- /dev/null +++ b/tests/unit/provider/fixtures/bitbucket/user.json @@ -0,0 +1,16 @@ +{ + "body": { + "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": 200 +} diff --git a/tests/unit/provider/test_bitbucket.py b/tests/unit/provider/test_bitbucket.py index 600e5f6..3dd097c 100644 --- a/tests/unit/provider/test_bitbucket.py +++ b/tests/unit/provider/test_bitbucket.py @@ -8,7 +8,15 @@ import pytest import requests -from scm.errors import PathIsDirectory, PathIsNotDirectory, ReadmeNotFound, ResourceBadRequest, ResourceNotFound +from scm.errors import ( + PathIsDirectory, + PathIsNotDirectory, + ReadmeNotFound, + ResourceBadRequest, + ResourceForbidden, + ResourceNotFound, + UnexpectedResponseFormat, +) from scm.providers.bitbucket.provider import ( BitbucketProvider, map_app_installation, @@ -23,7 +31,10 @@ ) from scm.types import ( CompareCommitsProtocol, + CreatePullRequestCommentProtocol, + DeletePullRequestCommentProtocol, GetAppInstallationProtocol, + GetAuthenticatedActorProtocol, GetCommitChangesProtocol, GetCommitProtocol, GetCommitsByPathProtocol, @@ -34,10 +45,12 @@ GetFileContentProtocol, GetFileUrlProtocol, GetGitRemoteProtocol, + GetPullRequestCommentsProtocol, GetPullRequestCommitsProtocol, GetPullRequestDiffProtocol, GetPullRequestFilesProtocol, GetPullRequestProtocol, + GetPullRequestReviewThreadsProtocol, GetPullRequestsProtocol, GetPullRequestTemplateProtocol, GetPullRequestUrlProtocol, @@ -73,6 +86,11 @@ GetPullRequestProtocol, GetPullRequestsProtocol, GetPullRequestUrlProtocol, + CreatePullRequestCommentProtocol, + DeletePullRequestCommentProtocol, + GetAuthenticatedActorProtocol, + GetPullRequestCommentsProtocol, + GetPullRequestReviewThreadsProtocol, ] COMMIT_SHA = "e723f15089c22737bb51aee8db06e0eab91e4ba1" HEAD_SHA = "e3ef83d1a831b7b26c838372f668b1627bbf9a1a" @@ -87,6 +105,15 @@ BASE_HASH = "a682892aa0f4502ef7a242f75d72d0b41eaa27b9" +APP_USER = { + "type": "app_user", + "uuid": "{00000000-0000-0000-0000-000000000002}", + "display_name": "Test App", + "account_id": "557058:00000000-0000-0000-0000-000000000002", + "kind": "connect_app", +} + + 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()) @@ -1021,3 +1048,214 @@ def test_returns_diff_text(self, provider: BitbucketProvider, client: unittest.m def test_get_pull_request_url(provider: BitbucketProvider) -> None: assert provider.get_pull_request_url("1") == f"https://bitbucket.org/{REPO}/pull-requests/1" + + +def test_get_authenticated_actor(provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.return_value = recorded("user") + + result = provider.get_authenticated_actor() + + assert request_kwargs(client)["path"] == "/2.0/user" + assert result["data"] == TEST_USER + + +def test_get_authenticated_actor_app_user(provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.return_value = json_response(APP_USER) + + result = provider.get_authenticated_actor() + + assert result["data"] == {"id": APP_USER["uuid"], "username": "Test App"} + + +def test_get_authenticated_actor_without_uuid(provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.return_value = json_response({"type": "app_user"}) + + with pytest.raises(UnexpectedResponseFormat): + provider.get_authenticated_actor() + + +def test_get_authenticated_actor_forbidden(provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.return_value = json_response({"type": "error"}, status=403) + + with pytest.raises(ResourceForbidden): + provider.get_authenticated_actor() + + +class TestPullRequestComments: + def test_lists_general_comments(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.return_value = recorded("pull_request_comments") + + result = provider.get_pull_request_comments("1") + + assert request_kwargs(client)["path"] == f"/2.0/repositories/{REPO}/pullrequests/1/comments" + assert request_kwargs(client)["params"] == {"q": "inline.path = null AND deleted = false"} + assert result["data"] == [ + { + "id": "897197941", + "body": "Test comment", + "author": TEST_USER, + "created_at": "2026-11-05T20:42:57.357805+00:00", + "author_association": None, + }, + { + "id": "897197945", + "body": "Test reply", + "author": TEST_USER, + "created_at": "2026-11-05T20:42:57.876427+00:00", + "author_association": None, + }, + ] + assert result["meta"]["next_cursor"] == "2" + + def test_passes_pagination(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.return_value = recorded("pull_request_comments") + + provider.get_pull_request_comments("1", pagination={"cursor": "3", "per_page": 5}) + + assert request_kwargs(client)["params"] == { + "q": "inline.path = null AND deleted = false", + "pagelen": "5", + "page": "3", + } + + def test_skips_pending_comments(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + body = recorded("pull_request_comments").json() + body["values"][0]["pending"] = True + client.request.return_value = json_response(body) + + result = provider.get_pull_request_comments("1") + + assert [comment["id"] for comment in result["data"]] == ["897197945"] + + def test_maps_app_and_missing_authors(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + body = recorded("pull_request_comments").json() + body["values"][0]["user"] = APP_USER + del body["values"][1]["user"] + client.request.return_value = json_response(body) + + result = provider.get_pull_request_comments("1") + + assert [comment["author"] for comment in result["data"]] == [ + {"id": APP_USER["uuid"], "username": "Test App"}, + None, + ] + + def test_create(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.return_value = recorded("pull_request_comment_created") + + result = provider.create_pull_request_comment("1", "Test created comment") + + assert request_kwargs(client)["method"] == "POST" + assert request_kwargs(client)["path"] == f"/2.0/repositories/{REPO}/pullrequests/1/comments" + assert request_kwargs(client)["data"] == {"content": {"raw": "Test created comment"}} + assert result["data"]["id"] == "897199302" + assert result["data"]["body"] == "Test created comment" + assert result["data"]["author"] == TEST_USER + + def test_delete(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + response = requests.Response() + response.status_code = 204 + client.request.return_value = response + + provider.delete_pull_request_comment("1", "897199302") + + assert request_kwargs(client)["method"] == "DELETE" + assert request_kwargs(client)["path"] == f"/2.0/repositories/{REPO}/pullrequests/1/comments/897199302" + + +class TestGetPullRequestReviewThreads: + def test_groups_inline_comments(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.return_value = recorded("pull_request_comments_inline") + + result = provider.get_pull_request_review_threads("1") + + assert request_kwargs(client)["path"] == f"/2.0/repositories/{REPO}/pullrequests/1/comments" + assert request_kwargs(client)["params"] == { + "q": "inline.path != null", + "fields": "+values.resolution.type,+values.inline.*", + "pagelen": "100", + } + threads = result["data"] + assert [ + (t["id"], t["is_resolved"], t["file_path"], t["line"], t["start_line"], [c["body"] for c in t["comments"]]) + for t in threads + ] == [ + ("897197949", True, "src/test_app/math.py", 3, None, ["Test inline comment", "Test inline reply"]), + ("897199175", False, "src/test_app/math.py", 4, 3, ["Test multiline comment"]), + ("897199181", False, "src/test_app/math.py", 2, None, ["Test reply to removed comment"]), + ] + assert threads[0]["is_outdated"] is False + assert threads[0]["comments"][1] == { + "id": "897197952", + "unique_id": "897197952", + "body": "Test inline reply", + "author": TEST_USER, + "is_bot": False, + "created_at": "2026-11-05T20:42:59.180418+00:00", + "updated_at": "2026-11-05T20:42:59.190806+00:00", + "is_minimized": False, + "commit_sha": "c495d0431cfa", + } + assert result["meta"]["next_cursor"] is None + + def test_reads_every_page(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + body = recorded("pull_request_comments_inline").json() + pages = [] + for values, next_url in [ + (body["values"][:1], "https://api.bitbucket.org/2.0/x?page=2"), + (body["values"][1:], None), + ]: + pages.append(json_response({**body, "values": values, "next": next_url})) + client.request.side_effect = pages + + threads = provider.get_pull_request_review_threads("1")["data"] + + assert client.request.call_args_list[1].kwargs["params"]["page"] == "2" + assert [c["body"] for c in threads[0]["comments"]] == ["Test inline comment", "Test inline reply"] + + def test_skips_pending_comments(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + body = recorded("pull_request_comments_inline").json() + body["values"][1]["pending"] = True + client.request.return_value = json_response(body) + + thread = provider.get_pull_request_review_threads("1")["data"][0] + + assert [c["body"] for c in thread["comments"]] == ["Test inline comment"] + + def test_reply_without_its_parent_starts_a_thread( + self, provider: BitbucketProvider, client: unittest.mock.MagicMock + ) -> None: + body = recorded("pull_request_comments_inline").json() + client.request.return_value = json_response({**body, "values": body["values"][1:2]}) + + threads = provider.get_pull_request_review_threads("1")["data"] + + assert [(t["id"], [c["body"] for c in t["comments"]]) for t in threads] == [ + ("897197952", ["Test inline reply"]), + ] + + def test_multiline_comment_on_removed_lines( + self, provider: BitbucketProvider, client: unittest.mock.MagicMock + ) -> None: + body = recorded("pull_request_comments_inline").json() + body["values"][0]["inline"].update({"to": None, "from": 5, "start_to": None, "start_from": 4}) + client.request.return_value = json_response(body) + + thread = provider.get_pull_request_review_threads("1")["data"][0] + + assert (thread["line"], thread["start_line"]) == (5, 4) + + def test_maps_app_users_and_outdated(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + body = recorded("pull_request_comments_inline").json() + body["values"][0]["user"] = APP_USER + body["values"][0]["inline"]["outdated"] = True + del body["values"][1]["user"] + client.request.return_value = json_response(body) + + thread = provider.get_pull_request_review_threads("1")["data"][0] + + assert thread["is_outdated"] is True + assert [(c["author"], c["is_bot"]) for c in thread["comments"]] == [ + ({"id": APP_USER["uuid"], "username": "Test App"}, True), + (None, False), + ]