Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions bin/bitbucket-client
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ Commands:
get-pull-request-commits <id>
get-pull-request-diff <id>
get-pull-request-url <id>
create-pull-request <title> <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
Expand All @@ -47,6 +52,8 @@ from scm import Secret
from scm.manager import SourceCodeManager
from scm.types import (
CompareCommitsProtocol,
CreatePullRequestDraftProtocol,
CreatePullRequestProtocol,
GetAppInstallationProtocol,
GetCommitChangesProtocol,
GetCommitProtocol,
Expand All @@ -67,7 +74,10 @@ from scm.types import (
GetPullRequestUrlProtocol,
GetReadmeProtocol,
GetRepositoryProtocol,
MarkPullRequestDraftStateProtocol,
PaginationParams,
RequestReviewProtocol,
UpdatePullRequestProtocol,
)

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
92 changes: 92 additions & 0 deletions src/scm/providers/bitbucket/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
PathIsNotDirectory,
ReadmeNotFound,
ResourceBadRequest,
ResourceConflict,
ResourceForbidden,
ResourceNotFound,
SCMCodedError,
Expand Down Expand Up @@ -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()}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
169 changes: 169 additions & 0 deletions tests/unit/provider/fixtures/bitbucket/pull_request_created.json
Original file line number Diff line number Diff line change
@@ -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 new 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": 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 new pull request.</p>",
"markup": "markdown",
"raw": "A new pull request.",
"type": "rendered"
},
"title": {
"html": "<p>New pull request</p>",
"markup": "markdown",
"raw": "New pull request",
"type": "rendered"
}
},
"reviewers": [],
"source": {
"branch": {
"links": {},
"name": "new-feature",
"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 new pull request.</p>",
"markup": "markdown",
"raw": "A new pull request.",
"type": "rendered"
},
"task_count": 0,
"title": "New pull request",
"type": "pullrequest",
"updated_on": "2026-11-05T20:43:01.781355+00:00"
},
"headers": {},
"status": 201
}
Loading
Loading