diff --git a/bin/bitbucket-client b/bin/bitbucket-client index 551e92b..7edfa73 100755 --- a/bin/bitbucket-client +++ b/bin/bitbucket-client @@ -27,6 +27,12 @@ Commands: get-commit-url get-commits-url [--file-path ] [--since YYYY-MM-DD] [--until YYYY-MM-DD] compare-commits [--include-behind] + get-pull-request + get-pull-requests [--state open|closed] [--head ] [--cursor ] [--per-page ] + get-pull-request-files [--cursor ] [--per-page ] + get-pull-request-commits + get-pull-request-diff + get-pull-request-url """ import argparse @@ -52,7 +58,13 @@ from scm.types import ( GetFileContentProtocol, GetFileUrlProtocol, GetGitRemoteProtocol, + GetPullRequestCommitsProtocol, + GetPullRequestDiffProtocol, + GetPullRequestFilesProtocol, + GetPullRequestProtocol, + GetPullRequestsProtocol, GetPullRequestTemplateProtocol, + GetPullRequestUrlProtocol, GetReadmeProtocol, GetRepositoryProtocol, PaginationParams, @@ -163,6 +175,26 @@ 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-pull-request") + p.add_argument("id") + + p = sub.add_parser("get-pull-requests") + p.add_argument("--state", default="open", choices=["open", "closed"]) + p.add_argument("--head", default=None) + add_pagination(p) + + p = sub.add_parser("get-pull-request-files") + p.add_argument("id") + add_pagination(p) + + p = sub.add_parser("get-pull-request-commits") + p.add_argument("id") + + p = sub.add_parser("get-pull-request-diff") + p.add_argument("id") + + p = sub.add_parser("get-pull-request-url") + p.add_argument("id") args = parser.parse_args() if not args.command: @@ -240,6 +272,30 @@ 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-pull-request": + assert isinstance(scm, GetPullRequestProtocol) + dump(scm.get_pull_request(args.id)) + + elif args.command == "get-pull-requests": + assert isinstance(scm, GetPullRequestsProtocol) + dump(scm.get_pull_requests(state=args.state, head=args.head, pagination=pagination_args(args))) + + elif args.command == "get-pull-request-files": + assert isinstance(scm, GetPullRequestFilesProtocol) + dump(scm.get_pull_request_files(args.id, pagination=pagination_args(args))) + + elif args.command == "get-pull-request-commits": + assert isinstance(scm, GetPullRequestCommitsProtocol) + dump(scm.get_pull_request_commits(args.id)) + + elif args.command == "get-pull-request-diff": + assert isinstance(scm, GetPullRequestDiffProtocol) + dump(scm.get_pull_request_diff(args.id)) + + elif args.command == "get-pull-request-url": + assert isinstance(scm, GetPullRequestUrlProtocol) + dump(scm.get_pull_request_url(args.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 a12e280..c2b8d8d 100644 --- a/src/scm/providers/bitbucket/provider.py +++ b/src/scm/providers/bitbucket/provider.py @@ -1,9 +1,10 @@ import base64 import hashlib +import json import re from collections.abc import Callable, Iterator from datetime import UTC, date, datetime -from typing import Any +from typing import Any, cast, get_args from urllib.parse import parse_qs, quote, unquote, urlparse import requests @@ -13,6 +14,7 @@ PathIsNotDirectory, ReadmeNotFound, ResourceBadRequest, + ResourceForbidden, ResourceNotFound, SCMCodedError, error_class_for_status, @@ -23,6 +25,8 @@ ActionResult, ApiClient, AppInstallation, + Author, + BranchName, Commit, CommitAuthor, CommitComparison, @@ -37,6 +41,11 @@ PaginatedActionResult, PaginationParams, ProviderName, + PullRequest, + PullRequestBranch, + PullRequestCommit, + PullRequestFile, + PullRequestState, Repository, RequestOptions, ) @@ -72,13 +81,12 @@ PULL_REQUEST_TEMPLATE_PARENT_DIRS = (".github", "", "docs") PULL_REQUEST_TEMPLATE_FILENAME = "pull_request_template.md" PULL_REQUEST_TEMPLATE_DIRNAME = "pull_request_template" -_FILE_STATUSES: dict[str, FileStatus] = { - "added": "added", - "removed": "removed", - "modified": "modified", - "renamed": "renamed", -} _RAW_AUTHOR = re.compile(r"^(?P.*?)\s*<(?P[^>]*)>\s*$") +_PULL_REQUEST_STATE_QUERIES: dict[PullRequestState | None, str] = { + "open": 'state="OPEN"', + "closed": '(state="MERGED" OR state="DECLINED" OR state="SUPERSEDED")', + None: '(state="OPEN" OR state="MERGED" OR state="DECLINED" OR state="SUPERSEDED")', +} class BitbucketProvider: @@ -463,6 +471,124 @@ def _get_commits( return result pagination = {**(pagination or {}), "cursor": cursor} + def get_pull_request( + self, + pull_request_id: str, + request_options: RequestOptions | None = None, + ) -> ActionResult[PullRequest]: + response = self.get(self._pull_request_path(pull_request_id), request_options=request_options) + result = map_action(response, map_pull_request) + raw = result["raw"]["data"] + for side, branch in (("source", result["data"]["head"]), ("destination", result["data"]["base"])): + if branch["sha"] and (repository := raw[side].get("repository")): + branch["sha"] = self._full_hash(repository["full_name"], branch["sha"], request_options) + return result + + def get_pull_requests( + self, + state: PullRequestState | None = "open", + head: BranchName | None = None, + pagination: PaginationParams | None = None, + request_options: RequestOptions | None = None, + ) -> PaginatedActionResult[list[PullRequest]]: + # Without a `state` term, Bitbucket lists only open pull requests. + query = _PULL_REQUEST_STATE_QUERIES[state] + if head: + branch = head.split(":", 1)[-1].removeprefix("refs/heads/") + query = f"source.branch.name={json.dumps(branch, ensure_ascii=False)} AND {query}" + response = self.get( + f"/2.0/repositories/{self.repository_path}/pullrequests", + params={"q": query}, + pagination=pagination, + request_options=request_options, + ) + return map_paginated_action(response, map_pull_request) + + def get_pull_request_files( + self, + pull_request_id: str, + pagination: PaginationParams | None = None, + request_options: RequestOptions | None = None, + ) -> PaginatedActionResult[list[PullRequestFile]]: + response = self.get( + f"{self._pull_request_path(pull_request_id)}/diffstat", + pagination=pagination, + request_options=request_options, + allow_redirects=True, + ) + patches = split_patches(self.get_pull_request_diff(pull_request_id, request_options)["data"]) + return map_paginated_action(response, lambda raw: map_pull_request_file(raw, patches)) + + def get_pull_request_commits( + self, + pull_request_id: str, + pagination: PaginationParams | None = None, + request_options: RequestOptions | None = None, + ) -> PaginatedActionResult[list[PullRequestCommit]]: + """Read every page, since Bitbucket lists newest first and callers expect oldest first. + + ``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} + # 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: + pull_request = self.get(self._pull_request_path(pull_request_id), request_options=request_options).json() + merge_hash = (pull_request.get("merge_commit") or {}).get("hash") + if merge_hash and values[0]["hash"].startswith(merge_hash): + values.pop(0) + return { + "data": [map_pull_request_commit(value) for value in reversed(values)], + "type": PROVIDER_TYPE, + "raw": {"data": values, "headers": dict(response.headers)}, + "meta": {"next_cursor": None}, + } + + def get_pull_request_diff( + self, + pull_request_id: str, + request_options: RequestOptions | None = None, + ) -> ActionResult[str]: + # Bitbucket answers with a redirect to the repository diff endpoint. + response = self.get( + f"{self._pull_request_path(pull_request_id)}/diff", + request_options=request_options, + allow_redirects=True, + ) + return { + "data": response.text, + "type": PROVIDER_TYPE, + "raw": {"data": response.text, "headers": dict(response.headers)}, + "meta": {}, + } + + def get_pull_request_url(self, pull_request_id: str) -> str: + return f"{self._web_base_url}/{self.repository_path}/pull-requests/{pull_request_id}" + + def _pull_request_path(self, pull_request_id: str) -> str: + return f"/2.0/repositories/{self.repository_path}/pullrequests/{pull_request_id}" + + def _full_hash(self, repository_path: str, short_hash: str, request_options: RequestOptions | None) -> str: + try: + response = self.get( + f"/2.0/repositories/{repository_path}/commit/{short_hash}", + params={"fields": "hash"}, + request_options=request_options, + ) + except (ResourceForbidden, ResourceNotFound): + # The commit can live in a fork we can't read, or be gone with a deleted branch. + return short_hash + return response.json()["hash"] + def parse_scopes(header: str) -> set[str]: return {scope.strip() for scope in header.split(",") if scope.strip()} @@ -552,12 +678,16 @@ def map_commit(raw: dict[str, Any], additions: int | None = None, deletions: int ) +def map_file_status(raw: str) -> FileStatus: + return cast(FileStatus, raw) if raw in get_args(FileStatus.__value__) else "unknown" + + def map_commit_file(raw: dict[str, Any], patches: dict[str, str]) -> CommitFile: old, new = raw["old"], raw["new"] filename = (new or old)["path"] return CommitFile( filename=filename, - status=_FILE_STATUSES.get(raw["status"], "unknown"), + status=map_file_status(raw["status"]), patch=patches.get(filename), additions=raw["lines_added"], deletions=raw["lines_removed"], @@ -569,12 +699,64 @@ def split_patches(diff: str) -> dict[str, str]: """Split a unified diff into each file's hunks, keyed by the file's new path.""" patches = {} for section in re.split(r"^(?=diff --git )", diff, flags=re.MULTILINE): - header, _, body = section.partition("\n") - if header and (hunks := re.search(r"^@@", body, flags=re.MULTILINE)): - patches[header.rpartition(" b/")[2]] = body[hunks.start() :] + if not (hunks := re.search(r"^@@", section, flags=re.MULTILINE)): + continue + # The `diff --git` line is ambiguous when a path contains " b/". Git ends a path with spaces in a tab. + header = section[: hunks.start()] + path = re.search(r"^\+\+\+ b/(.*?)\t?$", header, re.MULTILINE) or re.search( + r"^--- a/(.*?)\t?$", header, re.MULTILINE + ) + if path: + patches[path[1]] = section[hunks.start() :] return patches +def map_author(raw: dict[str, Any]) -> Author: + # App users have no nickname. + return Author(id=raw["uuid"], username=raw.get("nickname") or raw.get("display_name") or "") + + +def map_pull_request_branch(raw: dict[str, Any]) -> PullRequestBranch: + # Bitbucket abbreviates these hashes to 12 characters. Only get_pull_request expands them. + return PullRequestBranch(ref=raw["branch"]["name"], sha=(raw.get("commit") or {}).get("hash")) + + +def map_pull_request(raw: dict[str, Any]) -> PullRequest: + return PullRequest( + id=str(raw["id"]), + internal_id=str(raw["id"]), + title=raw["title"], + body=raw["description"] or None, + state="open" if raw["state"] == "OPEN" else "closed", + merged=raw["state"] == "MERGED", + html_url=raw["links"]["html"]["href"], + head=map_pull_request_branch(raw["source"]), + base=map_pull_request_branch(raw["destination"]), + author=map_author(raw["author"]), + ) + + +def map_pull_request_file(raw: dict[str, Any], patches: dict[str, str]) -> PullRequestFile: + status = map_file_status(raw["status"]) + filename = (raw["new"] or raw["old"])["path"] + return PullRequestFile( + filename=filename, + status=status, + patch=patches.get(filename), + changes=raw["lines_added"] + raw["lines_removed"], + sha="", + previous_filename=raw["old"]["path"] if status == "renamed" else None, + ) + + +def map_pull_request_commit(raw: dict[str, Any]) -> PullRequestCommit: + return PullRequestCommit( + sha=raw["hash"], + message=raw["message"], + author=map_commit_author(raw["author"], raw["date"]), + ) + + 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/pull_request.json b/tests/unit/provider/fixtures/bitbucket/pull_request.json new file mode 100644 index 0000000..9577623 --- /dev/null +++ b/tests/unit/provider/fixtures/bitbucket/pull_request.json @@ -0,0 +1,169 @@ +{ + "body": { + "author": { + "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}" + }, + "close_source_branch": false, + "closed_by": null, + "comment_count": 0, + "created_on": "2026-11-05T20:16:19.134981+00:00", + "description": "A test pull request.", + "destination": { + "branch": { + "name": "main" + }, + "commit": { + "hash": "a682892aa0f4", + "links": { + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/commits/a682892aa0f4" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/a682892aa0f4" + } + }, + "type": "commit" + }, + "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}" + } + }, + "draft": false, + "id": 1, + "links": { + "activity": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/1/activity" + }, + "approve": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/1/approve" + }, + "comments": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/1/comments" + }, + "commits": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/1/commits" + }, + "decline": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/1/decline" + }, + "diff": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/diff/test-workspace/test-repo:c495d0431cfa%0Da682892aa0f4?from_pullrequest_id=1&topic=true" + }, + "diffstat": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/diffstat/test-workspace/test-repo:c495d0431cfa%0Da682892aa0f4?from_pullrequest_id=1&topic=true" + }, + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/pull-requests/1" + }, + "merge": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/1/merge" + }, + "mergeability_checks": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/1/mergeability/checks" + }, + "request-changes": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/1/request-changes" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/1" + }, + "statuses": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/1/statuses" + } + }, + "merge_commit": null, + "participants": [], + "queued": false, + "reason": "", + "rendered": { + "description": { + "html": "

A test pull request.

", + "markup": "markdown", + "raw": "A test pull request.", + "type": "rendered" + }, + "title": { + "html": "

Test pull request

", + "markup": "markdown", + "raw": "Test pull request", + "type": "rendered" + } + }, + "reviewers": [], + "source": { + "branch": { + "links": {}, + "name": "test-branch-1", + "sync_strategies": [ + "merge_commit", + "rebase" + ] + }, + "commit": { + "hash": "c495d0431cfa", + "links": { + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/commits/c495d0431cfa" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/c495d0431cfa" + } + }, + "type": "commit" + }, + "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}" + } + }, + "state": "OPEN", + "summary": { + "html": "

A test pull request.

", + "markup": "markdown", + "raw": "A test pull request.", + "type": "rendered" + }, + "task_count": 0, + "title": "Test pull request", + "type": "pullrequest", + "updated_on": "2026-11-05T20:16:19.660756+00:00" + }, + "headers": {}, + "status": 200 +} diff --git a/tests/unit/provider/fixtures/bitbucket/pull_request_commits.json b/tests/unit/provider/fixtures/bitbucket/pull_request_commits.json new file mode 100644 index 0000000..2a708ff --- /dev/null +++ b/tests/unit/provider/fixtures/bitbucket/pull_request_commits.json @@ -0,0 +1,174 @@ +{ + "body": { + "page": 1, + "pagelen": 10, + "values": [ + { + "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:16:11+00:00", + "hash": "a69b43321a2390a5eeff8c4bb711348457a64396", + "links": { + "approve": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/a69b43321a2390a5eeff8c4bb711348457a64396/approve" + }, + "comments": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/a69b43321a2390a5eeff8c4bb711348457a64396/comments" + }, + "diff": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/diff/a69b43321a2390a5eeff8c4bb711348457a64396" + }, + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/commits/a69b43321a2390a5eeff8c4bb711348457a64396" + }, + "patch": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/patch/a69b43321a2390a5eeff8c4bb711348457a64396" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/a69b43321a2390a5eeff8c4bb711348457a64396" + }, + "statuses": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/a69b43321a2390a5eeff8c4bb711348457a64396/statuses" + } + }, + "message": "Rename tests, drop template, update README\n", + "parents": [ + { + "hash": "f1ee80b1c7c743738fb49d063f9a30a880343314", + "links": { + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/commits/f1ee80b1c7c743738fb49d063f9a30a880343314" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/f1ee80b1c7c743738fb49d063f9a30a880343314" + } + }, + "type": "commit" + } + ], + "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": "

Rename tests, drop template, update README

", + "markup": "markdown", + "raw": "Rename tests, drop template, update README\n", + "type": "rendered" + }, + "type": "commit" + }, + { + "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:16:11+00:00", + "hash": "f1ee80b1c7c743738fb49d063f9a30a880343314", + "links": { + "approve": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/f1ee80b1c7c743738fb49d063f9a30a880343314/approve" + }, + "comments": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/f1ee80b1c7c743738fb49d063f9a30a880343314/comments" + }, + "diff": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/diff/f1ee80b1c7c743738fb49d063f9a30a880343314" + }, + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/commits/f1ee80b1c7c743738fb49d063f9a30a880343314" + }, + "patch": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/patch/f1ee80b1c7c743738fb49d063f9a30a880343314" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/f1ee80b1c7c743738fb49d063f9a30a880343314" + }, + "statuses": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/f1ee80b1c7c743738fb49d063f9a30a880343314/statuses" + } + }, + "message": "Add greet module\n", + "parents": [ + { + "hash": "a682892aa0f4502ef7a242f75d72d0b41eaa27b9", + "links": { + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/commits/a682892aa0f4502ef7a242f75d72d0b41eaa27b9" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/a682892aa0f4502ef7a242f75d72d0b41eaa27b9" + } + }, + "type": "commit" + } + ], + "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 greet module

", + "markup": "markdown", + "raw": "Add greet module\n", + "type": "rendered" + }, + "type": "commit" + } + ] + }, + "headers": {}, + "status": 200 +} diff --git a/tests/unit/provider/fixtures/bitbucket/pull_request_diff.json b/tests/unit/provider/fixtures/bitbucket/pull_request_diff.json new file mode 100644 index 0000000..02c502a --- /dev/null +++ b/tests/unit/provider/fixtures/bitbucket/pull_request_diff.json @@ -0,0 +1,5 @@ +{ + "body": "diff --git a/src/test_app/math.py b/src/test_app/math.py\nindex 4693ad3..ca2ddb5 100644\n--- 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", + "headers": {}, + "status": 200 +} diff --git a/tests/unit/provider/fixtures/bitbucket/pull_request_diffstat.json b/tests/unit/provider/fixtures/bitbucket/pull_request_diffstat.json new file mode 100644 index 0000000..8aee686 --- /dev/null +++ b/tests/unit/provider/fixtures/bitbucket/pull_request_diffstat.json @@ -0,0 +1,97 @@ +{ + "body": { + "page": 1, + "pagelen": 500, + "size": 4, + "values": [ + { + "lines_added": 0, + "lines_removed": 3, + "new": null, + "old": { + "escaped_path": "PULL_REQUEST_TEMPLATE.md", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/src/a682892aa0f4502ef7a242f75d72d0b41eaa27b9/PULL_REQUEST_TEMPLATE.md" + } + }, + "path": "PULL_REQUEST_TEMPLATE.md", + "type": "commit_file" + }, + "status": "removed", + "type": "diffstat" + }, + { + "lines_added": 1, + "lines_removed": 0, + "new": { + "escaped_path": "README.md", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/src/a69b43321a2390a5eeff8c4bb711348457a64396/README.md" + } + }, + "path": "README.md", + "type": "commit_file" + }, + "old": { + "escaped_path": "README.md", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/src/a682892aa0f4502ef7a242f75d72d0b41eaa27b9/README.md" + } + }, + "path": "README.md", + "type": "commit_file" + }, + "status": "modified", + "type": "diffstat" + }, + { + "lines_added": 2, + "lines_removed": 0, + "new": { + "escaped_path": "src/test_app/greet.py", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/src/a69b43321a2390a5eeff8c4bb711348457a64396/src/test_app/greet.py" + } + }, + "path": "src/test_app/greet.py", + "type": "commit_file" + }, + "old": null, + "status": "added", + "type": "diffstat" + }, + { + "lines_added": 0, + "lines_removed": 0, + "new": { + "escaped_path": "tests/test_arithmetic.py", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/src/a69b43321a2390a5eeff8c4bb711348457a64396/tests/test_arithmetic.py" + } + }, + "path": "tests/test_arithmetic.py", + "type": "commit_file" + }, + "old": { + "escaped_path": "tests/test_math.py", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/src/a682892aa0f4502ef7a242f75d72d0b41eaa27b9/tests/test_math.py" + } + }, + "path": "tests/test_math.py", + "type": "commit_file" + }, + "status": "renamed", + "type": "diffstat" + } + ] + }, + "headers": {}, + "status": 200 +} diff --git a/tests/unit/provider/fixtures/bitbucket/pull_request_merged.json b/tests/unit/provider/fixtures/bitbucket/pull_request_merged.json new file mode 100644 index 0000000..cbcd6b9 --- /dev/null +++ b/tests/unit/provider/fixtures/bitbucket/pull_request_merged.json @@ -0,0 +1,191 @@ +{ + "body": { + "author": { + "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}" + }, + "close_source_branch": false, + "closed_by": { + "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}" + }, + "comment_count": 0, + "created_on": "2026-11-05T20:16:20.205113+00:00", + "description": "Adds, removes, renames and modifies files.", + "destination": { + "branch": { + "name": "merge-base" + }, + "commit": { + "hash": "a682892aa0f4", + "links": { + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/commits/a682892aa0f4" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/a682892aa0f4" + } + }, + "type": "commit" + }, + "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}" + } + }, + "draft": false, + "id": 2, + "links": { + "activity": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/2/activity" + }, + "approve": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/2/approve" + }, + "comments": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/2/comments" + }, + "commits": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/2/commits" + }, + "decline": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/2/decline" + }, + "diff": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/diff/test-workspace/test-repo:a69b43321a23%0Da682892aa0f4?from_pullrequest_id=2&topic=true" + }, + "diffstat": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/diffstat/test-workspace/test-repo:a69b43321a23%0Da682892aa0f4?from_pullrequest_id=2&topic=true" + }, + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/pull-requests/2" + }, + "merge": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/2/merge" + }, + "mergeability_checks": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/2/mergeability/checks" + }, + "request-changes": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/2/request-changes" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/2" + }, + "statuses": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/2/statuses" + } + }, + "merge_commit": { + "hash": "69c00816acfd", + "links": { + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/commits/69c00816acfd" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/69c00816acfd" + } + }, + "type": "commit" + }, + "participants": [], + "queued": false, + "reason": "", + "rendered": { + "description": { + "html": "

Adds, removes, renames and modifies files.

", + "markup": "markdown", + "raw": "Adds, removes, renames and modifies files.", + "type": "rendered" + }, + "title": { + "html": "

Change several files

", + "markup": "markdown", + "raw": "Change several files", + "type": "rendered" + } + }, + "reviewers": [], + "source": { + "branch": { + "links": {}, + "name": "file-changes", + "sync_strategies": [ + "merge_commit", + "rebase" + ] + }, + "commit": { + "hash": "a69b43321a23", + "links": { + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/commits/a69b43321a23" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/a69b43321a23" + } + }, + "type": "commit" + }, + "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}" + } + }, + "state": "MERGED", + "summary": { + "html": "

Adds, removes, renames and modifies files.

", + "markup": "markdown", + "raw": "Adds, removes, renames and modifies files.", + "type": "rendered" + }, + "task_count": 0, + "title": "Change several files", + "type": "pullrequest", + "updated_on": "2026-11-05T20:17:01.144315+00:00" + }, + "headers": {}, + "status": 200 +} diff --git a/tests/unit/provider/fixtures/bitbucket/pull_request_merged_diff.json b/tests/unit/provider/fixtures/bitbucket/pull_request_merged_diff.json new file mode 100644 index 0000000..617463c --- /dev/null +++ b/tests/unit/provider/fixtures/bitbucket/pull_request_merged_diff.json @@ -0,0 +1,5 @@ +{ + "body": "diff --git a/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md\ndeleted file mode 100644\nindex e82037b..0000000\n--- a/PULL_REQUEST_TEMPLATE.md\n+++ /dev/null\n@@ -1,3 +0,0 @@\n-## Summary\n-\n-## Test plan\ndiff --git a/README.md b/README.md\nindex 836111e..3feb00b 100644\n--- a/README.md\n+++ b/README.md\n@@ -1,3 +1,4 @@\n # test-repo\n \n A test repository.\n+More docs.\ndiff --git a/src/test_app/greet.py b/src/test_app/greet.py\nnew file mode 100644\nindex 0000000..8a5b158\n--- /dev/null\n+++ b/src/test_app/greet.py\n@@ -0,0 +1,2 @@\n+def greet(name):\n+ return f\"Hello, {name}\"\ndiff --git a/tests/test_math.py b/tests/test_arithmetic.py\nsimilarity index 100%\nrename from tests/test_math.py\nrename to tests/test_arithmetic.py\n", + "headers": {}, + "status": 200 +} diff --git a/tests/unit/provider/fixtures/bitbucket/pull_request_not_found.json b/tests/unit/provider/fixtures/bitbucket/pull_request_not_found.json new file mode 100644 index 0000000..d2ba4d8 --- /dev/null +++ b/tests/unit/provider/fixtures/bitbucket/pull_request_not_found.json @@ -0,0 +1,5 @@ +{ + "body": "Not Found", + "headers": {}, + "status": 404 +} diff --git a/tests/unit/provider/fixtures/bitbucket/pull_requests_page_1.json b/tests/unit/provider/fixtures/bitbucket/pull_requests_page_1.json new file mode 100644 index 0000000..fb0477c --- /dev/null +++ b/tests/unit/provider/fixtures/bitbucket/pull_requests_page_1.json @@ -0,0 +1,335 @@ +{ + "body": { + "next": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests?q=state%3D%22OPEN%22+OR+state%3D%22MERGED%22+OR+state%3D%22DECLINED%22+OR+state%3D%22SUPERSEDED%22&pagelen=2&page=2", + "page": 1, + "pagelen": 2, + "size": 3, + "values": [ + { + "author": { + "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}" + }, + "close_source_branch": false, + "closed_by": { + "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}" + }, + "comment_count": 0, + "created_on": "2026-11-05T20:16:21.232478+00:00", + "description": "", + "destination": { + "branch": { + "name": "main" + }, + "commit": { + "hash": "a682892aa0f4", + "links": { + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/commits/a682892aa0f4" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/a682892aa0f4" + } + }, + "type": "commit" + }, + "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}" + } + }, + "draft": false, + "id": 3, + "links": { + "activity": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/3/activity" + }, + "approve": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/3/approve" + }, + "comments": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/3/comments" + }, + "commits": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/3/commits" + }, + "decline": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/3/decline" + }, + "diff": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/diff/test-workspace/test-repo:d6c9ae94e902%0Da682892aa0f4?from_pullrequest_id=3&topic=true" + }, + "diffstat": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/diffstat/test-workspace/test-repo:d6c9ae94e902%0Da682892aa0f4?from_pullrequest_id=3&topic=true" + }, + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/pull-requests/3" + }, + "merge": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/3/merge" + }, + "mergeability_checks": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/3/mergeability/checks" + }, + "request-changes": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/3/request-changes" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/3" + }, + "statuses": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/3/statuses" + } + }, + "merge_commit": null, + "queued": false, + "reason": "", + "source": { + "branch": { + "links": {}, + "name": "declined-branch" + }, + "commit": { + "hash": "d6c9ae94e902", + "links": { + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/commits/d6c9ae94e902" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/d6c9ae94e902" + } + }, + "type": "commit" + }, + "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}" + } + }, + "state": "DECLINED", + "summary": { + "html": "", + "markup": "markdown", + "raw": "", + "type": "rendered" + }, + "task_count": 0, + "title": "Declined pull request", + "type": "pullrequest", + "updated_on": "2026-11-05T20:17:02.221583+00:00" + }, + { + "author": { + "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}" + }, + "close_source_branch": false, + "closed_by": { + "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}" + }, + "comment_count": 0, + "created_on": "2026-11-05T20:16:20.205113+00:00", + "description": "Adds, removes, renames and modifies files.", + "destination": { + "branch": { + "name": "merge-base" + }, + "commit": { + "hash": "a682892aa0f4", + "links": { + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/commits/a682892aa0f4" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/a682892aa0f4" + } + }, + "type": "commit" + }, + "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}" + } + }, + "draft": false, + "id": 2, + "links": { + "activity": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/2/activity" + }, + "approve": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/2/approve" + }, + "comments": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/2/comments" + }, + "commits": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/2/commits" + }, + "decline": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/2/decline" + }, + "diff": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/diff/test-workspace/test-repo:a69b43321a23%0Da682892aa0f4?from_pullrequest_id=2&topic=true" + }, + "diffstat": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/diffstat/test-workspace/test-repo:a69b43321a23%0Da682892aa0f4?from_pullrequest_id=2&topic=true" + }, + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/pull-requests/2" + }, + "merge": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/2/merge" + }, + "mergeability_checks": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/2/mergeability/checks" + }, + "request-changes": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/2/request-changes" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/2" + }, + "statuses": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/2/statuses" + } + }, + "merge_commit": { + "hash": "69c00816acfd", + "links": { + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/commits/69c00816acfd" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/69c00816acfd" + } + }, + "type": "commit" + }, + "queued": false, + "reason": "", + "source": { + "branch": { + "links": {}, + "name": "file-changes" + }, + "commit": { + "hash": "a69b43321a23", + "links": { + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/commits/a69b43321a23" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/a69b43321a23" + } + }, + "type": "commit" + }, + "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}" + } + }, + "state": "MERGED", + "summary": { + "html": "

Adds, removes, renames and modifies files.

", + "markup": "markdown", + "raw": "Adds, removes, renames and modifies files.", + "type": "rendered" + }, + "task_count": 0, + "title": "Change several files", + "type": "pullrequest", + "updated_on": "2026-11-05T20:17:01.144315+00:00" + } + ] + }, + "headers": {}, + "status": 200 +} diff --git a/tests/unit/provider/test_bitbucket.py b/tests/unit/provider/test_bitbucket.py index 3e21908..600e5f6 100644 --- a/tests/unit/provider/test_bitbucket.py +++ b/tests/unit/provider/test_bitbucket.py @@ -12,8 +12,10 @@ from scm.providers.bitbucket.provider import ( BitbucketProvider, map_app_installation, + map_author, map_commit_author, map_paginated_action, + map_pull_request_file, map_repository, next_cursor, parse_scopes, @@ -32,9 +34,16 @@ GetFileContentProtocol, GetFileUrlProtocol, GetGitRemoteProtocol, + GetPullRequestCommitsProtocol, + GetPullRequestDiffProtocol, + GetPullRequestFilesProtocol, + GetPullRequestProtocol, + GetPullRequestsProtocol, GetPullRequestTemplateProtocol, + GetPullRequestUrlProtocol, GetReadmeProtocol, GetRepositoryProtocol, + PullRequestState, Repository, ) @@ -58,6 +67,12 @@ GetCommitsUrlProtocol, GetCommitUrlProtocol, GetRepositoryProtocol, + GetPullRequestCommitsProtocol, + GetPullRequestDiffProtocol, + GetPullRequestFilesProtocol, + GetPullRequestProtocol, + GetPullRequestsProtocol, + GetPullRequestUrlProtocol, ] COMMIT_SHA = "e723f15089c22737bb51aee8db06e0eab91e4ba1" HEAD_SHA = "e3ef83d1a831b7b26c838372f668b1627bbf9a1a" @@ -65,6 +80,13 @@ TEST_AUTHOR = {"name": "Test User", "email": "test@example.com", "date": datetime(2026, 11, 5, 20, 16, 34, tzinfo=UTC)} +TEST_USER = {"id": "{00000000-0000-0000-0000-000000000001}", "username": "test-user"} + +HEAD_HASH = "c495d0431cfae535ec9a05e4def54229d0eb2ad2" + +BASE_HASH = "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()) @@ -625,6 +647,22 @@ def test_author_without_linked_user(self, raw: dict[str, Any], name: str, email: author = map_commit_author(raw, "2026-11-05T20:16:34+00:00") assert (author["name"], author["email"]) == (name, email) + def test_split_patches_reads_paths_containing_b_slash(self) -> None: + diff = ( + "diff --git a/Appendix b/notes.md b/Appendix b/notes.md\n" + "--- a/Appendix b/notes.md\t\n" + "+++ b/Appendix b/notes.md\t\n" + "@@ -1 +1 @@\n-x\n+y\n" + "diff --git a/gone.py b/gone.py\n" + "--- a/gone.py\n" + "+++ /dev/null\n" + "@@ -1 +0,0 @@\n-x\n" + ) + assert split_patches(diff) == { + "Appendix b/notes.md": "@@ -1 +1 @@\n-x\n+y\n", + "gone.py": "@@ -1 +0,0 @@\n-x\n", + } + def test_split_patches_skips_files_without_hunks(self) -> None: diff = ( "diff --git a/old.md b/new.md\nsimilarity index 100%\nrename from old.md\nrename to new.md\n" @@ -632,3 +670,354 @@ 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"} + + +def json_response(body: dict[str, Any], status: int = 200) -> requests.Response: + response = requests.Response() + response.status_code = status + response._content = json.dumps(body).encode() + response.request = requests.Request("GET", "https://api.bitbucket.org/2.0/x").prepare() + return response + + +class TestGetPullRequest: + def test_maps_open_pull_request(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.side_effect = [ + recorded("pull_request"), + json_response({"hash": HEAD_HASH}), + json_response({"hash": BASE_HASH}), + ] + + result = provider.get_pull_request("1") + + assert [(c.kwargs["path"], c.kwargs["params"]) for c in client.request.call_args_list] == [ + (f"/2.0/repositories/{REPO}/pullrequests/1", {}), + (f"/2.0/repositories/{REPO}/commit/c495d0431cfa", {"fields": "hash"}), + (f"/2.0/repositories/{REPO}/commit/a682892aa0f4", {"fields": "hash"}), + ] + assert result["data"] == { + "id": "1", + "internal_id": "1", + "title": "Test pull request", + "body": "A test pull request.", + "state": "open", + "merged": False, + "html_url": f"https://bitbucket.org/{REPO}/pull-requests/1", + "head": {"ref": "test-branch-1", "sha": HEAD_HASH}, + "base": {"ref": "main", "sha": BASE_HASH}, + "author": TEST_USER, + } + + def test_expands_head_hash_in_source_repository( + self, provider: BitbucketProvider, client: unittest.mock.MagicMock + ) -> None: + response = recorded("pull_request") + body = response.json() + body["source"]["repository"]["full_name"] = "fork-workspace/test-repo" + client.request.side_effect = [ + json_response(body), + json_response({"hash": HEAD_HASH}), + json_response({"hash": BASE_HASH}), + ] + + provider.get_pull_request("1") + + assert ( + client.request.call_args_list[1].kwargs["path"] + == "/2.0/repositories/fork-workspace/test-repo/commit/c495d0431cfa" + ) + + @pytest.mark.parametrize("status", [403, 404]) + def test_keeps_short_hash_it_cannot_expand( + self, provider: BitbucketProvider, client: unittest.mock.MagicMock, status: int + ) -> None: + client.request.side_effect = [ + recorded("pull_request"), + json_response({"type": "error"}, status=status), + json_response({"hash": BASE_HASH}), + ] + + pull_request = provider.get_pull_request("1")["data"] + + assert pull_request["head"]["sha"] == "c495d0431cfa" + assert pull_request["base"]["sha"] == BASE_HASH + + def test_other_errors_expanding_a_hash_raise( + self, provider: BitbucketProvider, client: unittest.mock.MagicMock + ) -> None: + client.request.side_effect = [recorded("pull_request"), json_response({"type": "error"}, status=400)] + with pytest.raises(ResourceBadRequest): + provider.get_pull_request("1") + + def test_skips_expanding_a_missing_commit( + self, provider: BitbucketProvider, client: unittest.mock.MagicMock + ) -> None: + body = recorded("pull_request").json() + body["source"]["commit"] = None + client.request.side_effect = [json_response(body), json_response({"hash": BASE_HASH})] + + pull_request = provider.get_pull_request("1")["data"] + + assert pull_request["head"]["sha"] is None + assert pull_request["base"]["sha"] == BASE_HASH + assert client.request.call_count == 2 + + @pytest.mark.parametrize("repository", [{}, {"repository": None}], ids=["missing", "null"]) + def test_keeps_short_hash_without_a_repository( + self, provider: BitbucketProvider, client: unittest.mock.MagicMock, repository: dict[str, Any] + ) -> None: + body = recorded("pull_request").json() + del body["source"]["repository"] + body["source"].update(repository) + client.request.side_effect = [json_response(body), json_response({"hash": BASE_HASH})] + + pull_request = provider.get_pull_request("1")["data"] + + assert pull_request["head"]["sha"] == "c495d0431cfa" + assert client.request.call_count == 2 + + def test_merged_pull_request_is_closed(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.side_effect = [ + recorded("pull_request_merged"), + json_response({"hash": "a69b43321a2390a5eeff8c4bb711348457a64396"}), + json_response({"hash": BASE_HASH}), + ] + + pull_request = provider.get_pull_request("2")["data"] + + assert pull_request["state"] == "closed" + assert pull_request["merged"] is True + assert pull_request["base"]["ref"] == "merge-base" + + def test_not_found(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.return_value = recorded("pull_request_not_found") + with pytest.raises(ResourceNotFound): + provider.get_pull_request("999") + + +@pytest.mark.parametrize( + ("raw", "username"), + [ + ({"nickname": "test-user", "display_name": "Test User"}, "test-user"), + ({"type": "app_user", "display_name": "Test App"}, "Test App"), + ({}, ""), + ], +) +def test_map_author(raw: dict[str, Any], username: str) -> None: + raw = {"uuid": "{00000000-0000-0000-0000-000000000002}", **raw} + assert map_author(raw) == {"id": "{00000000-0000-0000-0000-000000000002}", "username": username} + + +class TestGetPullRequests: + def test_maps_page(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.return_value = recorded("pull_requests_page_1") + + result = provider.get_pull_requests(state=None, pagination={"cursor": "5", "per_page": 2}) + + assert request_kwargs(client)["params"]["pagelen"] == "2" + assert request_kwargs(client)["params"]["page"] == "5" + assert [(pr["id"], pr["state"], pr["merged"]) for pr in result["data"]] == [ + ("3", "closed", False), + ("2", "closed", True), + ] + assert result["data"][0]["body"] is None + assert result["meta"]["next_cursor"] == "2" + + @pytest.mark.parametrize( + ("state", "query"), + [ + ("open", 'state="OPEN"'), + ("closed", '(state="MERGED" OR state="DECLINED" OR state="SUPERSEDED")'), + (None, '(state="OPEN" OR state="MERGED" OR state="DECLINED" OR state="SUPERSEDED")'), + ], + ) + def test_state_filter( + self, + provider: BitbucketProvider, + client: unittest.mock.MagicMock, + state: PullRequestState | None, + query: str, + ) -> None: + client.request.return_value = recorded("pull_requests_page_1") + provider.get_pull_requests(state=state) + assert request_kwargs(client)["params"] == {"q": query} + + @pytest.mark.parametrize("head", ["feature/x", "test-workspace:feature/x", "test-workspace:refs/heads/feature/x"]) + def test_head_filter(self, provider: BitbucketProvider, client: unittest.mock.MagicMock, head: str) -> None: + client.request.return_value = recorded("pull_requests_page_1") + provider.get_pull_requests(head=head) + assert request_kwargs(client)["params"] == {"q": 'source.branch.name="feature/x" AND state="OPEN"'} + + def test_head_filter_escapes_quotes(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.return_value = recorded("pull_requests_page_1") + provider.get_pull_requests(head='a"b') + assert request_kwargs(client)["params"]["q"].startswith('source.branch.name="a\\"b" AND ') + + def test_head_filter_keeps_non_ascii(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.return_value = recorded("pull_requests_page_1") + provider.get_pull_requests(head="feature/é") + assert request_kwargs(client)["params"]["q"].startswith('source.branch.name="feature/é" AND ') + + +class TestGetPullRequestFiles: + def test_maps_diffstat(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.side_effect = [recorded("pull_request_diffstat"), recorded("pull_request_merged_diff")] + + result = provider.get_pull_request_files("2") + + assert [(c.kwargs["path"], c.kwargs["allow_redirects"]) for c in client.request.call_args_list] == [ + (f"/2.0/repositories/{REPO}/pullrequests/2/diffstat", True), + (f"/2.0/repositories/{REPO}/pullrequests/2/diff", True), + ] + assert result["data"] == [ + { + "filename": "PULL_REQUEST_TEMPLATE.md", + "status": "removed", + "patch": "@@ -1,3 +0,0 @@\n-## Summary\n-\n-## Test plan\n", + "changes": 3, + "sha": "", + "previous_filename": None, + }, + { + "filename": "README.md", + "status": "modified", + "patch": "@@ -1,3 +1,4 @@\n # test-repo\n \n A test repository.\n+More docs.\n", + "changes": 1, + "sha": "", + "previous_filename": None, + }, + { + "filename": "src/test_app/greet.py", + "status": "added", + "patch": '@@ -0,0 +1,2 @@\n+def greet(name):\n+ return f"Hello, {name}"\n', + "changes": 2, + "sha": "", + "previous_filename": None, + }, + { + "filename": "tests/test_arithmetic.py", + "status": "renamed", + "patch": None, + "changes": 0, + "sha": "", + "previous_filename": "tests/test_math.py", + }, + ] + assert result["meta"]["next_cursor"] is None + + def test_passes_pagination_to_diffstat(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.side_effect = [recorded("pull_request_diffstat"), recorded("pull_request_merged_diff")] + + provider.get_pull_request_files("2", pagination={"cursor": "3", "per_page": 5}) + + assert client.request.call_args_list[0].kwargs["params"] == {"pagelen": "5", "page": "3"} + + def test_conflict_status_is_unknown(self) -> None: + raw = {"status": "merge conflict", "old": None, "new": {"path": "a.py"}, "lines_added": 1, "lines_removed": 0} + assert map_pull_request_file(raw, {})["status"] == "unknown" + + +class TestGetPullRequestCommits: + def test_maps_commits_oldest_first(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.return_value = recorded("pull_request_commits") + + result = provider.get_pull_request_commits("2") + + assert request_kwargs(client)["path"] == f"/2.0/repositories/{REPO}/pullrequests/2/commits" + assert request_kwargs(client)["params"] == {"pagelen": "100"} + assert client.request.call_count == 1 + assert result["meta"]["next_cursor"] is None + assert result["data"] == [ + { + "sha": "f1ee80b1c7c743738fb49d063f9a30a880343314", + "message": "Add greet module\n", + "author": { + "name": "Test User", + "email": "test@example.com", + "date": datetime(2026, 11, 5, 20, 16, 11, tzinfo=UTC), + }, + }, + { + "sha": "a69b43321a2390a5eeff8c4bb711348457a64396", + "message": "Rename tests, drop template, update README\n", + "author": { + "name": "Test User", + "email": "test@example.com", + "date": datetime(2026, 11, 5, 20, 16, 11, tzinfo=UTC), + }, + }, + ] + + def test_reads_every_page_oldest_first(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + newest, oldest = recorded("pull_request_commits").json()["values"] + next_url = f"https://api.bitbucket.org/2.0/repositories/{REPO}/pullrequests/2/commits?page=67Fg" + client.request.side_effect = [ + json_response({"values": [newest], "next": next_url}), + json_response({"values": [oldest]}), + ] + + result = provider.get_pull_request_commits("2", pagination={"per_page": 1}) + + assert client.request.call_args_list[1].kwargs["params"] == {"pagelen": "100", "page": "67Fg"} + assert [commit["sha"] for commit in result["data"]] == [oldest["hash"], newest["hash"]] + assert result["meta"]["next_cursor"] is None + + @staticmethod + def with_merge_commit(merge_hash: str) -> requests.Response: + body = recorded("pull_request_commits").json() + merge_commit = { + **body["values"][0], + "hash": merge_hash, + "message": "Merged in file-changes (pull request #2)\n", + "parents": [{"hash": BASE_HASH}, {"hash": body["values"][0]["hash"]}], + } + body["values"].insert(0, merge_commit) + return json_response(body) + + def test_drops_bitbucket_merge_commit(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.side_effect = [ + self.with_merge_commit("69c00816acfdb151b90d7dd925cfa79922f362e9"), + recorded("pull_request_merged"), + ] + + result = provider.get_pull_request_commits("2") + + assert client.request.call_args_list[1].kwargs["path"] == f"/2.0/repositories/{REPO}/pullrequests/2" + assert [commit["sha"] for commit in result["data"]] == [ + "f1ee80b1c7c743738fb49d063f9a30a880343314", + "a69b43321a2390a5eeff8c4bb711348457a64396", + ] + + def test_keeps_other_merge_commits(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + merge_hash = "1111111111111111111111111111111111111111" + client.request.side_effect = [self.with_merge_commit(merge_hash), recorded("pull_request_merged")] + + result = provider.get_pull_request_commits("2") + + assert result["data"][-1]["sha"] == merge_hash + + def test_keeps_merge_commit_on_open_pull_request( + self, provider: BitbucketProvider, client: unittest.mock.MagicMock + ) -> None: + merge_hash = "2222222222222222222222222222222222222222" + client.request.side_effect = [self.with_merge_commit(merge_hash), recorded("pull_request")] + + result = provider.get_pull_request_commits("1") + + assert result["data"][-1]["sha"] == merge_hash + + +class TestGetPullRequestDiff: + def test_returns_diff_text(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.return_value = recorded("pull_request_diff") + + result = provider.get_pull_request_diff("1") + + assert request_kwargs(client)["path"] == f"/2.0/repositories/{REPO}/pullrequests/1/diff" + assert request_kwargs(client)["allow_redirects"] is True + assert result["data"].startswith("diff --git a/src/test_app/math.py b/src/test_app/math.py\n") + assert "+def subtract(a, b):\n" in result["data"] + + +def test_get_pull_request_url(provider: BitbucketProvider) -> None: + assert provider.get_pull_request_url("1") == f"https://bitbucket.org/{REPO}/pull-requests/1"