From 2e7576839deb4fdc9c4ad88b174253985e951c1a Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Mon, 9 Nov 2026 12:12:41 -0800 Subject: [PATCH 1/2] feat(bitbucket): Add pull request writes --- bin/bitbucket-client | 57 +++++ src/scm/providers/bitbucket/provider.py | 92 ++++++++ .../pull_request_author_reviewer.json | 15 ++ .../bitbucket/pull_request_created.json | 169 +++++++++++++++ .../bitbucket/pull_request_declined.json | 181 ++++++++++++++++ .../bitbucket/pull_request_draft.json | 169 +++++++++++++++ .../bitbucket/pull_request_not_open.json | 10 + .../bitbucket/pull_request_updated.json | 169 +++++++++++++++ tests/unit/provider/test_bitbucket.py | 201 +++++++++++++++++- 9 files changed, 1062 insertions(+), 1 deletion(-) create mode 100644 tests/unit/provider/fixtures/bitbucket/pull_request_author_reviewer.json create mode 100644 tests/unit/provider/fixtures/bitbucket/pull_request_created.json create mode 100644 tests/unit/provider/fixtures/bitbucket/pull_request_declined.json create mode 100644 tests/unit/provider/fixtures/bitbucket/pull_request_draft.json create mode 100644 tests/unit/provider/fixtures/bitbucket/pull_request_not_open.json create mode 100644 tests/unit/provider/fixtures/bitbucket/pull_request_updated.json diff --git a/bin/bitbucket-client b/bin/bitbucket-client index 7edfa730..8e89917c 100755 --- a/bin/bitbucket-client +++ b/bin/bitbucket-client @@ -33,6 +33,11 @@ Commands: get-pull-request-commits get-pull-request-diff get-pull-request-url + create-pull-request <body> <head> <base> [--draft] + mark-pull-request-ready-for-review <id> + mark-pull-request-as-draft <id> + update-pull-request <id> [--title <title>] [--body <body>] [--state open|closed] + request-review <id> <reviewer_uuid> [<reviewer_uuid> ...] """ import argparse @@ -47,6 +52,8 @@ from scm import Secret from scm.manager import SourceCodeManager from scm.types import ( CompareCommitsProtocol, + CreatePullRequestDraftProtocol, + CreatePullRequestProtocol, GetAppInstallationProtocol, GetCommitChangesProtocol, GetCommitProtocol, @@ -67,7 +74,10 @@ from scm.types import ( GetPullRequestUrlProtocol, GetReadmeProtocol, GetRepositoryProtocol, + MarkPullRequestDraftStateProtocol, PaginationParams, + RequestReviewProtocol, + UpdatePullRequestProtocol, ) logger = logging.getLogger(__name__) @@ -196,6 +206,29 @@ def main(): p = sub.add_parser("get-pull-request-url") p.add_argument("id") + p = sub.add_parser("create-pull-request") + p.add_argument("title") + p.add_argument("body") + p.add_argument("head") + p.add_argument("base") + p.add_argument("--draft", action="store_true") + + p = sub.add_parser("mark-pull-request-ready-for-review") + p.add_argument("id") + + p = sub.add_parser("mark-pull-request-as-draft") + p.add_argument("id") + + p = sub.add_parser("update-pull-request") + p.add_argument("id") + p.add_argument("--title", default=None) + p.add_argument("--body", default=None) + p.add_argument("--state", default=None, choices=["open", "closed"]) + + p = sub.add_parser("request-review") + p.add_argument("id") + p.add_argument("reviewers", nargs="+") + args = parser.parse_args() if not args.command: parser.print_help() @@ -296,6 +329,30 @@ def main(): assert isinstance(scm, GetPullRequestUrlProtocol) dump(scm.get_pull_request_url(args.id)) + elif args.command == "create-pull-request": + if args.draft: + assert isinstance(scm, CreatePullRequestDraftProtocol) + dump(scm.create_pull_request_draft(args.title, args.body, args.head, args.base)) + else: + assert isinstance(scm, CreatePullRequestProtocol) + dump(scm.create_pull_request(args.title, args.body, args.head, args.base)) + + elif args.command == "mark-pull-request-ready-for-review": + assert isinstance(scm, MarkPullRequestDraftStateProtocol) + scm.mark_pull_request_ready_for_review(args.id) + + elif args.command == "mark-pull-request-as-draft": + assert isinstance(scm, MarkPullRequestDraftStateProtocol) + scm.mark_pull_request_as_draft(args.id) + + elif args.command == "update-pull-request": + assert isinstance(scm, UpdatePullRequestProtocol) + dump(scm.update_pull_request(args.id, title=args.title, body=args.body, state=args.state)) + + elif args.command == "request-review": + assert isinstance(scm, RequestReviewProtocol) + scm.request_review(args.id, args.reviewers) + 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 c2b8d8d3..c9d89225 100644 --- a/src/scm/providers/bitbucket/provider.py +++ b/src/scm/providers/bitbucket/provider.py @@ -14,6 +14,7 @@ PathIsNotDirectory, ReadmeNotFound, ResourceBadRequest, + ResourceConflict, ResourceForbidden, ResourceNotFound, SCMCodedError, @@ -589,6 +590,97 @@ def _full_hash(self, repository_path: str, short_hash: str, request_options: Req return short_hash return response.json()["hash"] + def create_pull_request( + self, title: str, body: str, head: BranchName, base: BranchName + ) -> ActionResult[PullRequest]: + return self._create_pull_request(title, body, head, base, draft=False) + + def create_pull_request_draft( + self, title: str, body: str, head: BranchName, base: BranchName + ) -> ActionResult[PullRequest]: + return self._create_pull_request(title, body, head, base, draft=True) + + def _create_pull_request( + self, title: str, body: str, head: BranchName, base: BranchName, draft: bool + ) -> ActionResult[PullRequest]: + # Bitbucket silently overwrites an open pull request between the same branches, + # so check first. + query = ( + f"source.branch.name={json.dumps(head, ensure_ascii=False)}" + f' AND destination.branch.name={json.dumps(base, ensure_ascii=False)} AND state="OPEN"' + ) + existing = self.get( + f"/2.0/repositories/{self.repository_path}/pullrequests", + params={"q": query}, + pagination={"per_page": 1}, + ).json() + if existing["values"]: + raise ResourceConflict(detail=f"A pull request already exists for {head}.") + response = self.post( + f"/2.0/repositories/{self.repository_path}/pullrequests", + data={ + "title": title, + "description": body, + "source": {"branch": {"name": head}}, + "destination": {"branch": {"name": base}}, + "draft": draft, + }, + ) + return map_action(response, map_pull_request) + + def mark_pull_request_ready_for_review(self, pull_request_id: str) -> None: + self._set_draft(pull_request_id, False) + + def mark_pull_request_as_draft(self, pull_request_id: str) -> None: + self._set_draft(pull_request_id, True) + + def _set_draft(self, pull_request_id: str, draft: bool) -> None: + current = self.get(self._pull_request_path(pull_request_id)).json() + if current["draft"] is not draft: + self._put_pull_request(pull_request_id, current, {"draft": draft}) + + def update_pull_request( + self, + pull_request_id: str, + title: str | None = None, + body: str | None = None, + state: PullRequestState | None = None, + ) -> ActionResult[PullRequest]: + data: dict[str, Any] = {} + if title is not None: + data["title"] = title + if body is not None: + data["description"] = body + response = self.get(self._pull_request_path(pull_request_id)) + current = response.json() + # Bitbucket can't reopen a declined pull request, so let the PUT fail with its error. + if data or (state == "open" and current["state"] != "OPEN"): + response = self._put_pull_request(pull_request_id, current, data) + if state == "closed" and response.json()["state"] == "OPEN": + response = self.post(f"{self._pull_request_path(pull_request_id)}/decline", data={}) + return map_action(response, map_pull_request) + + def request_review(self, pull_request_id: str, reviewers: list[str]) -> None: + """Add reviewers by account UUID, keeping the ones already on the pull request.""" + current = self.get(self._pull_request_path(pull_request_id)).json() + existing = [reviewer["uuid"] for reviewer in current["reviewers"]] + added = [uuid for uuid in dict.fromkeys(reviewers) if uuid not in existing] + if added: + self._put_pull_request(pull_request_id, current, {}, reviewers=existing + added) + + def _put_pull_request( + self, + pull_request_id: str, + current: dict[str, Any], + data: dict[str, Any], + reviewers: list[str] | None = None, + ) -> requests.Response: + # A PUT without `reviewers` can clear them, so always send the full list. + if reviewers is None: + reviewers = [reviewer["uuid"] for reviewer in current["reviewers"]] + data = {**data, "reviewers": [{"uuid": uuid} for uuid in reviewers]} + return self.put(self._pull_request_path(pull_request_id), data=data) + def parse_scopes(header: str) -> set[str]: return {scope.strip() for scope in header.split(",") if scope.strip()} diff --git a/tests/unit/provider/fixtures/bitbucket/pull_request_author_reviewer.json b/tests/unit/provider/fixtures/bitbucket/pull_request_author_reviewer.json new file mode 100644 index 00000000..106f5c73 --- /dev/null +++ b/tests/unit/provider/fixtures/bitbucket/pull_request_author_reviewer.json @@ -0,0 +1,15 @@ +{ + "body": { + "error": { + "fields": { + "reviewers": [ + "Test User is the author and cannot be included as a reviewer." + ] + }, + "message": "reviewers: Test User is the author and cannot be included as a reviewer." + }, + "type": "error" + }, + "headers": {}, + "status": 400 +} diff --git a/tests/unit/provider/fixtures/bitbucket/pull_request_created.json b/tests/unit/provider/fixtures/bitbucket/pull_request_created.json new file mode 100644 index 00000000..21327fa6 --- /dev/null +++ b/tests/unit/provider/fixtures/bitbucket/pull_request_created.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:43:01.374064+00:00", + "description": "A pull request from S8.", + "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": 5, + "links": { + "activity": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/5/activity" + }, + "approve": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/5/approve" + }, + "comments": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/5/comments" + }, + "commits": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/5/commits" + }, + "decline": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/5/decline" + }, + "diff": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/diff/test-workspace/test-repo:9fbd8db00325%0Da682892aa0f4?from_pullrequest_id=5&topic=true" + }, + "diffstat": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/diffstat/test-workspace/test-repo:9fbd8db00325%0Da682892aa0f4?from_pullrequest_id=5&topic=true" + }, + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/pull-requests/5" + }, + "merge": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/5/merge" + }, + "mergeability_checks": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/5/mergeability/checks" + }, + "request-changes": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/5/request-changes" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/5" + }, + "statuses": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/5/statuses" + } + }, + "merge_commit": null, + "participants": [], + "queued": false, + "reason": "", + "rendered": { + "description": { + "html": "<p>A pull request from S8.</p>", + "markup": "markdown", + "raw": "A pull request from S8.", + "type": "rendered" + }, + "title": { + "html": "<p>S8 create</p>", + "markup": "markdown", + "raw": "S8 create", + "type": "rendered" + } + }, + "reviewers": [], + "source": { + "branch": { + "links": {}, + "name": "s8-create", + "sync_strategies": [ + "merge_commit", + "rebase" + ] + }, + "commit": { + "hash": "9fbd8db00325", + "links": { + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/commits/9fbd8db00325" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/9fbd8db00325" + } + }, + "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": "<p>A pull request from S8.</p>", + "markup": "markdown", + "raw": "A pull request from S8.", + "type": "rendered" + }, + "task_count": 0, + "title": "S8 create", + "type": "pullrequest", + "updated_on": "2026-11-05T20:43:01.781355+00:00" + }, + "headers": {}, + "status": 201 +} diff --git a/tests/unit/provider/fixtures/bitbucket/pull_request_declined.json b/tests/unit/provider/fixtures/bitbucket/pull_request_declined.json new file mode 100644 index 00000000..6c44023e --- /dev/null +++ b/tests/unit/provider/fixtures/bitbucket/pull_request_declined.json @@ -0,0 +1,181 @@ +{ + "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}" + }, + "closed_on": "2026-11-05T20:43:15.485010+00:00", + "comment_count": 0, + "created_on": "2026-11-05T20:43:01.374064+00:00", + "description": "An updated 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": 5, + "links": { + "activity": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/5/activity" + }, + "approve": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/5/approve" + }, + "comments": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/5/comments" + }, + "commits": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/5/commits" + }, + "decline": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/5/decline" + }, + "diff": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/diff/test-workspace/test-repo:9fbd8db00325%0Da682892aa0f4?from_pullrequest_id=5&topic=true" + }, + "diffstat": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/diffstat/test-workspace/test-repo:9fbd8db00325%0Da682892aa0f4?from_pullrequest_id=5&topic=true" + }, + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/pull-requests/5" + }, + "merge": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/5/merge" + }, + "mergeability_checks": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/5/mergeability/checks" + }, + "request-changes": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/5/request-changes" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/5" + }, + "statuses": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/5/statuses" + } + }, + "merge_commit": null, + "participants": [], + "queued": false, + "reason": "", + "rendered": { + "description": { + "html": "<p>An updated description.</p>", + "markup": "markdown", + "raw": "An updated description.", + "type": "rendered" + }, + "title": { + "html": "<p>S8 create, renamed</p>", + "markup": "markdown", + "raw": "S8 create, renamed", + "type": "rendered" + } + }, + "reviewers": [], + "source": { + "branch": { + "links": {}, + "name": "s8-create", + "sync_strategies": [ + "merge_commit", + "rebase" + ] + }, + "commit": { + "hash": "9fbd8db00325", + "links": { + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/commits/9fbd8db00325" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/9fbd8db00325" + } + }, + "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": "<p>An updated description.</p>", + "markup": "markdown", + "raw": "An updated description.", + "type": "rendered" + }, + "task_count": 0, + "title": "S8 create, renamed", + "type": "pullrequest", + "updated_on": "2026-11-05T20:43:15.485016+00:00" + }, + "headers": {}, + "status": 200 +} diff --git a/tests/unit/provider/fixtures/bitbucket/pull_request_draft.json b/tests/unit/provider/fixtures/bitbucket/pull_request_draft.json new file mode 100644 index 00000000..624892e9 --- /dev/null +++ b/tests/unit/provider/fixtures/bitbucket/pull_request_draft.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:42:18.749868+00:00", + "description": "Draft probe.", + "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": true, + "id": 4, + "links": { + "activity": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/4/activity" + }, + "approve": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/4/approve" + }, + "comments": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/4/comments" + }, + "commits": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/4/commits" + }, + "decline": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/4/decline" + }, + "diff": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/diff/test-workspace/test-repo:aa3c6e02cf02%0Da682892aa0f4?from_pullrequest_id=4&topic=true" + }, + "diffstat": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/diffstat/test-workspace/test-repo:aa3c6e02cf02%0Da682892aa0f4?from_pullrequest_id=4&topic=true" + }, + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/pull-requests/4" + }, + "merge": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/4/merge" + }, + "mergeability_checks": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/4/mergeability/checks" + }, + "request-changes": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/4/request-changes" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/4" + }, + "statuses": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/4/statuses" + } + }, + "merge_commit": null, + "participants": [], + "queued": false, + "reason": "", + "rendered": { + "description": { + "html": "<p>Draft probe.</p>", + "markup": "markdown", + "raw": "Draft probe.", + "type": "rendered" + }, + "title": { + "html": "<p>S8 draft probe</p>", + "markup": "markdown", + "raw": "S8 draft probe", + "type": "rendered" + } + }, + "reviewers": [], + "source": { + "branch": { + "links": {}, + "name": "s8-draft", + "sync_strategies": [ + "merge_commit", + "rebase" + ] + }, + "commit": { + "hash": "aa3c6e02cf02", + "links": { + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/commits/aa3c6e02cf02" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/aa3c6e02cf02" + } + }, + "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": "<p>Draft probe.</p>", + "markup": "markdown", + "raw": "Draft probe.", + "type": "rendered" + }, + "task_count": 0, + "title": "S8 draft probe", + "type": "pullrequest", + "updated_on": "2026-11-05T20:43:11.832767+00:00" + }, + "headers": {}, + "status": 200 +} diff --git a/tests/unit/provider/fixtures/bitbucket/pull_request_not_open.json b/tests/unit/provider/fixtures/bitbucket/pull_request_not_open.json new file mode 100644 index 00000000..8af20ec6 --- /dev/null +++ b/tests/unit/provider/fixtures/bitbucket/pull_request_not_open.json @@ -0,0 +1,10 @@ +{ + "body": { + "error": { + "message": "Can only update an open pull request." + }, + "type": "error" + }, + "headers": {}, + "status": 400 +} diff --git a/tests/unit/provider/fixtures/bitbucket/pull_request_updated.json b/tests/unit/provider/fixtures/bitbucket/pull_request_updated.json new file mode 100644 index 00000000..4606b22f --- /dev/null +++ b/tests/unit/provider/fixtures/bitbucket/pull_request_updated.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:43:01.374064+00:00", + "description": "An updated 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": 5, + "links": { + "activity": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/5/activity" + }, + "approve": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/5/approve" + }, + "comments": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/5/comments" + }, + "commits": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/5/commits" + }, + "decline": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/5/decline" + }, + "diff": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/diff/test-workspace/test-repo:9fbd8db00325%0Da682892aa0f4?from_pullrequest_id=5&topic=true" + }, + "diffstat": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/diffstat/test-workspace/test-repo:9fbd8db00325%0Da682892aa0f4?from_pullrequest_id=5&topic=true" + }, + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/pull-requests/5" + }, + "merge": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/5/merge" + }, + "mergeability_checks": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/5/mergeability/checks" + }, + "request-changes": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/5/request-changes" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/5" + }, + "statuses": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests/5/statuses" + } + }, + "merge_commit": null, + "participants": [], + "queued": false, + "reason": "", + "rendered": { + "description": { + "html": "<p>An updated description.</p>", + "markup": "markdown", + "raw": "An updated description.", + "type": "rendered" + }, + "title": { + "html": "<p>S8 create, renamed</p>", + "markup": "markdown", + "raw": "S8 create, renamed", + "type": "rendered" + } + }, + "reviewers": [], + "source": { + "branch": { + "links": {}, + "name": "s8-create", + "sync_strategies": [ + "merge_commit", + "rebase" + ] + }, + "commit": { + "hash": "9fbd8db00325", + "links": { + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/commits/9fbd8db00325" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/9fbd8db00325" + } + }, + "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": "<p>An updated description.</p>", + "markup": "markdown", + "raw": "An updated description.", + "type": "rendered" + }, + "task_count": 0, + "title": "S8 create, renamed", + "type": "pullrequest", + "updated_on": "2026-11-05T20:43:14.437007+00:00" + }, + "headers": {}, + "status": 200 +} diff --git a/tests/unit/provider/test_bitbucket.py b/tests/unit/provider/test_bitbucket.py index 600e5f6b..b96b6322 100644 --- a/tests/unit/provider/test_bitbucket.py +++ b/tests/unit/provider/test_bitbucket.py @@ -8,7 +8,14 @@ import pytest import requests -from scm.errors import PathIsDirectory, PathIsNotDirectory, ReadmeNotFound, ResourceBadRequest, ResourceNotFound +from scm.errors import ( + PathIsDirectory, + PathIsNotDirectory, + ReadmeNotFound, + ResourceBadRequest, + ResourceConflict, + ResourceNotFound, +) from scm.providers.bitbucket.provider import ( BitbucketProvider, map_app_installation, @@ -23,6 +30,8 @@ ) from scm.types import ( CompareCommitsProtocol, + CreatePullRequestDraftProtocol, + CreatePullRequestProtocol, GetAppInstallationProtocol, GetCommitChangesProtocol, GetCommitProtocol, @@ -43,8 +52,11 @@ GetPullRequestUrlProtocol, GetReadmeProtocol, GetRepositoryProtocol, + MarkPullRequestDraftStateProtocol, PullRequestState, Repository, + RequestReviewProtocol, + UpdatePullRequestProtocol, ) FIXTURES = pathlib.Path(__file__).parent / "fixtures" / "bitbucket" @@ -73,6 +85,11 @@ GetPullRequestProtocol, GetPullRequestsProtocol, GetPullRequestUrlProtocol, + CreatePullRequestDraftProtocol, + CreatePullRequestProtocol, + MarkPullRequestDraftStateProtocol, + RequestReviewProtocol, + UpdatePullRequestProtocol, ] COMMIT_SHA = "e723f15089c22737bb51aee8db06e0eab91e4ba1" HEAD_SHA = "e3ef83d1a831b7b26c838372f668b1627bbf9a1a" @@ -1021,3 +1038,185 @@ def test_returns_diff_text(self, provider: BitbucketProvider, client: unittest.m def test_get_pull_request_url(provider: BitbucketProvider) -> None: assert provider.get_pull_request_url("1") == f"https://bitbucket.org/{REPO}/pull-requests/1" + + +def with_reviewers(name: str, *uuids: str) -> requests.Response: + response = recorded(name) + body = response.json() + body["reviewers"] = [{"type": "user", "uuid": uuid} for uuid in uuids] + response._content = json.dumps(body).encode() + return response + + +def pull_requests_page(*names: str) -> requests.Response: + response = recorded("pull_requests_page_1") + response._content = json.dumps({"values": [recorded(name).json() for name in names]}).encode() + return response + + +class TestCreatePullRequest: + def test_creates_pull_request(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.side_effect = [pull_requests_page(), recorded("pull_request_created")] + + result = provider.create_pull_request("S8 create", "A pull request from S8.", "s8-create", "main") + + check = client.request.call_args_list[0].kwargs + assert (check["method"], check["path"]) == ("GET", f"/2.0/repositories/{REPO}/pullrequests") + assert check["params"]["q"] == ( + 'source.branch.name="s8-create" AND destination.branch.name="main" AND state="OPEN"' + ) + assert request_kwargs(client)["method"] == "POST" + assert request_kwargs(client)["path"] == f"/2.0/repositories/{REPO}/pullrequests" + assert request_kwargs(client)["data"] == { + "title": "S8 create", + "description": "A pull request from S8.", + "source": {"branch": {"name": "s8-create"}}, + "destination": {"branch": {"name": "main"}}, + "draft": False, + } + assert result["data"]["id"] == "5" + assert result["data"]["state"] == "open" + assert result["data"]["head"]["ref"] == "s8-create" + + def test_creates_draft(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.side_effect = [pull_requests_page(), recorded("pull_request_draft")] + + result = provider.create_pull_request_draft("S8 draft probe", "Draft probe.", "s8-draft", "main") + + assert request_kwargs(client)["data"]["draft"] is True + assert result["data"]["id"] == "4" + + @pytest.mark.parametrize("method", ["create_pull_request", "create_pull_request_draft"]) + def test_open_pull_request_for_branches_conflicts( + self, provider: BitbucketProvider, client: unittest.mock.MagicMock, method: str + ) -> None: + client.request.return_value = pull_requests_page("pull_request_created") + + with pytest.raises(ResourceConflict): + getattr(provider, method)("S8 create", "", "s8-create", "main") + + assert client.request.call_count == 1 + + +class TestMarkPullRequestDraftState: + def test_marks_ready_and_keeps_reviewers( + self, provider: BitbucketProvider, client: unittest.mock.MagicMock + ) -> None: + client.request.side_effect = [with_reviewers("pull_request_draft", "{r1}"), recorded("pull_request_created")] + + provider.mark_pull_request_ready_for_review("4") + + put = client.request.call_args_list[1].kwargs + assert put["method"] == "PUT" + assert put["path"] == f"/2.0/repositories/{REPO}/pullrequests/4" + assert put["data"] == {"draft": False, "reviewers": [{"uuid": "{r1}"}]} + + def test_marks_draft(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.side_effect = [recorded("pull_request_created"), recorded("pull_request_draft")] + + provider.mark_pull_request_as_draft("5") + + assert client.request.call_args_list[1].kwargs["data"] == {"draft": True, "reviewers": []} + + @pytest.mark.parametrize( + ("fixture", "method"), + [ + ("pull_request_draft", "mark_pull_request_as_draft"), + ("pull_request_created", "mark_pull_request_ready_for_review"), + ], + ) + def test_already_in_state_sends_nothing( + self, provider: BitbucketProvider, client: unittest.mock.MagicMock, fixture: str, method: str + ) -> None: + client.request.return_value = recorded(fixture) + + getattr(provider, method)("1") + + assert client.request.call_count == 1 + + +class TestUpdatePullRequest: + def test_updates_title_and_body(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.side_effect = [with_reviewers("pull_request_created", "{r1}"), recorded("pull_request_updated")] + + result = provider.update_pull_request("5", title="S8 create, renamed", body="An updated description.") + + assert client.request.call_args_list[1].kwargs["data"] == { + "title": "S8 create, renamed", + "description": "An updated description.", + "reviewers": [{"uuid": "{r1}"}], + } + assert result["data"]["title"] == "S8 create, renamed" + assert result["data"]["body"] == "An updated description." + + def test_close_declines(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.side_effect = [recorded("pull_request_updated"), recorded("pull_request_declined")] + + result = provider.update_pull_request("5", state="closed") + + decline = client.request.call_args_list[1].kwargs + assert (decline["method"], decline["path"]) == ("POST", f"/2.0/repositories/{REPO}/pullrequests/5/decline") + assert result["data"]["state"] == "closed" + assert result["data"]["merged"] is False + + def test_close_closed_pull_request_sends_nothing( + self, provider: BitbucketProvider, client: unittest.mock.MagicMock + ) -> None: + client.request.return_value = recorded("pull_request_declined") + + result = provider.update_pull_request("5", state="closed") + + assert client.request.call_count == 1 + assert result["data"]["state"] == "closed" + + def test_reopen_open_pull_request_sends_nothing( + self, provider: BitbucketProvider, client: unittest.mock.MagicMock + ) -> None: + client.request.return_value = recorded("pull_request_created") + + result = provider.update_pull_request("5", state="open") + + assert client.request.call_count == 1 + assert result["data"]["state"] == "open" + + def test_reopen_declined_fails(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.side_effect = [recorded("pull_request_declined"), recorded("pull_request_not_open")] + + with pytest.raises(ResourceBadRequest, match="Can only update an open pull request"): + provider.update_pull_request("5", state="open") + + assert client.request.call_args_list[1].kwargs["method"] == "PUT" + + def test_nothing_to_change_only_reads(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.return_value = recorded("pull_request_created") + + result = provider.update_pull_request("5") + + assert client.request.call_count == 1 + assert result["data"]["id"] == "5" + + +class TestRequestReview: + def test_adds_to_existing_reviewers(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.side_effect = [with_reviewers("pull_request_created", "{r1}"), recorded("pull_request_created")] + + provider.request_review("5", ["{r2}", "{r1}", "{r2}"]) + + put = client.request.call_args_list[1].kwargs + assert put["method"] == "PUT" + assert put["data"] == {"reviewers": [{"uuid": "{r1}"}, {"uuid": "{r2}"}]} + + def test_existing_reviewers_send_nothing( + self, provider: BitbucketProvider, client: unittest.mock.MagicMock + ) -> None: + client.request.return_value = with_reviewers("pull_request_created", "{r1}") + + provider.request_review("5", ["{r1}"]) + + assert client.request.call_count == 1 + + def test_author_cannot_review(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.side_effect = [recorded("pull_request_draft"), recorded("pull_request_author_reviewer")] + + with pytest.raises(ResourceBadRequest): + provider.request_review("4", [TEST_USER["id"]]) From 2e83480e309c29de131f77d3fbea959808e10a76 Mon Sep 17 00:00:00 2001 From: Dan Fuller <dfuller@sentry.io> Date: Mon, 9 Nov 2026 12:25:22 -0800 Subject: [PATCH 2/2] test(bitbucket): Cover pull request closing and branch escaping --- .../bitbucket/pull_request_created.json | 18 ++--- .../bitbucket/pull_request_declined.json | 8 +-- .../bitbucket/pull_request_draft.json | 18 ++--- .../bitbucket/pull_request_updated.json | 8 +-- tests/unit/provider/test_bitbucket.py | 72 ++++++++++++++----- 5 files changed, 80 insertions(+), 44 deletions(-) diff --git a/tests/unit/provider/fixtures/bitbucket/pull_request_created.json b/tests/unit/provider/fixtures/bitbucket/pull_request_created.json index 21327fa6..921ed7da 100644 --- a/tests/unit/provider/fixtures/bitbucket/pull_request_created.json +++ b/tests/unit/provider/fixtures/bitbucket/pull_request_created.json @@ -16,7 +16,7 @@ "closed_by": null, "comment_count": 0, "created_on": "2026-11-05T20:43:01.374064+00:00", - "description": "A pull request from S8.", + "description": "A new pull request.", "destination": { "branch": { "name": "main" @@ -100,15 +100,15 @@ "reason": "", "rendered": { "description": { - "html": "<p>A pull request from S8.</p>", + "html": "<p>A new pull request.</p>", "markup": "markdown", - "raw": "A pull request from S8.", + "raw": "A new pull request.", "type": "rendered" }, "title": { - "html": "<p>S8 create</p>", + "html": "<p>New pull request</p>", "markup": "markdown", - "raw": "S8 create", + "raw": "New pull request", "type": "rendered" } }, @@ -116,7 +116,7 @@ "source": { "branch": { "links": {}, - "name": "s8-create", + "name": "new-feature", "sync_strategies": [ "merge_commit", "rebase" @@ -154,13 +154,13 @@ }, "state": "OPEN", "summary": { - "html": "<p>A pull request from S8.</p>", + "html": "<p>A new pull request.</p>", "markup": "markdown", - "raw": "A pull request from S8.", + "raw": "A new pull request.", "type": "rendered" }, "task_count": 0, - "title": "S8 create", + "title": "New pull request", "type": "pullrequest", "updated_on": "2026-11-05T20:43:01.781355+00:00" }, diff --git a/tests/unit/provider/fixtures/bitbucket/pull_request_declined.json b/tests/unit/provider/fixtures/bitbucket/pull_request_declined.json index 6c44023e..8c0c7293 100644 --- a/tests/unit/provider/fixtures/bitbucket/pull_request_declined.json +++ b/tests/unit/provider/fixtures/bitbucket/pull_request_declined.json @@ -118,9 +118,9 @@ "type": "rendered" }, "title": { - "html": "<p>S8 create, renamed</p>", + "html": "<p>Renamed pull request</p>", "markup": "markdown", - "raw": "S8 create, renamed", + "raw": "Renamed pull request", "type": "rendered" } }, @@ -128,7 +128,7 @@ "source": { "branch": { "links": {}, - "name": "s8-create", + "name": "new-feature", "sync_strategies": [ "merge_commit", "rebase" @@ -172,7 +172,7 @@ "type": "rendered" }, "task_count": 0, - "title": "S8 create, renamed", + "title": "Renamed pull request", "type": "pullrequest", "updated_on": "2026-11-05T20:43:15.485016+00:00" }, diff --git a/tests/unit/provider/fixtures/bitbucket/pull_request_draft.json b/tests/unit/provider/fixtures/bitbucket/pull_request_draft.json index 624892e9..10241afd 100644 --- a/tests/unit/provider/fixtures/bitbucket/pull_request_draft.json +++ b/tests/unit/provider/fixtures/bitbucket/pull_request_draft.json @@ -16,7 +16,7 @@ "closed_by": null, "comment_count": 0, "created_on": "2026-11-05T20:42:18.749868+00:00", - "description": "Draft probe.", + "description": "A draft pull request.", "destination": { "branch": { "name": "main" @@ -100,15 +100,15 @@ "reason": "", "rendered": { "description": { - "html": "<p>Draft probe.</p>", + "html": "<p>A draft pull request.</p>", "markup": "markdown", - "raw": "Draft probe.", + "raw": "A draft pull request.", "type": "rendered" }, "title": { - "html": "<p>S8 draft probe</p>", + "html": "<p>Draft pull request</p>", "markup": "markdown", - "raw": "S8 draft probe", + "raw": "Draft pull request", "type": "rendered" } }, @@ -116,7 +116,7 @@ "source": { "branch": { "links": {}, - "name": "s8-draft", + "name": "draft-feature", "sync_strategies": [ "merge_commit", "rebase" @@ -154,13 +154,13 @@ }, "state": "OPEN", "summary": { - "html": "<p>Draft probe.</p>", + "html": "<p>A draft pull request.</p>", "markup": "markdown", - "raw": "Draft probe.", + "raw": "A draft pull request.", "type": "rendered" }, "task_count": 0, - "title": "S8 draft probe", + "title": "Draft pull request", "type": "pullrequest", "updated_on": "2026-11-05T20:43:11.832767+00:00" }, diff --git a/tests/unit/provider/fixtures/bitbucket/pull_request_updated.json b/tests/unit/provider/fixtures/bitbucket/pull_request_updated.json index 4606b22f..a4b85c1f 100644 --- a/tests/unit/provider/fixtures/bitbucket/pull_request_updated.json +++ b/tests/unit/provider/fixtures/bitbucket/pull_request_updated.json @@ -106,9 +106,9 @@ "type": "rendered" }, "title": { - "html": "<p>S8 create, renamed</p>", + "html": "<p>Renamed pull request</p>", "markup": "markdown", - "raw": "S8 create, renamed", + "raw": "Renamed pull request", "type": "rendered" } }, @@ -116,7 +116,7 @@ "source": { "branch": { "links": {}, - "name": "s8-create", + "name": "new-feature", "sync_strategies": [ "merge_commit", "rebase" @@ -160,7 +160,7 @@ "type": "rendered" }, "task_count": 0, - "title": "S8 create, renamed", + "title": "Renamed pull request", "type": "pullrequest", "updated_on": "2026-11-05T20:43:14.437007+00:00" }, diff --git a/tests/unit/provider/test_bitbucket.py b/tests/unit/provider/test_bitbucket.py index b96b6322..6f20133b 100644 --- a/tests/unit/provider/test_bitbucket.py +++ b/tests/unit/provider/test_bitbucket.py @@ -1041,51 +1041,60 @@ def test_get_pull_request_url(provider: BitbucketProvider) -> None: def with_reviewers(name: str, *uuids: str) -> requests.Response: - response = recorded(name) - body = response.json() + body = recorded(name).json() body["reviewers"] = [{"type": "user", "uuid": uuid} for uuid in uuids] - response._content = json.dumps(body).encode() - return response + return json_response(body) def pull_requests_page(*names: str) -> requests.Response: - response = recorded("pull_requests_page_1") - response._content = json.dumps({"values": [recorded(name).json() for name in names]}).encode() - return response + return json_response({"values": [recorded(name).json() for name in names]}) class TestCreatePullRequest: def test_creates_pull_request(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: client.request.side_effect = [pull_requests_page(), recorded("pull_request_created")] - result = provider.create_pull_request("S8 create", "A pull request from S8.", "s8-create", "main") + result = provider.create_pull_request("New pull request", "A new pull request.", "new-feature", "main") check = client.request.call_args_list[0].kwargs assert (check["method"], check["path"]) == ("GET", f"/2.0/repositories/{REPO}/pullrequests") assert check["params"]["q"] == ( - 'source.branch.name="s8-create" AND destination.branch.name="main" AND state="OPEN"' + 'source.branch.name="new-feature" AND destination.branch.name="main" AND state="OPEN"' ) assert request_kwargs(client)["method"] == "POST" assert request_kwargs(client)["path"] == f"/2.0/repositories/{REPO}/pullrequests" assert request_kwargs(client)["data"] == { - "title": "S8 create", - "description": "A pull request from S8.", - "source": {"branch": {"name": "s8-create"}}, + "title": "New pull request", + "description": "A new pull request.", + "source": {"branch": {"name": "new-feature"}}, "destination": {"branch": {"name": "main"}}, "draft": False, } assert result["data"]["id"] == "5" assert result["data"]["state"] == "open" - assert result["data"]["head"]["ref"] == "s8-create" + assert result["data"]["head"]["ref"] == "new-feature" def test_creates_draft(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: client.request.side_effect = [pull_requests_page(), recorded("pull_request_draft")] - result = provider.create_pull_request_draft("S8 draft probe", "Draft probe.", "s8-draft", "main") + result = provider.create_pull_request_draft( + "Draft pull request", "A draft pull request.", "draft-feature", "main" + ) assert request_kwargs(client)["data"]["draft"] is True assert result["data"]["id"] == "4" + def test_escapes_branch_names_when_checking_for_a_pull_request( + self, provider: BitbucketProvider, client: unittest.mock.MagicMock + ) -> None: + client.request.side_effect = [pull_requests_page(), recorded("pull_request_created")] + + provider.create_pull_request("Title", "", 'a"b', 'c"d') + + assert client.request.call_args_list[0].kwargs["params"]["q"] == ( + 'source.branch.name="a\\"b" AND destination.branch.name="c\\"d" AND state="OPEN"' + ) + @pytest.mark.parametrize("method", ["create_pull_request", "create_pull_request_draft"]) def test_open_pull_request_for_branches_conflicts( self, provider: BitbucketProvider, client: unittest.mock.MagicMock, method: str @@ -1093,7 +1102,7 @@ def test_open_pull_request_for_branches_conflicts( client.request.return_value = pull_requests_page("pull_request_created") with pytest.raises(ResourceConflict): - getattr(provider, method)("S8 create", "", "s8-create", "main") + getattr(provider, method)("New pull request", "", "new-feature", "main") assert client.request.call_count == 1 @@ -1139,14 +1148,14 @@ class TestUpdatePullRequest: def test_updates_title_and_body(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: client.request.side_effect = [with_reviewers("pull_request_created", "{r1}"), recorded("pull_request_updated")] - result = provider.update_pull_request("5", title="S8 create, renamed", body="An updated description.") + result = provider.update_pull_request("5", title="Renamed pull request", body="An updated description.") assert client.request.call_args_list[1].kwargs["data"] == { - "title": "S8 create, renamed", + "title": "Renamed pull request", "description": "An updated description.", "reviewers": [{"uuid": "{r1}"}], } - assert result["data"]["title"] == "S8 create, renamed" + assert result["data"]["title"] == "Renamed pull request" assert result["data"]["body"] == "An updated description." def test_close_declines(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: @@ -1159,6 +1168,33 @@ def test_close_declines(self, provider: BitbucketProvider, client: unittest.mock assert result["data"]["state"] == "closed" assert result["data"]["merged"] is False + def test_updates_then_declines(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.side_effect = [ + recorded("pull_request_created"), + recorded("pull_request_updated"), + recorded("pull_request_declined"), + ] + + result = provider.update_pull_request("5", title="New title", state="closed") + + put, decline = client.request.call_args_list[1:] + assert (put.kwargs["method"], put.kwargs["data"]["title"]) == ("PUT", "New title") + assert (decline.kwargs["method"], decline.kwargs["path"]) == ( + "POST", + f"/2.0/repositories/{REPO}/pullrequests/5/decline", + ) + assert result["data"]["state"] == "closed" + + def test_no_decline_when_update_shows_closed( + self, provider: BitbucketProvider, client: unittest.mock.MagicMock + ) -> None: + client.request.side_effect = [recorded("pull_request_created"), recorded("pull_request_declined")] + + result = provider.update_pull_request("5", title="New title", state="closed") + + assert client.request.call_count == 2 + assert result["data"]["state"] == "closed" + def test_close_closed_pull_request_sends_nothing( self, provider: BitbucketProvider, client: unittest.mock.MagicMock ) -> None: