blob: 7c672ce4c4605ac34b4f583079dbad9b22e1ea71 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from __future__ import annotations
import json
import urllib.request
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from magpie_bitbucket import cloud, datacenter
from magpie_bitbucket.cli import main
from magpie_bitbucket.client import BitbucketError, SameHostRedirectHandler, load_config, make_auth_header
from magpie_bitbucket.normalize import (
issue,
issue_comments,
issue_list,
pull_request,
pull_request_commits,
pull_request_diff,
pull_request_discussion,
pull_request_list,
pull_request_merge_checks,
pull_request_reviews,
pull_request_status,
repository,
repository_restrictions,
)
@pytest.fixture
def cloud_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("BITBUCKET_KIND", "cloud")
monkeypatch.setenv("BITBUCKET_CLOUD_USER", "alice@example.test")
monkeypatch.setenv("BITBUCKET_TOKEN", "token-123")
monkeypatch.setenv("BITBUCKET_WORKSPACE", "apache")
monkeypatch.setenv("BITBUCKET_REPO_SLUG", "magpie")
@pytest.fixture
def datacenter_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("BITBUCKET_KIND", "datacenter")
monkeypatch.setenv("BITBUCKET_TOKEN", "token-123")
monkeypatch.setenv("BITBUCKET_BASE_URL", "https://bitbucket.example.test/")
monkeypatch.setenv("BITBUCKET_PROJECT_KEY", "MAGPIE")
monkeypatch.setenv("BITBUCKET_REPO_SLUG", "magpie")
def make_mock_response(body: dict[str, Any]) -> MagicMock:
response = MagicMock()
response.read.return_value = json.dumps(body).encode()
return response
def make_mock_text_response(
body: str,
content_type: str = "text/x-diff",
url: str = "https://bitbucket.example.test/diff",
) -> MagicMock:
response = MagicMock()
response.read.return_value = body.encode()
response.headers.get_content_charset.return_value = "utf-8"
response.headers.get.return_value = content_type
response.geturl.return_value = url
return response
def mock_opener(mock_build_opener: MagicMock, *bodies: dict[str, Any]) -> MagicMock:
opener = MagicMock()
opener.open.side_effect = [
MagicMock(
__enter__=MagicMock(return_value=make_mock_response(body)), __exit__=MagicMock(return_value=None)
)
for body in bodies
]
mock_build_opener.return_value = opener
return opener
def urllib_request(url: str) -> urllib.request.Request:
return urllib.request.Request(
url,
headers={"Authorization": "Bearer token-123"},
method="GET",
)
def test_same_host_redirect_handler_allows_same_origin() -> None:
handler = SameHostRedirectHandler()
request = urllib_request("https://bitbucket.example.test/rest/api/1.0/foo")
redirected = handler.redirect_request(
request,
None,
302,
"Found",
{},
"https://bitbucket.example.test/rest/api/1.0/bar",
)
assert redirected is not None
assert redirected.full_url == "https://bitbucket.example.test/rest/api/1.0/bar"
def test_same_host_redirect_handler_rejects_different_host() -> None:
handler = SameHostRedirectHandler()
request = urllib_request("https://bitbucket.example.test/rest/api/1.0/foo")
with pytest.raises(BitbucketError, match="refusing to forward credentials"):
handler.redirect_request(
request,
None,
302,
"Found",
{},
"https://evil.example.test/rest/api/1.0/bar",
)
def test_same_host_redirect_handler_rejects_different_port() -> None:
handler = SameHostRedirectHandler()
request = urllib_request("https://bitbucket.example.test:8443/rest/api/1.0/foo")
with pytest.raises(BitbucketError, match="refusing to forward credentials"):
handler.redirect_request(
request,
None,
302,
"Found",
{},
"https://bitbucket.example.test:9443/rest/api/1.0/bar",
)
def test_load_config_defaults_to_cloud(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("BITBUCKET_KIND", raising=False)
config = load_config()
assert config.kind == "cloud"
assert config.auth_scheme == "Basic"
def test_load_config_rejects_unknown_kind(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("BITBUCKET_KIND", "server")
with pytest.raises(BitbucketError, match="BITBUCKET_KIND must be 'cloud' or 'datacenter'"):
load_config()
def test_make_auth_header_basic(cloud_env: None) -> None:
config = load_config()
assert make_auth_header(config).startswith("Basic ")
def test_make_auth_header_rejects_unknown_scheme(cloud_env: None, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("BITBUCKET_AUTH_SCHEME", "Digest")
config = load_config()
with pytest.raises(BitbucketError, match="BITBUCKET_AUTH_SCHEME must be 'Basic' or 'Bearer'"):
make_auth_header(config)
def test_make_auth_header_bearer(datacenter_env: None) -> None:
config = load_config()
assert make_auth_header(config) == "Bearer token-123"
@patch("urllib.request.build_opener")
def test_cloud_get_repository_url(mock_build_opener: MagicMock, cloud_env: None) -> None:
opener = mock_opener(mock_build_opener, {"name": "Magpie", "slug": "magpie"})
result = cloud.get_repository(load_config())
request = opener.open.call_args.args[0]
assert request.full_url == "https://api.bitbucket.org/2.0/repositories/apache/magpie"
assert opener.open.call_args.kwargs["timeout"] == 30
assert result["name"] == "Magpie"
@patch("urllib.request.build_opener")
def test_cloud_list_open_pull_requests_follows_next(mock_build_opener: MagicMock, cloud_env: None) -> None:
opener = mock_opener(
mock_build_opener,
{
"values": [{"id": 1, "title": "One"}],
"next": "https://api.bitbucket.org/2.0/repositories/apache/magpie/pullrequests?page=2",
},
{"values": [{"id": 2, "title": "Two"}]},
)
result = cloud.list_open_pull_requests(load_config())
first_request = opener.open.call_args_list[0].args[0]
second_request = opener.open.call_args_list[1].args[0]
assert (
first_request.full_url
== "https://api.bitbucket.org/2.0/repositories/apache/magpie/pullrequests?state=OPEN"
)
assert (
second_request.full_url
== "https://api.bitbucket.org/2.0/repositories/apache/magpie/pullrequests?page=2"
)
assert [item["id"] for item in result["values"]] == [1, 2]
@patch("urllib.request.build_opener")
def test_cloud_get_pull_request_url(mock_build_opener: MagicMock, cloud_env: None) -> None:
opener = mock_opener(mock_build_opener, {"id": 7, "title": "Fix docs"})
result = cloud.get_pull_request(load_config(), "7")
request = opener.open.call_args.args[0]
assert request.full_url == "https://api.bitbucket.org/2.0/repositories/apache/magpie/pullrequests/7"
assert result["title"] == "Fix docs"
@patch("urllib.request.build_opener")
def test_datacenter_get_repository_url(mock_build_opener: MagicMock, datacenter_env: None) -> None:
opener = mock_opener(mock_build_opener, {"name": "Magpie", "slug": "magpie"})
result = datacenter.get_repository(load_config())
request = opener.open.call_args.args[0]
assert request.full_url == "https://bitbucket.example.test/rest/api/1.0/projects/MAGPIE/repos/magpie"
assert result["slug"] == "magpie"
@patch("urllib.request.build_opener")
def test_datacenter_list_open_pull_requests_follows_next_page_start(
mock_build_opener: MagicMock,
datacenter_env: None,
) -> None:
opener = mock_opener(
mock_build_opener,
{"values": [{"id": 1, "title": "One"}], "isLastPage": False, "nextPageStart": 25},
{"values": [{"id": 2, "title": "Two"}], "isLastPage": True},
)
result = datacenter.list_open_pull_requests(load_config())
first_request = opener.open.call_args_list[0].args[0]
second_request = opener.open.call_args_list[1].args[0]
assert (
first_request.full_url
== "https://bitbucket.example.test/rest/api/1.0/projects/MAGPIE/repos/magpie/pull-requests?state=OPEN&start=0"
)
assert (
second_request.full_url
== "https://bitbucket.example.test/rest/api/1.0/projects/MAGPIE/repos/magpie/pull-requests?state=OPEN&start=25"
)
assert [item["id"] for item in result["values"]] == [1, 2]
@patch("urllib.request.build_opener")
def test_datacenter_get_pull_request_url(mock_build_opener: MagicMock, datacenter_env: None) -> None:
opener = mock_opener(mock_build_opener, {"id": 9, "title": "Fix tests"})
result = datacenter.get_pull_request(load_config(), "9")
request = opener.open.call_args.args[0]
assert (
request.full_url
== "https://bitbucket.example.test/rest/api/1.0/projects/MAGPIE/repos/magpie/pull-requests/9"
)
assert result["title"] == "Fix tests"
@patch("urllib.request.build_opener")
def test_cloud_get_pull_request_status_follows_next(
mock_build_opener: MagicMock,
cloud_env: None,
) -> None:
opener = mock_opener(
mock_build_opener,
{
"values": [{"key": "build", "state": "SUCCESSFUL"}],
"next": "https://api.bitbucket.org/2.0/repositories/apache/magpie/pullrequests/7/statuses?page=2",
},
{"values": [{"key": "lint", "state": "INPROGRESS"}]},
)
result = cloud.get_pull_request_status(load_config(), "7")
first_request = opener.open.call_args_list[0].args[0]
second_request = opener.open.call_args_list[1].args[0]
assert (
first_request.full_url
== "https://api.bitbucket.org/2.0/repositories/apache/magpie/pullrequests/7/statuses"
)
assert (
second_request.full_url
== "https://api.bitbucket.org/2.0/repositories/apache/magpie/pullrequests/7/statuses?page=2"
)
assert result["pull_request_id"] == "7"
assert [item["key"] for item in result["values"]] == ["build", "lint"]
@patch("urllib.request.build_opener")
def test_datacenter_get_pull_request_status_uses_latest_commit_and_paginates(
mock_build_opener: MagicMock,
datacenter_env: None,
) -> None:
opener = mock_opener(
mock_build_opener,
{"id": 9, "fromRef": {"latestCommit": "def456"}},
{"values": [{"key": "build", "state": "SUCCESSFUL"}], "isLastPage": False, "nextPageStart": 25},
{"values": [{"key": "lint", "state": "FAILED"}], "isLastPage": True},
)
result = datacenter.get_pull_request_status(load_config(), "9")
first_request = opener.open.call_args_list[0].args[0]
second_request = opener.open.call_args_list[1].args[0]
third_request = opener.open.call_args_list[2].args[0]
assert (
first_request.full_url
== "https://bitbucket.example.test/rest/api/1.0/projects/MAGPIE/repos/magpie/pull-requests/9"
)
assert (
second_request.full_url
== "https://bitbucket.example.test/rest/build-status/1.0/commits/def456?start=0"
)
assert (
third_request.full_url
== "https://bitbucket.example.test/rest/build-status/1.0/commits/def456?start=25"
)
assert result["commit"] == "def456"
assert [item["key"] for item in result["values"]] == ["build", "lint"]
def test_normalize_cloud_pull_request_status() -> None:
raw = {
"pull_request_id": "7",
"commit": "abc123",
"values": [
{
"key": "build",
"name": "Build",
"state": "SUCCESSFUL",
"url": "https://ci.example.test/build/1",
"description": "Build passed",
"created_on": "2026-07-09T10:00:00+00:00",
"updated_on": "2026-07-09T10:02:00+00:00",
}
],
}
result = pull_request_status("cloud", raw)
assert result["backend"] == "bitbucket-cloud"
assert result["coverage"] == "partial-read-only"
assert result["pull_request_id"] == "7"
assert result["commit"] == "abc123"
assert result["state"] == "unknown"
assert result["checks"] == "passing"
assert result["mergeable"] == "unknown"
assert result["check_details"][0]["key"] == "build"
assert result["check_details"][0]["state"] == "success"
assert result["check_details"][0]["url"] == "https://ci.example.test/build/1"
def test_normalize_datacenter_pull_request_status_failure() -> None:
raw = {
"pull_request_id": "9",
"commit": "def456",
"values": [
{
"key": "build",
"name": "Build",
"state": "FAILED",
"url": "https://ci.example.test/build/2",
"description": "Build failed",
"dateAdded": 1783428000000,
"dateUpdated": 1783428300000,
}
],
}
result = pull_request_status("datacenter", raw)
assert result["backend"] == "bitbucket-datacenter"
assert result["pull_request_id"] == "9"
assert result["commit"] == "def456"
assert result["state"] == "unknown"
assert result["checks"] == "failing"
assert result["mergeable"] == "unknown"
assert result["check_details"][0]["state"] == "failure"
assert result["check_details"][0]["created"] == "2026-07-07T12:40:00Z"
assert result["check_details"][0]["updated"] == "2026-07-07T12:45:00Z"
@patch("urllib.request.build_opener")
def test_cloud_get_pull_request_reviews_url(mock_build_opener: MagicMock, cloud_env: None) -> None:
opener = mock_opener(
mock_build_opener,
{"id": 7, "reviewers": [{"display_name": "Reviewer One"}]},
{
"values": [
{
"approval": {
"user": {"display_name": "Reviewer One"},
"date": "2026-07-01T00:00:00+00:00",
}
}
]
},
)
result = cloud.get_pull_request_reviews(load_config(), "7")
first_request = opener.open.call_args_list[0].args[0]
second_request = opener.open.call_args_list[1].args[0]
assert first_request.full_url == "https://api.bitbucket.org/2.0/repositories/apache/magpie/pullrequests/7"
assert (
second_request.full_url
== "https://api.bitbucket.org/2.0/repositories/apache/magpie/pullrequests/7/activity"
)
assert result["pull_request"]["id"] == 7
assert result["values"][0]["approval"]["user"]["display_name"] == "Reviewer One"
@patch("urllib.request.build_opener")
def test_datacenter_get_pull_request_reviews_url(mock_build_opener: MagicMock, datacenter_env: None) -> None:
opener = mock_opener(
mock_build_opener,
{
"id": 9,
"reviewers": [
{
"user": {"displayName": "Reviewer One"},
"approved": True,
"status": "APPROVED",
}
],
},
{
"values": [
{
"action": "APPROVED",
"user": {"displayName": "Reviewer One"},
"createdDate": 1710000000000,
}
],
"isLastPage": True,
},
)
result = datacenter.get_pull_request_reviews(load_config(), "9")
first_request = opener.open.call_args_list[0].args[0]
second_request = opener.open.call_args_list[1].args[0]
assert (
first_request.full_url
== "https://bitbucket.example.test/rest/api/1.0/projects/MAGPIE/repos/magpie/pull-requests/9"
)
assert (
second_request.full_url
== "https://bitbucket.example.test/rest/api/1.0/projects/MAGPIE/repos/magpie/pull-requests/9/activities?start=0"
)
assert result["pull_request"]["reviewers"][0]["user"]["displayName"] == "Reviewer One"
assert result["values"][0]["action"] == "APPROVED"
def test_normalize_pull_request_reviews_cloud() -> None:
normalized = pull_request_reviews(
"cloud",
{
"pull_request_id": "7",
"pull_request": {"reviewers": [{"display_name": "Reviewer One"}]},
"values": [
{
"approval": {
"user": {"display_name": "Reviewer One"},
"date": "2026-07-01T00:00:00+00:00",
}
},
{
"changes_requested": {
"user": {"display_name": "Reviewer Two"},
"date": "2026-07-02T00:00:00+00:00",
}
},
],
},
)
assert normalized["backend"] == "bitbucket-cloud"
assert normalized["review_decision"] == "changes_requested"
assert normalized["reviewers"][0]["user"] == "Reviewer One"
assert normalized["approvals"][0]["author"] == "Reviewer One"
assert normalized["changes_requested"][0]["author"] == "Reviewer Two"
def test_normalize_pull_request_reviews_datacenter() -> None:
normalized = pull_request_reviews(
"datacenter",
{
"pull_request_id": "9",
"pull_request": {
"reviewers": [
{
"user": {"displayName": "Reviewer One"},
"approved": True,
"status": "APPROVED",
}
]
},
"values": [
{
"action": "APPROVED",
"user": {"displayName": "Reviewer One"},
"createdDate": 1710000000000,
}
],
},
)
assert normalized["backend"] == "bitbucket-datacenter"
assert normalized["review_decision"] == "approved"
assert normalized["reviewers"][0]["user"] == "Reviewer One"
assert normalized["approvals"][0]["author"] == "Reviewer One"
@patch("urllib.request.build_opener")
def test_cloud_get_pull_request_reviews_follows_next(mock_build_opener: MagicMock, cloud_env: None) -> None:
opener = mock_opener(
mock_build_opener,
{"id": 7, "reviewers": [{"display_name": "Reviewer One"}]},
{
"values": [
{"approval": {"user": {"display_name": "Reviewer One"}, "date": "2026-07-01T00:00:00+00:00"}}
],
"next": "https://api.bitbucket.org/2.0/repositories/apache/magpie/pullrequests/7/activity?page=2",
},
{
"values": [
{
"changes_requested": {
"user": {"display_name": "Reviewer Two"},
"date": "2026-07-02T00:00:00+00:00",
}
}
]
},
)
result = cloud.get_pull_request_reviews(load_config(), "7")
third_request = opener.open.call_args_list[2].args[0]
assert (
third_request.full_url
== "https://api.bitbucket.org/2.0/repositories/apache/magpie/pullrequests/7/activity?page=2"
)
assert len(result["values"]) == 2
@patch("urllib.request.build_opener")
def test_datacenter_get_pull_request_reviews_paginates(
mock_build_opener: MagicMock, datacenter_env: None
) -> None:
opener = mock_opener(
mock_build_opener,
{"id": 9, "reviewers": []},
{
"values": [
{"action": "APPROVED", "user": {"displayName": "Reviewer One"}, "createdDate": 1710000000000}
],
"isLastPage": False,
"nextPageStart": 25,
},
{
"values": [
{
"action": "NEEDS_WORK",
"user": {"displayName": "Reviewer Two"},
"createdDate": 1710000100000,
}
],
"isLastPage": True,
},
)
result = datacenter.get_pull_request_reviews(load_config(), "9")
third_request = opener.open.call_args_list[2].args[0]
assert (
third_request.full_url
== "https://bitbucket.example.test/rest/api/1.0/projects/MAGPIE/repos/magpie/pull-requests/9/activities?start=25"
)
assert len(result["values"]) == 2
def test_normalize_pull_request_reviews_prefers_current_approval_over_old_changes_requested() -> None:
normalized = pull_request_reviews(
"cloud",
{
"pull_request_id": "7",
"pull_request": {
"reviewers": [
{
"display_name": "Reviewer One",
"approved": True,
}
]
},
"values": [
{
"changes_requested": {
"user": {"display_name": "Reviewer One"},
"date": "2026-07-01T00:00:00+00:00",
}
}
],
},
)
assert normalized["review_decision"] == "approved"
assert normalized["approvals"][0]["author"] == "Reviewer One"
assert normalized["changes_requested"] == []
def test_normalize_pull_request_reviews_datacenter_needs_work_is_not_pending_request() -> None:
normalized = pull_request_reviews(
"datacenter",
{
"pull_request_id": "9",
"pull_request": {
"reviewers": [
{
"user": {"displayName": "Reviewer One"},
"status": "NEEDS_WORK",
}
]
},
"values": [],
},
)
assert normalized["review_decision"] == "changes_requested"
assert normalized["changes_requested"][0]["author"] == "Reviewer One"
assert normalized["review_requests"] == []
def test_normalize_pull_request_reviews_cloud_pending_reviewer_survives_participant_merge() -> None:
# A requested reviewer who has not acted appears in BOTH reviewers[] (pending)
# and participants[] (approved:false, state:null → "unknown"). The participant
# merge must not downgrade the pending request to "unknown", or the reviewer
# drops out of review_requests and review_decision collapses to "unknown".
normalized = pull_request_reviews(
"cloud",
{
"pull_request_id": "7",
"pull_request": {
"reviewers": [{"display_name": "Reviewer One"}],
"participants": [
{
"user": {"display_name": "Reviewer One"},
"role": "REVIEWER",
"approved": False,
"state": None,
}
],
},
"values": [],
},
)
assert normalized["review_decision"] == "review_required"
assert [r["user"] for r in normalized["review_requests"]] == ["Reviewer One"]
assert [r["review_state"] for r in normalized["reviewers"]] == ["pending"]
def test_normalize_pull_request_reviews_cloud_participant_approval_refines_pending() -> None:
# The complement of the case above: when the participant carries a definite
# signal (approved), it SHOULD refine the pending request to approved.
normalized = pull_request_reviews(
"cloud",
{
"pull_request_id": "7",
"pull_request": {
"reviewers": [{"display_name": "Reviewer One"}],
"participants": [
{"user": {"display_name": "Reviewer One"}, "role": "REVIEWER", "approved": True}
],
},
"values": [],
},
)
assert normalized["review_decision"] == "approved"
assert normalized["approvals"][0]["author"] == "Reviewer One"
assert normalized["review_requests"] == []
def test_normalize_pull_request_reviews_latest_event_overrides_old_request_changes() -> None:
normalized = pull_request_reviews(
"cloud",
{
"pull_request_id": "7",
"pull_request": {},
"values": [
{
"changes_requested": {
"user": {"display_name": "Reviewer One"},
"date": "2026-07-01T00:00:00+00:00",
}
},
{
"approval": {
"user": {"display_name": "Reviewer One"},
"date": "2026-07-02T00:00:00+00:00",
}
},
],
},
)
assert normalized["review_decision"] == "approved"
assert normalized["approvals"][0]["author"] == "Reviewer One"
assert normalized["changes_requested"] == []
def test_normalize_pull_request_reviews_approval_removed_clears_fallback_approval() -> None:
normalized = pull_request_reviews(
"datacenter",
{
"pull_request_id": "9",
"pull_request": {},
"values": [
{
"action": "APPROVED",
"user": {"displayName": "Reviewer One"},
"createdDate": 1710000000000,
},
{
"action": "UNAPPROVED",
"user": {"displayName": "Reviewer One"},
"createdDate": 1710000100000,
},
],
},
)
assert normalized["review_decision"] == "review_required"
assert normalized["approvals"] == []
assert normalized["changes_requested"] == []
def test_cli_pr_reviews_cloud(cloud_env: None, capsys: pytest.CaptureFixture[str]) -> None:
with patch(
"magpie_bitbucket.cloud.get_pull_request_reviews",
return_value={
"pull_request_id": "7",
"pull_request": {"reviewers": [{"display_name": "Reviewer One"}]},
"values": [
{
"approval": {
"user": {"display_name": "Reviewer One"},
"date": "2026-07-01T00:00:00+00:00",
}
}
],
},
) as get_reviews:
assert main(["pr", "reviews", "7"]) == 0
get_reviews.assert_called_once()
output = json.loads(capsys.readouterr().out)
assert output["backend"] == "bitbucket-cloud"
assert output["review_decision"] == "approved"
def test_cli_pr_reviews_datacenter(datacenter_env: None, capsys: pytest.CaptureFixture[str]) -> None:
with patch(
"magpie_bitbucket.datacenter.get_pull_request_reviews",
return_value={
"pull_request_id": "9",
"pull_request": {
"reviewers": [
{
"user": {"displayName": "Reviewer One"},
"approved": True,
}
]
},
"values": [
{
"action": "APPROVED",
"user": {"displayName": "Reviewer One"},
"createdDate": 1710000000000,
}
],
},
) as get_reviews:
assert main(["pr", "reviews", "9"]) == 0
get_reviews.assert_called_once()
output = json.loads(capsys.readouterr().out)
assert output["backend"] == "bitbucket-datacenter"
assert output["review_decision"] == "approved"
@patch("magpie_bitbucket.cloud.get_pull_request_status")
def test_cli_pr_status_cloud(
mock_get_pull_request_status: MagicMock,
cloud_env: None,
capsys: pytest.CaptureFixture[str],
) -> None:
mock_get_pull_request_status.return_value = {
"pull_request_id": "7",
"commit": "abc123",
"values": [{"key": "build", "state": "SUCCESSFUL"}],
}
exit_code = main(["pr", "status", "7"])
captured = capsys.readouterr()
output = json.loads(captured.out)
assert exit_code == 0
assert output["pull_request_id"] == "7"
assert output["commit"] == "abc123"
assert output["checks"] == "passing"
assert output["check_details"][0]["key"] == "build"
def test_normalize_pull_request_status_aggregate_values() -> None:
assert pull_request_status("cloud", {"values": []})["checks"] == "none"
assert pull_request_status("cloud", {"values": [{"state": "SUCCESSFUL"}]})["checks"] == "passing"
assert pull_request_status("cloud", {"values": [{"state": "FAILED"}]})["checks"] == "failing"
assert pull_request_status("cloud", {"values": [{"state": "INPROGRESS"}]})["checks"] == "pending"
@patch("magpie_bitbucket.datacenter.get_pull_request_status")
def test_cli_pr_status_datacenter(
mock_get_pull_request_status: MagicMock,
datacenter_env: None,
capsys: pytest.CaptureFixture[str],
) -> None:
mock_get_pull_request_status.return_value = {
"pull_request_id": "9",
"commit": "def456",
"values": [{"key": "build", "state": "SUCCESSFUL"}],
}
exit_code = main(["pr", "status", "9"])
captured = capsys.readouterr()
output = json.loads(captured.out)
assert exit_code == 0
assert output["backend"] == "bitbucket-datacenter"
assert output["pull_request_id"] == "9"
assert output["checks"] == "passing"
assert output["check_details"][0]["key"] == "build"
@patch("urllib.request.build_opener")
def test_datacenter_get_pull_request_status_requires_latest_commit(
mock_build_opener: MagicMock,
datacenter_env: None,
) -> None:
mock_opener(mock_build_opener, {"id": 9, "fromRef": {}})
with pytest.raises(BitbucketError, match=r"fromRef\.latestCommit"):
datacenter.get_pull_request_status(load_config(), "9")
@patch("urllib.request.build_opener")
def test_cloud_get_pull_request_commits_follows_next(
mock_build_opener: MagicMock,
cloud_env: None,
) -> None:
opener = mock_opener(
mock_build_opener,
{
"values": [{"hash": "abc123", "message": "First commit"}],
"next": "https://api.bitbucket.org/2.0/repositories/apache/magpie/pullrequests/7/commits?page=2",
},
{"values": [{"hash": "def456", "message": "Second commit"}]},
)
result = cloud.get_pull_request_commits(load_config(), "7")
first_request = opener.open.call_args_list[0].args[0]
second_request = opener.open.call_args_list[1].args[0]
assert (
first_request.full_url
== "https://api.bitbucket.org/2.0/repositories/apache/magpie/pullrequests/7/commits"
)
assert (
second_request.full_url
== "https://api.bitbucket.org/2.0/repositories/apache/magpie/pullrequests/7/commits?page=2"
)
assert result["pull_request_id"] == "7"
assert [item["hash"] for item in result["values"]] == ["abc123", "def456"]
@patch("urllib.request.build_opener")
def test_datacenter_get_pull_request_commits_follows_next_page_start(
mock_build_opener: MagicMock,
datacenter_env: None,
) -> None:
opener = mock_opener(
mock_build_opener,
{"values": [{"id": "abc123", "message": "First commit"}], "isLastPage": False, "nextPageStart": 25},
{"values": [{"id": "def456", "message": "Second commit"}], "isLastPage": True},
)
result = datacenter.get_pull_request_commits(load_config(), "9")
first_request = opener.open.call_args_list[0].args[0]
second_request = opener.open.call_args_list[1].args[0]
assert (
first_request.full_url
== "https://bitbucket.example.test/rest/api/1.0/projects/MAGPIE/repos/magpie/pull-requests/9/commits?start=0"
)
assert (
second_request.full_url
== "https://bitbucket.example.test/rest/api/1.0/projects/MAGPIE/repos/magpie/pull-requests/9/commits?start=25"
)
assert result["pull_request_id"] == "9"
assert [item["id"] for item in result["values"]] == ["abc123", "def456"]
def test_normalize_cloud_pull_request_commits() -> None:
raw = {
"pull_request_id": "7",
"values": [
{
"hash": "abc123",
"message": "Fix docs",
"author": {"raw": "Alice <alice@example.test>", "user": {"display_name": "Alice"}},
"date": "2026-07-09T10:00:00+00:00",
"links": {"html": {"href": "https://bitbucket.org/apache/magpie/commits/abc123"}},
}
],
}
result = pull_request_commits("cloud", raw)
assert result["backend"] == "bitbucket-cloud"
assert result["coverage"] == "partial-read-only"
assert result["pull_request_id"] == "7"
assert result["commits"][0]["hash"] == "abc123"
assert result["commits"][0]["message"] == "Fix docs"
assert result["commits"][0]["author"] == "Alice"
assert result["commits"][0]["date"] == "2026-07-09T10:00:00+00:00"
assert result["commits"][0]["links"]["html"] == "https://bitbucket.org/apache/magpie/commits/abc123"
def test_normalize_datacenter_pull_request_commits() -> None:
raw = {
"pull_request_id": "9",
"values": [
{
"id": "def456789",
"displayId": "def4567",
"message": "Fix tests",
"author": {"displayName": "Bob"},
"authorTimestamp": 1783428000000,
"links": {"self": [{"href": "https://bitbucket.example.test/commits/def456789"}]},
}
],
}
result = pull_request_commits("datacenter", raw)
assert result["backend"] == "bitbucket-datacenter"
assert result["pull_request_id"] == "9"
assert result["commits"][0]["hash"] == "def456789"
assert result["commits"][0]["display_hash"] == "def4567"
assert result["commits"][0]["message"] == "Fix tests"
assert result["commits"][0]["author"] == "Bob"
assert result["commits"][0]["date"] == "2026-07-07T12:40:00Z"
assert result["commits"][0]["links"]["self"] == "https://bitbucket.example.test/commits/def456789"
@patch("magpie_bitbucket.cloud.get_pull_request_commits")
def test_cli_pr_commits_cloud(
mock_get_pull_request_commits: MagicMock,
cloud_env: None,
capsys: pytest.CaptureFixture[str],
) -> None:
mock_get_pull_request_commits.return_value = {
"pull_request_id": "7",
"values": [{"hash": "abc123", "message": "Fix docs"}],
}
exit_code = main(["pr", "commits", "7"])
captured = capsys.readouterr()
output = json.loads(captured.out)
assert exit_code == 0
assert output["backend"] == "bitbucket-cloud"
assert output["pull_request_id"] == "7"
assert output["commits"][0]["hash"] == "abc123"
@patch("magpie_bitbucket.datacenter.get_pull_request_commits")
def test_cli_pr_commits_datacenter(
mock_get_pull_request_commits: MagicMock,
datacenter_env: None,
capsys: pytest.CaptureFixture[str],
) -> None:
mock_get_pull_request_commits.return_value = {
"pull_request_id": "9",
"values": [{"id": "def456789", "message": "Fix tests"}],
}
exit_code = main(["pr", "commits", "9"])
captured = capsys.readouterr()
output = json.loads(captured.out)
assert exit_code == 0
assert output["backend"] == "bitbucket-datacenter"
assert output["pull_request_id"] == "9"
assert output["commits"][0]["hash"] == "def456789"
@patch("urllib.request.build_opener")
def test_cloud_get_pull_request_diff_url(
mock_build_opener: MagicMock,
cloud_env: None,
) -> None:
response = make_mock_text_response(
"diff --git a/a.txt b/a.txt\n",
url="https://api.bitbucket.org/2.0/repositories/apache/magpie/diff/source..dest",
)
opener = MagicMock()
opener.open.return_value.__enter__.return_value = response
mock_build_opener.return_value = opener
result = cloud.get_pull_request_diff(load_config(), "7")
request = opener.open.call_args.args[0]
assert request.full_url == "https://api.bitbucket.org/2.0/repositories/apache/magpie/pullrequests/7/diff"
assert request.headers["Accept"] == "text/x-diff"
assert result["pull_request_id"] == "7"
assert result["body"] == "diff --git a/a.txt b/a.txt\n"
assert result["content_type"] == "text/x-diff"
@patch("urllib.request.build_opener")
def test_datacenter_get_pull_request_diff_url(
mock_build_opener: MagicMock,
datacenter_env: None,
) -> None:
response = make_mock_text_response(
"diff --git a/a.txt b/a.txt\n",
url="https://bitbucket.example.test/rest/api/1.0/projects/MAGPIE/repos/magpie/pull-requests/9/diff",
)
opener = MagicMock()
opener.open.return_value.__enter__.return_value = response
mock_build_opener.return_value = opener
result = datacenter.get_pull_request_diff(load_config(), "9")
request = opener.open.call_args.args[0]
assert (
request.full_url
== "https://bitbucket.example.test/rest/api/1.0/projects/MAGPIE/repos/magpie/pull-requests/9/diff"
)
assert request.headers["Accept"] == "text/plain"
assert result["pull_request_id"] == "9"
assert result["body"] == "diff --git a/a.txt b/a.txt\n"
assert result["content_type"] == "text/x-diff"
def test_normalize_pull_request_diff() -> None:
raw = {
"pull_request_id": "7",
"body": "diff --git a/a.txt b/a.txt\n",
"content_type": "text/x-diff",
"url": "https://api.bitbucket.org/example",
}
result = pull_request_diff("cloud", raw)
assert result["backend"] == "bitbucket-cloud"
assert result["coverage"] == "partial-read-only"
assert result["pull_request_id"] == "7"
assert result["diff"] == "diff --git a/a.txt b/a.txt\n"
assert result["content_type"] == "text/x-diff"
assert result["url"] == "https://api.bitbucket.org/example"
@patch("magpie_bitbucket.cloud.get_pull_request_diff")
def test_cli_pr_diff_cloud(
mock_get_pull_request_diff: MagicMock,
cloud_env: None,
capsys: pytest.CaptureFixture[str],
) -> None:
mock_get_pull_request_diff.return_value = {
"pull_request_id": "7",
"body": "diff --git a/a.txt b/a.txt\n",
"content_type": "text/x-diff",
"url": "https://api.bitbucket.org/example",
}
exit_code = main(["pr", "diff", "7"])
captured = capsys.readouterr()
output = json.loads(captured.out)
assert exit_code == 0
assert output["backend"] == "bitbucket-cloud"
assert output["pull_request_id"] == "7"
assert output["diff"] == "diff --git a/a.txt b/a.txt\n"
@patch("magpie_bitbucket.datacenter.get_pull_request_diff")
def test_cli_pr_diff_datacenter(
mock_get_pull_request_diff: MagicMock,
datacenter_env: None,
capsys: pytest.CaptureFixture[str],
) -> None:
mock_get_pull_request_diff.return_value = {
"pull_request_id": "9",
"body": "diff --git a/a.txt b/a.txt\n",
"content_type": "text/x-diff",
"url": "https://bitbucket.example.test/example",
}
exit_code = main(["pr", "diff", "9"])
captured = capsys.readouterr()
output = json.loads(captured.out)
assert exit_code == 0
assert output["backend"] == "bitbucket-datacenter"
assert output["pull_request_id"] == "9"
assert output["diff"] == "diff --git a/a.txt b/a.txt\n"
def test_normalize_cloud_repository() -> None:
raw = {
"uuid": "{abc}",
"name": "Magpie",
"slug": "magpie",
"description": "Agent-assisted maintainership",
"is_private": False,
"mainbranch": {"name": "main"},
"links": {"html": {"href": "https://bitbucket.org/apache/magpie"}},
}
result = repository("cloud", raw)
assert result["backend"] == "bitbucket-cloud"
assert result["id"] == "{abc}"
assert result["main_branch"] == "main"
assert result["links"]["html"] == "https://bitbucket.org/apache/magpie"
assert result["capabilities"]["issues"] == "not_implemented"
def test_normalize_datacenter_repository_accepts_string_default_branch() -> None:
raw = {
"id": 101,
"name": "Magpie",
"slug": "magpie",
"public": False,
"defaultBranch": "refs/heads/main",
"links": {"self": [{"href": "https://bitbucket.example.test/projects/MAGPIE/repos/magpie"}]},
}
result = repository("datacenter", raw)
assert result["backend"] == "bitbucket-datacenter"
assert result["id"] == "101"
assert result["is_private"] is True
assert result["main_branch"] == "refs/heads/main"
def test_normalize_cloud_pull_request() -> None:
raw = {
"id": 12,
"title": "Fix docs",
"state": "OPEN",
"created_on": "2026-07-01T00:00:00Z",
"updated_on": "2026-07-02T00:00:00Z",
"author": {"display_name": "Alice"},
"source": {"branch": {"name": "fix-docs"}},
"destination": {"branch": {"name": "main"}},
"links": {"html": {"href": "https://bitbucket.org/apache/magpie/pull-requests/12"}},
"description": "Updates docs.",
}
result = pull_request("cloud", raw)
assert result["backend"] == "bitbucket-cloud"
assert result["id"] == "12"
assert result["state"] == "open"
assert result["author"] == "Alice"
assert result["source"] == "fix-docs"
assert result["target"] == "main"
assert result["mergeable"] == "unknown"
assert result["checks"] == "none"
assert result["diff"] is None
assert result["commits"] is None
assert result["labels"] == ["bitbucket", "read-only", "partial-change-request"]
def test_normalize_datacenter_pull_request_timestamp() -> None:
raw = {
"id": 13,
"title": "Fix tests",
"state": "OPEN",
"createdDate": 1780000000000,
"updatedDate": 1780000001000,
"author": {"user": {"displayName": "Bob"}},
"fromRef": {"displayId": "fix-tests"},
"toRef": {"displayId": "main"},
"links": {
"self": [{"href": "https://bitbucket.example.test/projects/MAGPIE/repos/magpie/pull-requests/13"}]
},
"description": "Updates tests.",
}
result = pull_request("datacenter", raw)
assert result["backend"] == "bitbucket-datacenter"
assert result["id"] == "13"
assert result["state"] == "open"
assert result["author"] == "Bob"
assert result["source"] == "fix-tests"
assert result["target"] == "main"
assert result["created"] == "2026-05-28T20:26:40Z"
assert result["updated"] == "2026-05-28T20:26:41Z"
def test_normalize_pull_request_list() -> None:
raw = {
"values": [
{"id": 1, "title": "One", "state": "OPEN"},
{"id": 2, "title": "Two", "state": "MERGED"},
]
}
result = pull_request_list("cloud", raw)
assert result["backend"] == "bitbucket-cloud"
assert result["coverage"] == "read-only-partial-change-request"
assert [item["id"] for item in result["pull_requests"]] == ["1", "2"]
assert [item["state"] for item in result["pull_requests"]] == ["open", "merged"]
@patch("magpie_bitbucket.cloud.get_repository")
def test_cli_auth_check_cloud(
mock_get_repository: MagicMock, cloud_env: None, capsys: pytest.CaptureFixture[str]
) -> None:
mock_get_repository.return_value = {
"uuid": "{abc}",
"name": "Magpie",
"slug": "magpie",
}
exit_code = main(["auth-check"])
captured = capsys.readouterr()
output = json.loads(captured.out)
assert exit_code == 0
assert output["ok"] is True
assert output["backend"] == "bitbucket-cloud"
assert output["repository"]["name"] == "Magpie"
@patch("magpie_bitbucket.cloud.list_open_pull_requests")
def test_cli_pr_list_open_cloud(
mock_list_open_pull_requests: MagicMock,
cloud_env: None,
capsys: pytest.CaptureFixture[str],
) -> None:
mock_list_open_pull_requests.return_value = {"values": [{"id": 1, "title": "One", "state": "OPEN"}]}
exit_code = main(["pr", "list-open"])
captured = capsys.readouterr()
output = json.loads(captured.out)
assert exit_code == 0
assert output["pull_requests"][0]["id"] == "1"
assert output["pull_requests"][0]["state"] == "open"
@patch("urllib.request.build_opener")
def test_cloud_get_pull_request_discussion_follows_next(
mock_build_opener: MagicMock,
cloud_env: None,
) -> None:
opener = mock_opener(
mock_build_opener,
{
"values": [{"id": 101, "content": {"raw": "First comment"}}],
"next": "https://api.bitbucket.org/2.0/repositories/apache/magpie/pullrequests/7/comments?page=2",
},
{"values": [{"id": 102, "content": {"raw": "Second comment"}}]},
)
result = cloud.get_pull_request_discussion(load_config(), "7")
first_request = opener.open.call_args_list[0].args[0]
second_request = opener.open.call_args_list[1].args[0]
assert (
first_request.full_url
== "https://api.bitbucket.org/2.0/repositories/apache/magpie/pullrequests/7/comments"
)
assert (
second_request.full_url
== "https://api.bitbucket.org/2.0/repositories/apache/magpie/pullrequests/7/comments?page=2"
)
assert result["pull_request_id"] == "7"
assert [item["id"] for item in result["values"]] == [101, 102]
@patch("urllib.request.build_opener")
def test_datacenter_get_pull_request_discussion_follows_next_page_start(
mock_build_opener: MagicMock,
datacenter_env: None,
) -> None:
opener = mock_opener(
mock_build_opener,
{"values": [{"id": 201, "comment": {"text": "First"}}], "isLastPage": False, "nextPageStart": 25},
{"values": [{"id": 202, "comment": {"text": "Second"}}], "isLastPage": True},
)
result = datacenter.get_pull_request_discussion(load_config(), "9")
first_request = opener.open.call_args_list[0].args[0]
second_request = opener.open.call_args_list[1].args[0]
assert (
first_request.full_url
== "https://bitbucket.example.test/rest/api/1.0/projects/MAGPIE/repos/magpie/pull-requests/9/activities?start=0"
)
assert (
second_request.full_url
== "https://bitbucket.example.test/rest/api/1.0/projects/MAGPIE/repos/magpie/pull-requests/9/activities?start=25"
)
assert result["pull_request_id"] == "9"
assert [item["id"] for item in result["values"]] == [201, 202]
def test_normalize_cloud_pull_request_discussion() -> None:
raw = {
"pull_request_id": "7",
"values": [
{
"id": 101,
"content": {"raw": "Looks good."},
"user": {"display_name": "Alice"},
"created_on": "2026-07-01T00:00:00Z",
"updated_on": "2026-07-01T01:00:00Z",
"state": "active",
"deleted": False,
"inline": {"path": "README.md", "to": 10},
}
],
}
result = pull_request_discussion("cloud", raw)
assert result["backend"] == "bitbucket-cloud"
assert result["coverage"] == "partial-read-only"
assert result["pull_request_id"] == "7"
assert result["comments"][0]["id"] == "101"
assert result["comments"][0]["author"] == "Alice"
assert result["comments"][0]["body"] == "Looks good."
assert result["comments"][0]["date"] == "2026-07-01T00:00:00Z"
assert result["comments"][0]["kind"] == "comment"
assert result["comments"][0]["deleted"] is False
assert result["comments"][0]["inline"] == {"path": "README.md", "to_line": 10}
assert result["participants"] == ["Alice"]
assert result["unresolved_count"] is None
def test_normalize_datacenter_pull_request_discussion() -> None:
raw = {
"pull_request_id": "9",
"values": [
{
"id": 201,
"action": "COMMENTED",
"createdDate": 1780000000000,
"user": {"displayName": "Bob"},
"comment": {
"id": 301,
"text": "Please update tests.",
"author": {"displayName": "Bob"},
"createdDate": 1780000000000,
"updatedDate": 1780000001000,
"anchor": {"path": "tests/test_bitbucket.py", "line": 42},
"deleted": False,
},
}
],
}
result = pull_request_discussion("datacenter", raw)
assert result["backend"] == "bitbucket-datacenter"
assert result["coverage"] == "partial-read-only"
assert result["pull_request_id"] == "9"
assert result["comments"][0]["id"] == "301"
assert result["comments"][0]["author"] == "Bob"
assert result["comments"][0]["body"] == "Please update tests."
assert result["comments"][0]["created"] == "2026-05-28T20:26:40Z"
assert result["comments"][0]["updated"] == "2026-05-28T20:26:41Z"
assert result["comments"][0]["date"] == "2026-05-28T20:26:40Z"
assert result["comments"][0]["kind"] == "comment"
assert result["comments"][0]["deleted"] is False
assert result["comments"][0]["inline"] == {"path": "tests/test_bitbucket.py", "to_line": 42}
assert result["participants"] == ["Bob"]
assert result["unresolved_count"] is None
@patch("magpie_bitbucket.cloud.get_pull_request_discussion")
def test_cli_pr_discussion_cloud(
mock_get_pull_request_discussion: MagicMock,
cloud_env: None,
capsys: pytest.CaptureFixture[str],
) -> None:
mock_get_pull_request_discussion.return_value = {
"pull_request_id": "7",
"values": [{"id": 101, "content": {"raw": "Looks good."}}],
}
exit_code = main(["pr", "discussion", "7"])
captured = capsys.readouterr()
output = json.loads(captured.out)
assert exit_code == 0
assert output["pull_request_id"] == "7"
assert output["comments"][0]["id"] == "101"
assert output["comments"][0]["body"] == "Looks good."
@patch("magpie_bitbucket.cli.load_config")
def test_cli_pr_discussion_datacenter(
mock_load_config: MagicMock,
datacenter_env: None,
capsys: pytest.CaptureFixture[str],
) -> None:
config = load_config()
mock_load_config.return_value = config
with patch.object(datacenter, "get_pull_request_discussion") as mock_discussion:
mock_discussion.return_value = {
"pull_request_id": "9",
"values": [
{
"action": "COMMENTED",
"comment": {
"id": 1,
"text": "Looks good",
"author": {"displayName": "Asha"},
},
}
],
}
exit_code = main(["pr", "discussion", "9"])
captured = capsys.readouterr()
output = json.loads(captured.out)
mock_discussion.assert_called_once_with(config, "9")
assert exit_code == 0
assert output["backend"] == "bitbucket-datacenter"
assert output["comments"][0]["body"] == "Looks good"
def test_normalize_datacenter_discussion_includes_threaded_replies() -> None:
raw = {
"pull_request_id": "9",
"values": [
{
"action": "COMMENTED",
"comment": {
"id": 1,
"text": "Parent comment",
"author": {"displayName": "Asha"},
"createdDate": 1783428000000,
"comments": [
{
"id": 2,
"text": "Reply comment",
"author": {"displayName": "Ravi"},
"createdDate": 1783428300000,
}
],
},
}
],
}
result = pull_request_discussion("datacenter", raw)
assert [comment["body"] for comment in result["comments"]] == [
"Parent comment",
"Reply comment",
]
assert result["comments"][1]["parent_id"] == "1"
assert result["participants"] == ["Asha", "Ravi"]
def test_normalize_datacenter_discussion_allows_null_threaded_replies() -> None:
raw = {
"pull_request_id": "9",
"values": [
{
"action": "COMMENTED",
"comment": {
"id": 1,
"text": "Parent comment",
"author": {"displayName": "Asha"},
"createdDate": 1783428000000,
"comments": None,
},
}
],
}
result = pull_request_discussion("datacenter", raw)
assert len(result["comments"]) == 1
assert result["comments"][0]["body"] == "Parent comment"
assert result["comments"][0]["parent_id"] is None
assert result["participants"] == ["Asha"]
def test_normalize_datacenter_discussion_filters_non_comment_activities() -> None:
raw = {
"pull_request_id": "9",
"values": [
{"id": 1, "action": "APPROVED", "user": {"displayName": "Reviewer"}},
{
"id": 2,
"action": "COMMENTED",
"comment": {
"id": 302,
"text": "Real comment.",
"author": {"displayName": "Alice"},
"createdDate": 1780000000000,
},
},
],
}
result = pull_request_discussion("datacenter", raw)
assert len(result["comments"]) == 1
assert result["comments"][0]["id"] == "302"
assert result["comments"][0]["body"] == "Real comment."
assert result["participants"] == ["Alice"]
def test_normalize_cloud_discussion_preserves_empty_raw_body() -> None:
raw = {
"pull_request_id": "7",
"values": [
{
"id": 101,
"content": {"raw": "", "markup": "markdown fallback", "html": "<p>fallback</p>"},
"user": {"display_name": "Alice"},
}
],
}
result = pull_request_discussion("cloud", raw)
assert result["comments"][0]["body"] == ""
@patch("urllib.request.build_opener")
def test_cloud_discussion_rejects_next_url_host_change(
mock_build_opener: MagicMock,
cloud_env: None,
) -> None:
mock_opener(
mock_build_opener,
{
"values": [],
"next": "https://evil.example.test/steal-token",
},
)
with pytest.raises(BitbucketError, match="pagination URL changed scheme or host"):
cloud.get_pull_request_discussion(load_config(), "7")
@patch("urllib.request.build_opener")
def test_cloud_discussion_rejects_repeated_next_url(
mock_build_opener: MagicMock,
cloud_env: None,
) -> None:
repeated_url = "https://api.bitbucket.org/2.0/repositories/apache/magpie/pullrequests/7/comments?page=2"
mock_opener(
mock_build_opener,
{
"values": [],
"next": repeated_url,
},
{
"values": [],
"next": repeated_url,
},
)
with pytest.raises(BitbucketError, match="pagination returned a repeated URL"):
cloud.get_pull_request_discussion(load_config(), "7")
@patch("urllib.request.build_opener")
def test_datacenter_discussion_stops_on_non_advancing_next_page_start(
mock_build_opener: MagicMock,
datacenter_env: None,
) -> None:
opener = mock_opener(
mock_build_opener,
{
"values": [{"id": 201, "comment": {"text": "First"}}],
"isLastPage": False,
"nextPageStart": 0,
},
)
result = datacenter.get_pull_request_discussion(load_config(), "9")
assert opener.open.call_count == 1
assert result["values"][0]["id"] == 201
def test_cloud_get_pull_request_merge_checks_composes_status_and_reviews(cloud_env: None) -> None:
with (
patch("magpie_bitbucket.cloud.get_pull_request_status") as get_status,
patch("magpie_bitbucket.cloud.get_pull_request_reviews") as get_reviews,
):
get_status.return_value = {
"pull_request_id": "7",
"pull_request": {"id": 7, "state": "OPEN"},
"values": [],
}
get_reviews.return_value = {
"pull_request_id": "7",
"pull_request": {"id": 7, "state": "OPEN"},
"values": [],
}
result = cloud.get_pull_request_merge_checks(load_config(), "7")
get_status.assert_called_once()
get_reviews.assert_called_once()
assert result["pull_request_id"] == "7"
assert result["pull_request"]["id"] == 7
def test_datacenter_get_pull_request_merge_checks_composes_status_and_reviews(
datacenter_env: None,
) -> None:
with (
patch("magpie_bitbucket.datacenter.get_pull_request_merge_status") as get_merge,
patch("magpie_bitbucket.datacenter.get_pull_request_status") as get_status,
patch("magpie_bitbucket.datacenter.get_pull_request_reviews") as get_reviews,
):
get_merge.return_value = {"canMerge": True, "conflicted": False, "vetoes": []}
get_status.return_value = {
"pull_request_id": "9",
"pull_request": {"id": 9, "state": "OPEN"},
"values": [],
}
get_reviews.return_value = {
"pull_request_id": "9",
"pull_request": {"id": 9, "state": "OPEN"},
"values": [],
}
result = datacenter.get_pull_request_merge_checks(load_config(), "9")
get_merge.assert_called_once()
get_status.assert_called_once()
get_reviews.assert_called_once()
assert result["pull_request_id"] == "9"
assert result["pull_request"]["id"] == 9
assert result["merge"]["canMerge"] is True
def test_normalize_pull_request_merge_checks_blocks_on_known_signals() -> None:
normalized = pull_request_merge_checks(
"cloud",
{
"pull_request_id": "7",
"pull_request": {
"state": "OPEN",
"mergeable": False,
"conflicted": True,
},
"status": {
"pull_request_id": "7",
"pull_request": {"state": "OPEN"},
"values": [{"state": "FAILED"}],
},
"reviews": {
"pull_request_id": "7",
"pull_request": {},
"values": [
{
"changes_requested": {
"user": {"display_name": "Reviewer One"},
"date": "2026-07-01T00:00:00+00:00",
}
}
],
},
},
)
assert normalized["backend"] == "bitbucket-cloud"
assert normalized["merge_check_state"] == "blocked"
assert normalized["has_known_blockers"] is True
assert normalized["mergeable"] == "conflicting"
assert normalized["can_merge"] is False
assert normalized["conflicted"] is True
assert normalized["checks"] == "failing"
assert normalized["review_decision"] == "changes_requested"
assert {blocker["kind"] for blocker in normalized["blockers"]} == {
"mergeability",
"merge_conflict",
"status_checks",
"reviews",
}
def test_normalize_pull_request_merge_checks_unknown_without_backend_signal() -> None:
normalized = pull_request_merge_checks(
"datacenter",
{
"pull_request_id": "9",
"pull_request": {"state": "OPEN"},
"status": {"pull_request_id": "9", "values": []},
"reviews": {"pull_request_id": "9", "pull_request": {}, "values": []},
},
)
assert normalized["backend"] == "bitbucket-datacenter"
assert normalized["merge_check_state"] == "unknown"
assert normalized["has_known_blockers"] is False
assert normalized["mergeable"] == "unknown"
assert normalized["can_merge"] is None
assert normalized["conflicted"] is None
assert normalized["blockers"] == []
def test_cli_pr_merge_checks_cloud(cloud_env: None, capsys: pytest.CaptureFixture[str]) -> None:
with patch(
"magpie_bitbucket.cloud.get_pull_request_merge_checks",
return_value={
"pull_request_id": "7",
"pull_request": {"state": "OPEN", "mergeable": False},
"status": {"pull_request_id": "7", "values": [{"state": "FAILED"}]},
"reviews": {"pull_request_id": "7", "pull_request": {}, "values": []},
},
) as get_merge_checks:
assert main(["pr", "merge-checks", "7"]) == 0
get_merge_checks.assert_called_once()
output = json.loads(capsys.readouterr().out)
assert output["backend"] == "bitbucket-cloud"
assert output["merge_check_state"] == "blocked"
assert output["has_known_blockers"] is True
@patch("urllib.request.build_opener")
def test_datacenter_get_pull_request_merge_status_url(
mock_build_opener: MagicMock, datacenter_env: None
) -> None:
opener = mock_opener(
mock_build_opener,
{"canMerge": False, "conflicted": True, "vetoes": []},
)
result = datacenter.get_pull_request_merge_status(load_config(), "9")
request = opener.open.call_args.args[0]
assert request.full_url == (
"https://bitbucket.example.test/rest/api/1.0/projects/MAGPIE/repos/magpie/pull-requests/9/merge"
)
assert request.get_method() == "GET"
assert result["canMerge"] is False
def test_datacenter_get_pull_request_merge_checks_includes_merge_status(
datacenter_env: None,
) -> None:
with (
patch("magpie_bitbucket.datacenter.get_pull_request_merge_status") as get_merge,
patch("magpie_bitbucket.datacenter.get_pull_request_status") as get_status,
patch("magpie_bitbucket.datacenter.get_pull_request_reviews") as get_reviews,
):
get_merge.return_value = {
"canMerge": False,
"conflicted": True,
"vetoes": [{"summaryMessage": "Needs one more approval"}],
}
get_status.return_value = {
"pull_request_id": "9",
"pull_request": {"id": 9, "state": "OPEN"},
"values": [],
}
get_reviews.return_value = {
"pull_request_id": "9",
"pull_request": {"id": 9, "state": "OPEN"},
"values": [],
}
result = datacenter.get_pull_request_merge_checks(load_config(), "9")
get_merge.assert_called_once()
get_status.assert_called_once()
get_reviews.assert_called_once()
assert result["merge"]["canMerge"] is False
assert result["merge"]["conflicted"] is True
def test_normalize_pull_request_merge_checks_datacenter_vetoes() -> None:
normalized = pull_request_merge_checks(
"datacenter",
{
"pull_request_id": "9",
"pull_request": {"state": "OPEN"},
"merge": {
"canMerge": False,
"conflicted": False,
"outcome": "VETOED",
"vetoes": [
{
"summaryMessage": "Needs one more approval",
"detailedMessage": "Project policy requires another reviewer.",
}
],
},
"status": {"pull_request_id": "9", "values": []},
"reviews": {"pull_request_id": "9", "pull_request": {}, "values": []},
},
)
assert normalized["merge_check_state"] == "blocked"
assert normalized["has_known_blockers"] is True
assert normalized["mergeable"] == "unknown"
assert normalized["can_merge"] is False
assert normalized["conflicted"] is False
assert any(blocker["kind"] == "merge_veto" for blocker in normalized["blockers"])
def test_cli_pr_merge_checks_datacenter(datacenter_env: None, capsys: pytest.CaptureFixture[str]) -> None:
with patch(
"magpie_bitbucket.datacenter.get_pull_request_merge_checks",
return_value={
"pull_request_id": "9",
"pull_request": {"state": "OPEN"},
"merge": {"canMerge": True, "conflicted": False, "vetoes": []},
"status": {"pull_request_id": "9", "values": []},
"reviews": {"pull_request_id": "9", "pull_request": {}, "values": []},
},
) as get_merge_checks:
assert main(["pr", "merge-checks", "9"]) == 0
get_merge_checks.assert_called_once()
output = json.loads(capsys.readouterr().out)
assert output["backend"] == "bitbucket-datacenter"
assert output["merge_check_state"] == "passing"
assert output["mergeable"] == "clean"
@patch("urllib.request.build_opener")
def test_cloud_get_repository_restrictions_follows_next(
mock_build_opener: MagicMock, cloud_env: None
) -> None:
opener = mock_opener(
mock_build_opener,
{
"values": [{"id": 1, "kind": "push", "pattern": "main"}],
"next": "https://api.bitbucket.org/2.0/repositories/apache/magpie/branch-restrictions?page=2",
},
{"values": [{"id": 2, "kind": "force", "pattern": "release/*"}]},
)
result = cloud.get_repository_restrictions(load_config())
first_request = opener.open.call_args_list[0].args[0]
second_request = opener.open.call_args_list[1].args[0]
assert first_request.full_url == (
"https://api.bitbucket.org/2.0/repositories/apache/magpie/branch-restrictions"
)
assert second_request.full_url.endswith("branch-restrictions?page=2")
assert result["values"] == [
{"id": 1, "kind": "push", "pattern": "main"},
{"id": 2, "kind": "force", "pattern": "release/*"},
]
@patch("urllib.request.build_opener")
def test_datacenter_get_repository_restrictions_paginates(
mock_build_opener: MagicMock, datacenter_env: None
) -> None:
opener = mock_opener(
mock_build_opener,
{
"values": [{"id": 1, "type": "read-only"}],
"isLastPage": False,
"nextPageStart": 25,
},
{"values": [{"id": 2, "type": "pull-request-only"}], "isLastPage": True},
)
result = datacenter.get_repository_restrictions(load_config())
first_request = opener.open.call_args_list[0].args[0]
second_request = opener.open.call_args_list[1].args[0]
assert first_request.full_url == (
"https://bitbucket.example.test/rest/api/1.0/projects/MAGPIE/repos/magpie/"
"branch-permissions/search?start=0"
)
assert second_request.full_url.endswith("branch-permissions/search?start=25")
assert result["permission_required"] == "REPO_ADMIN"
assert result["values"] == [
{"id": 1, "type": "read-only"},
{"id": 2, "type": "pull-request-only"},
]
def test_normalize_cloud_repository_restrictions() -> None:
normalized = repository_restrictions(
"cloud",
{
"values": [
{
"id": 1,
"kind": "push",
"pattern": "main",
"users": [{"display_name": "Reviewer One"}],
"groups": [{"name": "admins"}],
}
]
},
)
assert normalized["backend"] == "bitbucket-cloud"
assert normalized["coverage"] == "partial-read-only"
assert normalized["restrictions"][0]["kind"] == "push"
assert normalized["restrictions"][0]["pattern"] == "main"
assert normalized["restrictions"][0]["users"] == ["Reviewer One"]
assert normalized["restrictions"][0]["groups"] == ["admins"]
def test_normalize_datacenter_repository_restrictions() -> None:
normalized = repository_restrictions(
"datacenter",
{
"permission_required": "REPO_ADMIN",
"values": [
{
"id": 9,
"type": "pull-request-only",
"matcher": {
"id": "refs/heads/main",
"displayId": "main",
"type": {"id": "BRANCH", "name": "Branch"},
},
"users": [{"displayName": "Reviewer One"}],
"groups": [{"name": "admins"}],
"accessKeys": [{"key": "deploy-key"}],
}
],
},
)
assert normalized["backend"] == "bitbucket-datacenter"
assert normalized["permission_required"] == "REPO_ADMIN"
restriction = normalized["restrictions"][0]
assert restriction["kind"] == "pull-request-only"
assert restriction["pattern"] == "main"
assert restriction["branch_match_kind"] == "BRANCH"
assert restriction["branch_type"] == "refs/heads/main"
assert restriction["users"] == ["Reviewer One"]
assert restriction["groups"] == ["admins"]
assert restriction["access_keys"] == ["deploy-key"]
@patch("magpie_bitbucket.cloud.get_repository_restrictions")
def test_cli_repo_restrictions_cloud(
mock_get_repository_restrictions: MagicMock,
cloud_env: None,
capsys: pytest.CaptureFixture[str],
) -> None:
mock_get_repository_restrictions.return_value = {"values": [{"id": 1, "kind": "push", "pattern": "main"}]}
exit_code = main(["repo", "restrictions"])
assert exit_code == 0
mock_get_repository_restrictions.assert_called_once()
output = json.loads(capsys.readouterr().out)
assert output["backend"] == "bitbucket-cloud"
assert output["restrictions"][0]["pattern"] == "main"
@patch("magpie_bitbucket.datacenter.get_repository_restrictions")
def test_cli_repo_restrictions_datacenter(
mock_get_repository_restrictions: MagicMock,
datacenter_env: None,
capsys: pytest.CaptureFixture[str],
) -> None:
mock_get_repository_restrictions.return_value = {
"permission_required": "REPO_ADMIN",
"values": [{"id": 9, "type": "read-only"}],
}
exit_code = main(["repo", "restrictions"])
assert exit_code == 0
mock_get_repository_restrictions.assert_called_once()
output = json.loads(capsys.readouterr().out)
assert output["backend"] == "bitbucket-datacenter"
assert output["permission_required"] == "REPO_ADMIN"
@patch("urllib.request.build_opener")
def test_cloud_list_open_issues_follows_next(mock_build_opener: MagicMock, cloud_env: None) -> None:
opener = mock_opener(
mock_build_opener,
{
"values": [{"id": 1, "title": "First issue", "state": "open"}],
"next": "https://api.bitbucket.org/2.0/repositories/apache/magpie/issues?page=2",
},
{"values": [{"id": 2, "title": "Second issue", "state": "open"}]},
)
result = cloud.list_open_issues(load_config())
first_request = opener.open.call_args_list[0].args[0]
second_request = opener.open.call_args_list[1].args[0]
assert first_request.full_url == (
"https://api.bitbucket.org/2.0/repositories/apache/magpie/issues?q=state%3D%22new%22%20OR%20state%3D%22open%22%20OR%20state%3D%22on%20hold%22"
)
assert second_request.full_url.endswith("/issues?page=2")
assert result["values"] == [
{"id": 1, "title": "First issue", "state": "open"},
{"id": 2, "title": "Second issue", "state": "open"},
]
@patch("urllib.request.build_opener")
def test_cloud_get_issue_url(mock_build_opener: MagicMock, cloud_env: None) -> None:
opener = mock_opener(mock_build_opener, {"id": 7, "title": "Fix issue"})
result = cloud.get_issue(load_config(), "7")
request = opener.open.call_args.args[0]
assert request.full_url == "https://api.bitbucket.org/2.0/repositories/apache/magpie/issues/7"
assert result == {"id": 7, "title": "Fix issue"}
def test_datacenter_list_open_issues_unsupported(datacenter_env: None) -> None:
with pytest.raises(BitbucketError, match="Data Center native issue reads are not supported"):
datacenter.list_open_issues(load_config())
def test_datacenter_get_issue_unsupported(datacenter_env: None) -> None:
with pytest.raises(BitbucketError, match="Data Center native issue reads are not supported"):
datacenter.get_issue(load_config(), "7")
def test_normalize_cloud_issue() -> None:
normalized = issue(
"cloud",
{
"id": 7,
"title": "Fix docs",
"state": "new",
"kind": "bug",
"priority": "major",
"reporter": {"display_name": "Reporter One"},
"assignee": {"display_name": "Assignee One"},
"created_on": "2026-07-01T12:00:00+00:00",
"updated_on": "2026-07-02T12:00:00+00:00",
"content": {"raw": "Issue body"},
"links": {"html": {"href": "https://bitbucket.org/apache/magpie/issues/7"}},
},
)
assert normalized["backend"] == "bitbucket-cloud"
assert normalized["id"] == "7"
assert normalized["title"] == "Fix docs"
assert normalized["state"] == "open"
assert normalized["kind"] == "bug"
assert normalized["priority"] == "major"
assert normalized["reporter"] == "Reporter One"
assert normalized["assignee"] == "Assignee One"
assert normalized["description"] == "Issue body"
assert normalized["permalink"] == "https://bitbucket.org/apache/magpie/issues/7"
assert "partial-tracker" in normalized["labels"]
def test_normalize_cloud_issue_list() -> None:
normalized = issue_list(
"cloud",
{
"values": [
{"id": 1, "title": "First issue", "state": "new"},
{"id": 2, "title": "Second issue", "state": "resolved"},
]
},
)
assert normalized["backend"] == "bitbucket-cloud"
assert normalized["coverage"] == "partial-read-only"
assert [item["state"] for item in normalized["issues"]] == ["open", "closed"]
@patch("magpie_bitbucket.cloud.list_open_issues")
def test_cli_issue_list_open_cloud(
mock_list_open_issues: MagicMock,
cloud_env: None,
capsys: pytest.CaptureFixture[str],
) -> None:
mock_list_open_issues.return_value = {"values": [{"id": 1, "title": "First issue", "state": "new"}]}
exit_code = main(["issue", "list-open"])
assert exit_code == 0
mock_list_open_issues.assert_called_once()
output = json.loads(capsys.readouterr().out)
assert output["backend"] == "bitbucket-cloud"
assert output["issues"][0]["title"] == "First issue"
@patch("magpie_bitbucket.cloud.get_issue")
def test_cli_issue_get_cloud(
mock_get_issue: MagicMock,
cloud_env: None,
capsys: pytest.CaptureFixture[str],
) -> None:
mock_get_issue.return_value = {"id": 7, "title": "Fix docs", "state": "new"}
exit_code = main(["issue", "get", "7"])
assert exit_code == 0
mock_get_issue.assert_called_once_with(load_config(), "7")
output = json.loads(capsys.readouterr().out)
assert output["backend"] == "bitbucket-cloud"
assert output["id"] == "7"
assert output["state"] == "open"
@patch("urllib.request.build_opener")
def test_cloud_get_issue_comments_follows_next(mock_build_opener: MagicMock, cloud_env: None) -> None:
opener = mock_opener(
mock_build_opener,
{
"values": [{"id": 101, "content": {"raw": "First comment"}}],
"next": "https://api.bitbucket.org/2.0/repositories/apache/magpie/issues/7/comments?page=2",
},
{"values": [{"id": 102, "content": {"raw": "Second comment"}}]},
)
result = cloud.get_issue_comments(load_config(), "7")
first_request = opener.open.call_args_list[0].args[0]
second_request = opener.open.call_args_list[1].args[0]
assert first_request.full_url == (
"https://api.bitbucket.org/2.0/repositories/apache/magpie/issues/7/comments"
)
assert second_request.full_url.endswith("/issues/7/comments?page=2")
assert result["issue_id"] == "7"
assert result["values"] == [
{"id": 101, "content": {"raw": "First comment"}},
{"id": 102, "content": {"raw": "Second comment"}},
]
def test_datacenter_get_issue_comments_unsupported(datacenter_env: None) -> None:
with pytest.raises(BitbucketError, match="Data Center native issue comments are not supported"):
datacenter.get_issue_comments(load_config(), "7")
def test_normalize_cloud_issue_comments() -> None:
normalized = issue_comments(
"cloud",
{
"issue_id": "7",
"values": [
{
"id": 101,
"user": {"display_name": "Alice"},
"content": {"raw": "Looks good."},
"created_on": "2026-07-01T00:00:00Z",
"updated_on": "2026-07-01T01:00:00Z",
"deleted": False,
"links": {"html": {"href": "https://bitbucket.org/apache/magpie/issues/7#comment-101"}},
}
],
},
)
assert normalized["backend"] == "bitbucket-cloud"
assert normalized["coverage"] == "partial-read-only"
assert normalized["issue_id"] == "7"
assert normalized["participants"] == ["Alice"]
assert normalized["comments"][0]["id"] == "101"
assert normalized["comments"][0]["author"] == "Alice"
assert normalized["comments"][0]["body"] == "Looks good."
assert normalized["comments"][0]["date"] == "2026-07-01T00:00:00Z"
assert normalized["comments"][0]["kind"] == "comment"
assert normalized["comments"][0]["deleted"] is False
deleted_normalized = issue_comments(
"cloud",
{"issue_id": "7", "values": [{"id": 102, "deleted": True}]},
)
assert deleted_normalized["comments"][0]["deleted"] is True
@patch("magpie_bitbucket.cloud.get_issue_comments")
def test_cli_issue_comments_cloud(
mock_get_issue_comments: MagicMock,
cloud_env: None,
capsys: pytest.CaptureFixture[str],
) -> None:
mock_get_issue_comments.return_value = {
"issue_id": "7",
"values": [{"id": 101, "user": {"display_name": "Alice"}, "content": {"raw": "Looks good."}}],
}
exit_code = main(["issue", "comments", "7"])
assert exit_code == 0
mock_get_issue_comments.assert_called_once()
output = json.loads(capsys.readouterr().out)
assert output["backend"] == "bitbucket-cloud"
assert output["issue_id"] == "7"
assert output["comments"][0]["body"] == "Looks good."