From 9ef79f4f646b7508c7d05cd95b32cb0c62a64503 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Mon, 9 Nov 2026 12:11:53 -0800 Subject: [PATCH 1/3] feat(bitbucket): Add pull request reads --- bin/bitbucket-client | 53 +++ src/scm/providers/bitbucket/provider.py | 182 ++++++++++ .../fixtures/bitbucket/pull_request.json | 169 +++++++++ .../bitbucket/pull_request_commits.json | 174 +++++++++ .../fixtures/bitbucket/pull_request_diff.json | 5 + .../bitbucket/pull_request_diffstat.json | 97 +++++ .../bitbucket/pull_request_merged.json | 191 ++++++++++ .../bitbucket/pull_request_merged_diff.json | 5 + .../bitbucket/pull_request_not_found.json | 5 + .../bitbucket/pull_requests_page_1.json | 335 ++++++++++++++++++ tests/unit/provider/test_bitbucket.py | 317 +++++++++++++++++ 11 files changed, 1533 insertions(+) create mode 100644 tests/unit/provider/fixtures/bitbucket/pull_request.json create mode 100644 tests/unit/provider/fixtures/bitbucket/pull_request_commits.json create mode 100644 tests/unit/provider/fixtures/bitbucket/pull_request_diff.json create mode 100644 tests/unit/provider/fixtures/bitbucket/pull_request_diffstat.json create mode 100644 tests/unit/provider/fixtures/bitbucket/pull_request_merged.json create mode 100644 tests/unit/provider/fixtures/bitbucket/pull_request_merged_diff.json create mode 100644 tests/unit/provider/fixtures/bitbucket/pull_request_not_found.json create mode 100644 tests/unit/provider/fixtures/bitbucket/pull_requests_page_1.json diff --git a/bin/bitbucket-client b/bin/bitbucket-client index 551e92be..a0a50ee9 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] + get-pull-request-files + 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,23 @@ 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 = sub.add_parser("get-pull-request-files") + p.add_argument("id") + + 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 +269,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)) + + elif args.command == "get-pull-request-files": + assert isinstance(scm, GetPullRequestFilesProtocol) + dump(scm.get_pull_request_files(args.id)) + + 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 a12e2809..7bf18e3d 100644 --- a/src/scm/providers/bitbucket/provider.py +++ b/src/scm/providers/bitbucket/provider.py @@ -1,8 +1,10 @@ import base64 import hashlib +import json import re from collections.abc import Callable, Iterator from datetime import UTC, date, datetime +from email.utils import parseaddr from typing import Any from urllib.parse import parse_qs, quote, unquote, urlparse @@ -13,6 +15,7 @@ PathIsNotDirectory, ReadmeNotFound, ResourceBadRequest, + ResourceForbidden, ResourceNotFound, SCMCodedError, error_class_for_status, @@ -23,6 +26,8 @@ ActionResult, ApiClient, AppInstallation, + Author, + BranchName, Commit, CommitAuthor, CommitComparison, @@ -37,6 +42,11 @@ PaginatedActionResult, PaginationParams, ProviderName, + PullRequest, + PullRequestBranch, + PullRequestCommit, + PullRequestFile, + PullRequestState, Repository, RequestOptions, ) @@ -81,6 +91,13 @@ _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: def __init__( self, @@ -463,6 +480,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()} @@ -575,6 +710,53 @@ def split_patches(diff: str) -> dict[str, str]: 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 = _FILE_STATUSES.get(raw["status"], "unknown") + 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: + name, email = parseaddr(raw["author"]["raw"]) + return PullRequestCommit( + sha=raw["hash"], + message=raw["message"], + author=CommitAuthor(name=name, email=email, date=datetime.fromisoformat(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 00000000..9577623a --- /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 00000000..2a708ff9 --- /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 00000000..02c502ae --- /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 00000000..8aee686a --- /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 00000000..2d8e7cc3 --- /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": "s7-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": "s7-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 00000000..617463cb --- /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 00000000..d2ba4d89 --- /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 00000000..c595fcbe --- /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": "s7-declined" + }, + "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": "s7-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": "s7-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 3e21908d..cd658ccf 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()) @@ -632,3 +654,298 @@ 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_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"] == "s7-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={"per_page": 2}) + + 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 ') + + +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_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: + response = recorded("pull_request_commits") + body = response.json() + merge_commit = { + **body["values"][0], + "hash": merge_hash, + "message": "Merged in s7-file-changes (pull request #2)\n", + "parents": [{"hash": "a682892aa0f4502ef7a242f75d72d0b41eaa27b9"}, {"hash": body["values"][0]["hash"]}], + } + body["values"].insert(0, merge_commit) + response._content = json.dumps(body).encode() + return response + + 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 + + +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" From 415deeec5c0b026729bc5b1221f479d352d1b86a Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Mon, 9 Nov 2026 12:15:31 -0800 Subject: [PATCH 2/3] ref(bitbucket): Share file status and author parsing, and read diff paths from the file headers --- src/scm/providers/bitbucket/provider.py | 34 ++++++++++++------------- tests/unit/provider/test_bitbucket.py | 16 ++++++++++++ 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/src/scm/providers/bitbucket/provider.py b/src/scm/providers/bitbucket/provider.py index 7bf18e3d..c2b8d8d3 100644 --- a/src/scm/providers/bitbucket/provider.py +++ b/src/scm/providers/bitbucket/provider.py @@ -4,8 +4,7 @@ import re from collections.abc import Callable, Iterator from datetime import UTC, date, datetime -from email.utils import parseaddr -from typing import Any +from typing import Any, cast, get_args from urllib.parse import parse_qs, quote, unquote, urlparse import requests @@ -82,15 +81,7 @@ 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")', @@ -687,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"], @@ -704,9 +699,15 @@ 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 @@ -736,7 +737,7 @@ def map_pull_request(raw: dict[str, Any]) -> PullRequest: def map_pull_request_file(raw: dict[str, Any], patches: dict[str, str]) -> PullRequestFile: - status = _FILE_STATUSES.get(raw["status"], "unknown") + status = map_file_status(raw["status"]) filename = (raw["new"] or raw["old"])["path"] return PullRequestFile( filename=filename, @@ -749,11 +750,10 @@ def map_pull_request_file(raw: dict[str, Any], patches: dict[str, str]) -> PullR def map_pull_request_commit(raw: dict[str, Any]) -> PullRequestCommit: - name, email = parseaddr(raw["author"]["raw"]) return PullRequestCommit( sha=raw["hash"], message=raw["message"], - author=CommitAuthor(name=name, email=email, date=datetime.fromisoformat(raw["date"])), + author=map_commit_author(raw["author"], raw["date"]), ) diff --git a/tests/unit/provider/test_bitbucket.py b/tests/unit/provider/test_bitbucket.py index cd658ccf..76c4a013 100644 --- a/tests/unit/provider/test_bitbucket.py +++ b/tests/unit/provider/test_bitbucket.py @@ -647,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" From 3074fde6324e7a3386de419dac5186c17f02885c Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Mon, 9 Nov 2026 12:25:21 -0800 Subject: [PATCH 3/3] test(bitbucket): Cover pull request read edge cases --- bin/bitbucket-client | 11 +-- .../bitbucket/pull_request_merged.json | 4 +- .../bitbucket/pull_requests_page_1.json | 6 +- tests/unit/provider/test_bitbucket.py | 72 ++++++++++++++++--- 4 files changed, 76 insertions(+), 17 deletions(-) diff --git a/bin/bitbucket-client b/bin/bitbucket-client index a0a50ee9..7edfa730 100755 --- a/bin/bitbucket-client +++ b/bin/bitbucket-client @@ -28,8 +28,8 @@ Commands: 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] - get-pull-request-files + 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 @@ -180,9 +180,12 @@ def main(): 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") @@ -275,11 +278,11 @@ def main(): elif args.command == "get-pull-requests": assert isinstance(scm, GetPullRequestsProtocol) - dump(scm.get_pull_requests(state=args.state)) + 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)) + dump(scm.get_pull_request_files(args.id, pagination=pagination_args(args))) elif args.command == "get-pull-request-commits": assert isinstance(scm, GetPullRequestCommitsProtocol) diff --git a/tests/unit/provider/fixtures/bitbucket/pull_request_merged.json b/tests/unit/provider/fixtures/bitbucket/pull_request_merged.json index 2d8e7cc3..cbcd6b92 100644 --- a/tests/unit/provider/fixtures/bitbucket/pull_request_merged.json +++ b/tests/unit/provider/fixtures/bitbucket/pull_request_merged.json @@ -30,7 +30,7 @@ "description": "Adds, removes, renames and modifies files.", "destination": { "branch": { - "name": "s7-merge-base" + "name": "merge-base" }, "commit": { "hash": "a682892aa0f4", @@ -138,7 +138,7 @@ "source": { "branch": { "links": {}, - "name": "s7-file-changes", + "name": "file-changes", "sync_strategies": [ "merge_commit", "rebase" diff --git a/tests/unit/provider/fixtures/bitbucket/pull_requests_page_1.json b/tests/unit/provider/fixtures/bitbucket/pull_requests_page_1.json index c595fcbe..fb0477c2 100644 --- a/tests/unit/provider/fixtures/bitbucket/pull_requests_page_1.json +++ b/tests/unit/provider/fixtures/bitbucket/pull_requests_page_1.json @@ -117,7 +117,7 @@ "source": { "branch": { "links": {}, - "name": "s7-declined" + "name": "declined-branch" }, "commit": { "hash": "d6c9ae94e902", @@ -192,7 +192,7 @@ "description": "Adds, removes, renames and modifies files.", "destination": { "branch": { - "name": "s7-merge-base" + "name": "merge-base" }, "commit": { "hash": "a682892aa0f4", @@ -284,7 +284,7 @@ "source": { "branch": { "links": {}, - "name": "s7-file-changes" + "name": "file-changes" }, "commit": { "hash": "a69b43321a23", diff --git a/tests/unit/provider/test_bitbucket.py b/tests/unit/provider/test_bitbucket.py index 76c4a013..600e5f6b 100644 --- a/tests/unit/provider/test_bitbucket.py +++ b/tests/unit/provider/test_bitbucket.py @@ -742,6 +742,40 @@ def test_keeps_short_hash_it_cannot_expand( 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"), @@ -753,7 +787,7 @@ def test_merged_pull_request_is_closed(self, provider: BitbucketProvider, client assert pull_request["state"] == "closed" assert pull_request["merged"] is True - assert pull_request["base"]["ref"] == "s7-merge-base" + 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") @@ -778,8 +812,10 @@ 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={"per_page": 2}) + 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), @@ -817,6 +853,11 @@ def test_head_filter_escapes_quotes(self, provider: BitbucketProvider, client: u 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: @@ -864,6 +905,13 @@ def test_maps_diffstat(self, provider: BitbucketProvider, client: unittest.mock. ] 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" @@ -916,17 +964,15 @@ def test_reads_every_page_oldest_first(self, provider: BitbucketProvider, client @staticmethod def with_merge_commit(merge_hash: str) -> requests.Response: - response = recorded("pull_request_commits") - body = response.json() + body = recorded("pull_request_commits").json() merge_commit = { **body["values"][0], "hash": merge_hash, - "message": "Merged in s7-file-changes (pull request #2)\n", - "parents": [{"hash": "a682892aa0f4502ef7a242f75d72d0b41eaa27b9"}, {"hash": body["values"][0]["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) - response._content = json.dumps(body).encode() - return response + return json_response(body) def test_drops_bitbucket_merge_commit(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: client.request.side_effect = [ @@ -950,6 +996,16 @@ def test_keeps_other_merge_commits(self, provider: BitbucketProvider, client: un 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: