diff --git a/docs/labels-and-capabilities.md b/docs/labels-and-capabilities.md index 85951ea47..1aea9cd5c 100644 --- a/docs/labels-and-capabilities.md +++ b/docs/labels-and-capabilities.md @@ -337,7 +337,7 @@ or a contract-free mix of substrates (e.g. `tools/spec-inventory` is | [`tools/egress-gateway`](../tools/egress-gateway/) | `substrate:sandbox` | Egress-allowlist forward proxy (proxy.py plugin); host-level egress chokepoint — defence-in-depth for RFC-AI-0003 §4.4 | | [`tools/container-gateway`](../tools/container-gateway/) | `substrate:sandbox` | Per-project policy proxy for the podman / docker API; label-scoped, mount- and privilege-checked container access from inside the sandbox | | [`tools/forwarder-relay`](../tools/forwarder-relay/) | `contract:report-relay` | Adapter contract for inbound-relay backends (ASF Security relay, huntr.com, HackerOne triagers). Pure interface spec; adapters declare detection + credit-extraction + reporter-addressing rules. | -| [`tools/bitbucket`](../tools/bitbucket/) | `contract:change-request` + `contract:tracker` | Coverage: `partial`. Bitbucket Cloud and Bitbucket Data Center bridge foundation for repository metadata context, branch restriction context for PR-management decisions, pull-request discovery/fetching, read-only commit fetching, read-only diff fetching, comments-only discussion fetching, read-only review-state fetching, Cloud-only pull-request task listing/fetching, read-only merge-check context fetching, and read-only status fetching, plus narrowly scoped pull-request comment creation on Cloud and Data Center and Cloud-only write actions: `pr review` (with a `comment`, `approve`, or `request-changes` verdict), approve/unapprove, request-changes/remove-request-changes, decline, reject, and `pr merge` (with `pr merge-task-status` to follow an asynchronous merge). Native Bitbucket issue operations are not implemented: Atlassian removed the native Bitbucket Cloud Issue Tracker API on 2026-08-20 and Data Center native issues were never supported, so the historical issue CLI surface fails closed before any request. Data Center `pr jira-issues ` provides read-only discovery of Jira keys associated with pull-request commits and hands them to the Jira bridge (`tools/jira`) for tracker operations. The `partial` qualifier means this tool implements named contract operations but does not satisfy the complete contract and must not be counted as a complete/selectable backend. Broader linked Jira coverage and the remaining pull-request coverage are incomplete. | +| [`tools/bitbucket`](../tools/bitbucket/) | `contract:change-request` + `contract:tracker` | Coverage: `partial`. Bitbucket Cloud and Bitbucket Data Center bridge foundation for repository metadata context, branch restriction context for PR-management decisions, pull-request discovery/fetching, read-only commit fetching, read-only diff fetching, comments-only discussion fetching, read-only review-state fetching, Cloud-only pull-request task listing/fetching, read-only merge-check context fetching, and read-only status fetching, plus narrowly scoped pull-request comment creation, approve/unapprove, and request-changes/remove-request-changes actions on Cloud and Data Center, and Cloud-only write actions: `pr review` (with a `comment`, `approve`, or `request-changes` verdict), decline, reject, and `pr merge` (with `pr merge-task-status` to follow an asynchronous merge). Native Bitbucket issue operations are not implemented: Atlassian removed the native Bitbucket Cloud Issue Tracker API on 2026-08-20 and Data Center native issues were never supported, so the historical issue CLI surface fails closed before any request. Data Center `pr jira-issues ` provides read-only discovery of Jira keys associated with pull-request commits and hands them to the Jira bridge (`tools/jira`) for tracker operations. The `partial` qualifier means this tool implements named contract operations but does not satisfy the complete contract and must not be counted as a complete/selectable backend. Broader linked Jira coverage and the remaining pull-request coverage are incomplete. | | [`tools/forgejo`](../tools/forgejo/) | `contract:tracker` + `contract:source-control` + `contract:change-request` | Coverage: `partial`. Forgejo / Gitea REST API and `tea` CLI forge bridge foundation for issue listing/fetching, confirmed issue creation, body edits, comments, labels, and milestones under `contract:tracker`, git-backed branch/commit/push operations under `contract:source-control`, and pull-request creation via REST API / compare URL and label edits under `contract:change-request`. Project boards are unsupported (`no-op`) due to absence of REST card/column endpoints. The `partial` qualifier means this tool implements named contract operations but does not satisfy the complete contract and must not be counted as a complete/selectable backend. | | [`tools/fossil`](../tools/fossil/) | `contract:tracker` + `contract:source-control` | Fossil SCM forge bridge: integrates local SQLite-backed ticket tracking, wiki, and forum reads with the version-control shim | | [`tools/github`](../tools/github/) | `contract:tracker` + `contract:source-control` + `contract:change-request` + `contract:people` | GitHub REST / GraphQL tracker substrate (called by every lifecycle phase) plus the Git source-control binding documented in [`source-control.md`](../tools/github/source-control.md) (runnable backend in [`tools/vcs`](../tools/vcs/)), the pull-request review/merge gate (`change-request`; the ASF default backend, alongside `tools/jira-patch/` and `tools/mail-patch/` for SVN-first projects), and account profiles / collaborators / team membership (`people`) | diff --git a/tools/bitbucket/README.md b/tools/bitbucket/README.md index aca9b15c5..682dc85de 100644 --- a/tools/bitbucket/README.md +++ b/tools/bitbucket/README.md @@ -80,10 +80,10 @@ Implemented commands and compatibility stubs: - `magpie-bitbucket pr comment --body-file ` (Cloud and Data Center write) - `magpie-bitbucket pr reviews ` - `magpie-bitbucket pr review --verdict {comment,approve,request-changes} --body-file ` (Cloud-only write) -- `magpie-bitbucket pr approve ` (Cloud-only write) -- `magpie-bitbucket pr unapprove ` (Cloud-only write) -- `magpie-bitbucket pr request-changes ` (Cloud-only write) -- `magpie-bitbucket pr remove-request-changes ` (Cloud-only write) +- `magpie-bitbucket pr approve ` (Cloud and Data Center write) +- `magpie-bitbucket pr unapprove ` (Cloud and Data Center write) +- `magpie-bitbucket pr request-changes ` (Cloud and Data Center write) +- `magpie-bitbucket pr remove-request-changes ` (Cloud and Data Center write) - `magpie-bitbucket pr decline ` (Cloud-only write) - `magpie-bitbucket pr reject --reason-file ` (Cloud-only write) - `magpie-bitbucket pr merge --strategy {merge,squash,rebase} --expected-source-commit ` (Cloud-only write) @@ -105,9 +105,10 @@ activity where exposed by the configured Bitbucket backend. Write coverage is intentionally narrow. The bridge supports confirmed top-level pull-request comment creation on Bitbucket Cloud and Data Center. -Additional confirmed Cloud-only writes cover pull-request approve/unapprove, -request-changes/remove-request-changes, decline, backend-neutral reject, and -merge actions after the calling skill has obtained explicit user confirmation. Native Bitbucket Cloud issue writes +Additional confirmed Cloud and Data Center writes cover pull-request +approve/unapprove and request-changes/remove-request-changes. Cloud-only writes +cover decline, backend-neutral reject, and merge actions after the calling +skill has obtained explicit user confirmation. Native Bitbucket Cloud issue writes are unavailable because Atlassian removed the native Issue Tracker API. Other writes, such as editing/deleting pull-request comments, changing branches, or triggering builds, remain out of scope and should be added @@ -123,7 +124,7 @@ separately with narrow command surfaces and maintainer review. ## Features -This implementation primarily covers read-only operations, plus narrowly scoped Cloud write operations: +This implementation primarily covers read-only operations, plus narrowly scoped write operations: 1. **Authentication preflight:** verify the configured Bitbucket backend and credentials can reach the selected repository. 2. **Repository metadata:** fetch normalized repository details from Bitbucket Cloud or Data Center. @@ -136,8 +137,8 @@ This implementation primarily covers read-only operations, plus narrowly scoped 9. **Pull-request diff fetch:** fetch the pull request unified diff as normalized read-only output. 10. **Pull-request discussion fetch:** fetch a comments-only pull request discussion subset as normalized read-only output. 11. **Pull-request review-state fetch:** fetch reviewers, approvals, change-request signals, pending review requests, and normalized review activity. -12. **Cloud pull-request approve:** approve one pull request as the authenticated user after explicit caller-side confirmation. -13. **Cloud pull-request unapprove:** withdraw the authenticated user's approval after explicit caller-side confirmation. +12. **Pull-request approve:** approve one Cloud or Data Center pull request as the authenticated user after explicit caller-side confirmation. +13. **Pull-request unapprove:** withdraw the authenticated user's Cloud or Data Center pull-request approval after explicit caller-side confirmation. 14. **Pull-request tasks fetch:** list Bitbucket Cloud pull-request tasks and fetch one task as partial read-only change-request context. 15. **Pull-request merge-check context fetch:** fetch known read-only mergeability, conflict, status-check, and review blocker context while preserving unknown values where the backend does not expose a clear signal. 16. **Pull-request status fetch:** fetch build/status checks for the pull request as normalized read-only output. @@ -162,8 +163,8 @@ surface: | Change requests | `get_discussion` / `pr discussion ` | Partial read-only | Fetches a comments-only discussion subset with pagination. Participants beyond comment authors and unresolved-thread accounting remain incomplete. | | Change requests | `pr comment --body-file ` | Partial write | Creates one top-level Bitbucket Cloud or Data Center pull-request comment from a caller-supplied body file after explicit caller-side confirmation. | | Change requests | `reviews` supplement / `pr reviews ` | Partial read-only | Fetches reviewers, approvals, change-request signals, pending review requests, normalized review events, and an aggregate review decision. This does not post reviews or mutate PR state. | -| Change requests | `pr approve ` / `pr unapprove ` | Partial write, Cloud only | Approves or withdraws the authenticated user's approval after explicit caller-side confirmation. Data Center approval writes remain unsupported by these commands. For a review body together with a verdict, use `pr review` (the `post_review` contract). | -| Change requests | `pr request-changes ` / `pr remove-request-changes ` | Partial write, Cloud only | Requests changes or removes the authenticated user's change request after explicit caller-side confirmation. Data Center change-request writes remain unsupported by these commands. For a review body together with a verdict, use `pr review` (the `post_review` contract). | +| Change requests | `pr approve ` / `pr unapprove ` | Partial write | Approves or withdraws the authenticated user's approval on Bitbucket Cloud or Data Center after explicit caller-side confirmation. For a review body together with a verdict, use `pr review` (the `post_review` contract), which remains Cloud-only. | +| Change requests | `pr request-changes ` / `pr remove-request-changes ` | Partial write | Requests changes or removes the authenticated user's change request on Bitbucket Cloud or Data Center after explicit caller-side confirmation. Data Center maps these states to `NEEDS_WORK` and `UNAPPROVED`. For a review body together with a verdict, use `pr review` (the `post_review` contract), which remains Cloud-only. | | Change requests | `pr decline ` | Partial write, Cloud only | Declines one Bitbucket Cloud pull request after explicit caller-side confirmation. Data Center decline writes remain unsupported by this command. | | Change requests | `merge_checks` supplement / `pr merge-checks ` | Partial read-only | Fetches known read-only merge-check context, including Data Center merge-test results, reported mergeability/conflict fields, status checks, review decision, and normalized blockers. Unknown backend signals remain unknown. This does not merge or mutate PR state. | | Change requests | `post_review` / `pr review --verdict {comment,approve,request-changes} --body-file ` | Partial write, Cloud only | Implements the backend-neutral review contract for Bitbucket Cloud after explicit caller-side confirmation. `comment` posts the review body only. `approve` and `request-changes` post the body first and then apply the corresponding Bitbucket verdict. If the second mutation fails after the body was posted, the command fails with an explicit partial-outcome warning so callers inspect the PR before retrying. Data Center post-review writes remain unsupported. | @@ -265,7 +266,7 @@ injected by the caller as `BITBUCKET_TOKEN` / `BITBUCKET_CLOUD_USER`. | Variable | Required for | Description | |---|---|---| | `BITBUCKET_KIND` | all commands | `cloud` or `datacenter`. Defaults to `cloud`. | -| `BITBUCKET_TOKEN` | authenticated API calls | API token or personal access token accepted by the selected backend. Read-only PR/repository commands should use minimum read scopes. Pull-request comment writes on Cloud or Data Center, plus Cloud approve/unapprove, request-changes/remove-request-changes, decline, and merge writes require credentials permitted to write pull requests. `repo restrictions` needs elevated repository-admin scope on Bitbucket Cloud and may require `REPO_ADMIN` on Data Center. | +| `BITBUCKET_TOKEN` | authenticated API calls | API token or personal access token accepted by the selected backend. Read-only PR/repository commands should use minimum read scopes. Pull-request comment, approve/unapprove, and request-changes/remove-request-changes writes on Cloud or Data Center, plus Cloud decline and merge writes require credentials permitted to write pull requests. `repo restrictions` needs elevated repository-admin scope on Bitbucket Cloud and may require `REPO_ADMIN` on Data Center. | | `BITBUCKET_AUTH_SCHEME` | all commands | Authentication scheme. Defaults to `Basic` for Cloud and `Bearer` for Data Center. | | `BITBUCKET_CLOUD_USER` | Cloud Basic auth | Atlassian account email/user used with `BITBUCKET_TOKEN`. | | `BITBUCKET_WORKSPACE` | Cloud | Bitbucket Cloud workspace slug. | @@ -304,8 +305,8 @@ The bridge currently supports these narrow mutations (native Cloud issue writes were removed upstream and fail closed): - top-level pull-request comment creation on Cloud and Data Center -- Cloud-only pull-request approval and approval withdrawal -- Cloud-only pull-request change-request creation and removal +- pull-request approval and approval withdrawal on Cloud and Data Center +- pull-request change-request creation and removal on Cloud and Data Center - Cloud-only pull-request decline - Cloud-only pull-request merge with source-commit pinning @@ -313,7 +314,7 @@ For pull-request merge, the calling skill must run and inspect `pr merge-checks ` before invoking `pr merge`; the merge command itself does not independently enforce approval, build-status, or merge-check gates. -Bitbucket Data Center issue-comment, review-state, decline, and merge +Bitbucket Data Center issue-comment, post-review, decline, and merge writes remain unsupported by these commands. All other Bitbucket mutations remain out of scope for the current bridge and diff --git a/tools/bitbucket/src/magpie_bitbucket/client.py b/tools/bitbucket/src/magpie_bitbucket/client.py index fc8ba2da5..4ee0a1ee1 100644 --- a/tools/bitbucket/src/magpie_bitbucket/client.py +++ b/tools/bitbucket/src/magpie_bitbucket/client.py @@ -198,6 +198,49 @@ def get_json(url: str, config: BitbucketConfig) -> dict[str, Any]: raise BitbucketError(f"Failed to parse JSON response from {url}") from exc +def get_json_with_header( + url: str, + config: BitbucketConfig, + header: str, +) -> tuple[dict[str, Any], str | None]: + """GET a Bitbucket API URL and return JSON plus one response header.""" + _require_https(url) + request = urllib.request.Request( + url, + headers={ + "Accept": "application/json", + "Authorization": make_auth_header(config), + }, + method="GET", + ) + + opener = urllib.request.build_opener(NoAuthRedirectHandler) + + try: + with opener.open(request, timeout=DEFAULT_TIMEOUT_SECONDS) as response: + body = response.read().decode("utf-8") + parsed = json.loads(body) + + if not isinstance(parsed, dict): + raise BitbucketError(f"Expected JSON object from {url}") + + return parsed, response.headers.get(header) + + except BitbucketError: + raise + except urllib.error.HTTPError as exc: + message = _read_http_error(exc) + raise BitbucketError(f"Bitbucket request failed with HTTP {exc.code}: {message}") from exc + except urllib.error.URLError as exc: + raise BitbucketError(f"Failed to connect to Bitbucket: {exc.reason}") from exc + except TimeoutError as exc: + raise BitbucketError( + f"Timed out while connecting to Bitbucket after {DEFAULT_TIMEOUT_SECONDS}s" + ) from exc + except json.JSONDecodeError as exc: + raise BitbucketError(f"Failed to parse JSON response from {url}") from exc + + def write_request_with_metadata( url: str, config: BitbucketConfig, diff --git a/tools/bitbucket/src/magpie_bitbucket/datacenter.py b/tools/bitbucket/src/magpie_bitbucket/datacenter.py index fb06456eb..3f950dae5 100644 --- a/tools/bitbucket/src/magpie_bitbucket/datacenter.py +++ b/tools/bitbucket/src/magpie_bitbucket/datacenter.py @@ -21,15 +21,18 @@ import json from typing import Any +from urllib.parse import urlencode from magpie_bitbucket.client import ( BitbucketConfig, BitbucketError, get_json, + get_json_with_header, get_text, post_json, quote_path, require, + write_request, ) @@ -357,14 +360,99 @@ def get_pull_request_status(config: BitbucketConfig, pull_request_id: str) -> di return combined +def _current_user_slug(config: BitbucketConfig) -> str: + """Return the authenticated Bitbucket Data Center user's URL-safe slug.""" + users_url = f"{_api_base(config)}/users" + + _, username = get_json_with_header(users_url, config, "X-AUSERNAME") + + if not isinstance(username, str) or not username.strip(): + raise BitbucketError( + "Bitbucket Data Center response did not include the authenticated user in X-AUSERNAME" + ) + + username = username.strip() + start = 0 + matches: list[dict[str, Any]] = [] + + while True: + params: dict[str, str | int] = {"filter": username} + if start: + params["start"] = start + + page = get_json(f"{users_url}?{urlencode(params)}", config) + + values = page.get("values") + if not isinstance(values, list): + raise BitbucketError("Bitbucket Data Center user search response did not include values") + + matches.extend(user for user in values if isinstance(user, dict) and user.get("name") == username) + + next_start = _next_page_start(page, start) + if next_start is None: + break + + start = next_start + + if not matches: + raise BitbucketError(f"Bitbucket Data Center could not resolve authenticated user {username!r}") + + if len(matches) > 1: + raise BitbucketError( + f"Bitbucket Data Center returned multiple matches for authenticated user {username!r}" + ) + + slug = matches[0].get("slug") + + if not isinstance(slug, str) or not slug.strip(): + raise BitbucketError("Bitbucket Data Center authenticated user did not include a user slug") + + return quote_path(slug) + + +def _set_pull_request_participant_status( + config: BitbucketConfig, + pull_request_id: str, + status: str, +) -> dict[str, Any]: + """Set the authenticated user's pull-request participant status.""" + project_key = quote_path(require(config.project_key, "BITBUCKET_PROJECT_KEY")) + repo_slug = quote_path(require(config.repo_slug, "BITBUCKET_REPO_SLUG")) + pr_id = quote_path(pull_request_id) + user_slug = _current_user_slug(config) + + url = ( + f"{_api_base(config)}/projects/{project_key}/repos/{repo_slug}/" + f"pull-requests/{pr_id}/participants/{user_slug}" + ) + + participant = write_request( + url, + config, + method="PUT", + payload={"status": status}, + ) + + if participant is None: + raise BitbucketError( + "Bitbucket Data Center participant-status response did not include a participant" + ) + + return { + "pull_request_id": pull_request_id, + "participant": participant, + } + + def approve_pull_request( config: BitbucketConfig, pull_request_id: str, ) -> dict[str, Any]: - """Reject pull-request approval writes for Data Center for now.""" - _ = (config, pull_request_id) - raise BitbucketError( - "Bitbucket Data Center pull request approval writes are not supported by this command yet" + """Approve one Bitbucket Data Center pull request as the authenticated user.""" + return _set_pull_request_participant_status( + config, + pull_request_id, + "APPROVED", ) @@ -372,10 +460,11 @@ def unapprove_pull_request( config: BitbucketConfig, pull_request_id: str, ) -> dict[str, Any]: - """Reject pull-request unapproval writes for Data Center for now.""" - _ = (config, pull_request_id) - raise BitbucketError( - "Bitbucket Data Center pull request approval writes are not supported by this command yet" + """Remove the authenticated user's approval from a Data Center pull request.""" + return _set_pull_request_participant_status( + config, + pull_request_id, + "UNAPPROVED", ) @@ -383,10 +472,11 @@ def request_pull_request_changes( config: BitbucketConfig, pull_request_id: str, ) -> dict[str, Any]: - """Reject pull-request change-request writes for Data Center for now.""" - _ = (config, pull_request_id) - raise BitbucketError( - "Bitbucket Data Center pull request change-request writes are not supported by this command yet" + """Request changes on a Data Center pull request as the authenticated user.""" + return _set_pull_request_participant_status( + config, + pull_request_id, + "NEEDS_WORK", ) @@ -394,10 +484,11 @@ def remove_pull_request_changes_request( config: BitbucketConfig, pull_request_id: str, ) -> dict[str, Any]: - """Reject pull-request change-request removal writes for Data Center for now.""" - _ = (config, pull_request_id) - raise BitbucketError( - "Bitbucket Data Center pull request change-request writes are not supported by this command yet" + """Remove the authenticated user's Data Center change request.""" + return _set_pull_request_participant_status( + config, + pull_request_id, + "UNAPPROVED", ) diff --git a/tools/bitbucket/tests/test_bitbucket.py b/tools/bitbucket/tests/test_bitbucket.py index c0bf7393d..d547e32ed 100644 --- a/tools/bitbucket/tests/test_bitbucket.py +++ b/tools/bitbucket/tests/test_bitbucket.py @@ -85,10 +85,14 @@ def datacenter_env(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("BITBUCKET_REPO_SLUG", "magpie") -def make_mock_response(body: dict[str, Any]) -> MagicMock: +def make_mock_response( + body: dict[str, Any], + *, + headers: dict[str, str] | None = None, +) -> MagicMock: response = MagicMock() response.status = 200 - response.headers.get.return_value = None + response.headers.get.side_effect = lambda name, default=None: (headers or {}).get(name, default) response.read.return_value = json.dumps(body).encode() return response @@ -3033,24 +3037,213 @@ def test_cloud_unapprove_pull_request_deletes_without_body( assert result == {"pull_request_id": "7"} -def test_datacenter_approve_pull_request_unsupported( +@pytest.mark.parametrize( + ("operation", "expected_status"), + [ + (datacenter.approve_pull_request, "APPROVED"), + (datacenter.unapprove_pull_request, "UNAPPROVED"), + ], +) +@patch("magpie_bitbucket.datacenter._current_user_slug", return_value="alice") +@patch("magpie_bitbucket.client.urllib.request.build_opener") +def test_datacenter_pull_request_approval_status( + mock_build_opener: MagicMock, + mock_current_user_slug: MagicMock, + operation: Any, + expected_status: str, + datacenter_env: None, +) -> None: + mock_opener( + mock_build_opener, + { + "user": { + "slug": "alice", + "displayName": "Alice", + "name": "alice", + }, + "role": "REVIEWER", + "approved": expected_status == "APPROVED", + "status": expected_status, + }, + ) + + result = operation(load_config(), "9") + + mock_current_user_slug.assert_called_once() + + requests = mock_build_opener.return_value.open.call_args_list + assert len(requests) == 1 + + status_request = requests[0].args[0] + + assert status_request.full_url == ( + "https://bitbucket.example.test/rest/api/1.0/projects/MAGPIE/repos/magpie/" + "pull-requests/9/participants/alice" + ) + assert status_request.get_method() == "PUT" + assert json.loads(status_request.data.decode("utf-8")) == {"status": expected_status} + + assert result["pull_request_id"] == "9" + assert result["participant"]["status"] == expected_status + + +@patch("magpie_bitbucket.datacenter.get_json") +@patch("magpie_bitbucket.datacenter.get_json_with_header") +def test_datacenter_current_user_slug_from_paged_user_search( + mock_get_json_with_header: MagicMock, + mock_get_json: MagicMock, + datacenter_env: None, +) -> None: + mock_get_json_with_header.return_value = ( + {"values": [], "isLastPage": True}, + "alice", + ) + mock_get_json.side_effect = [ + { + "size": 1, + "limit": 25, + "isLastPage": False, + "nextPageStart": 25, + "values": [ + { + "name": "alicia", + "displayName": "Alicia", + "slug": "alicia", + } + ], + "start": 0, + }, + { + "size": 1, + "limit": 25, + "isLastPage": True, + "values": [ + { + "name": "alice", + "displayName": "Alice", + "slug": "alice", + } + ], + "start": 25, + }, + ] + + result = datacenter._current_user_slug(load_config()) + + assert result == "alice" + mock_get_json_with_header.assert_called_once_with( + "https://bitbucket.example.test/rest/api/1.0/users", + load_config(), + "X-AUSERNAME", + ) + + assert [call.args[0] for call in mock_get_json.call_args_list] == [ + "https://bitbucket.example.test/rest/api/1.0/users?filter=alice", + "https://bitbucket.example.test/rest/api/1.0/users?filter=alice&start=25", + ] + + +@patch("magpie_bitbucket.datacenter.get_json") +@patch("magpie_bitbucket.datacenter.get_json_with_header") +def test_datacenter_current_user_slug_rejects_no_match( + mock_get_json_with_header: MagicMock, + mock_get_json: MagicMock, + datacenter_env: None, +) -> None: + mock_get_json_with_header.return_value = ( + {"values": [], "isLastPage": True}, + "alice", + ) + mock_get_json.return_value = { + "size": 0, + "limit": 25, + "isLastPage": True, + "values": [], + "start": 0, + } + + with pytest.raises( + BitbucketError, + match="could not resolve authenticated user", + ): + datacenter._current_user_slug(load_config()) + + +@patch("magpie_bitbucket.datacenter.get_json") +@patch("magpie_bitbucket.datacenter.get_json_with_header") +def test_datacenter_current_user_slug_rejects_multiple_matches( + mock_get_json_with_header: MagicMock, + mock_get_json: MagicMock, datacenter_env: None, ) -> None: + mock_get_json_with_header.return_value = ( + {"values": [], "isLastPage": True}, + "alice", + ) + mock_get_json.return_value = { + "size": 2, + "limit": 25, + "isLastPage": True, + "values": [ + {"name": "alice", "slug": "alice"}, + {"name": "alice", "slug": "alice-duplicate"}, + ], + "start": 0, + } + + with pytest.raises( + BitbucketError, + match="returned multiple matches", + ): + datacenter._current_user_slug(load_config()) + + +@patch("magpie_bitbucket.datacenter.get_json_with_header") +def test_datacenter_current_user_slug_requires_authenticated_username( + mock_get_json_with_header: MagicMock, + datacenter_env: None, +) -> None: + mock_get_json_with_header.return_value = ( + {"values": [], "isLastPage": True}, + None, + ) + with pytest.raises( BitbucketError, - match="Data Center pull request approval writes are not supported", + match="did not include the authenticated user in X-AUSERNAME", ): - datacenter.approve_pull_request(load_config(), "9") + datacenter._current_user_slug(load_config()) -def test_datacenter_unapprove_pull_request_unsupported( +@patch("magpie_bitbucket.datacenter.get_json") +@patch("magpie_bitbucket.datacenter.get_json_with_header") +def test_datacenter_current_user_slug_requires_slug( + mock_get_json_with_header: MagicMock, + mock_get_json: MagicMock, datacenter_env: None, ) -> None: + mock_get_json_with_header.return_value = ( + {"values": [], "isLastPage": True}, + "alice", + ) + mock_get_json.return_value = { + "size": 1, + "limit": 25, + "isLastPage": True, + "values": [ + { + "name": "alice", + "displayName": "Alice", + } + ], + "start": 0, + } + with pytest.raises( BitbucketError, - match="Data Center pull request approval writes are not supported", + match="authenticated user did not include a user slug", ): - datacenter.unapprove_pull_request(load_config(), "9") + datacenter._current_user_slug(load_config()) def test_normalize_pull_request_approval_approved() -> None: @@ -3139,6 +3332,49 @@ def test_cli_pr_unapprove_cloud( assert output["approved"] is False +@pytest.mark.parametrize( + ("action", "function_name", "approved", "participant_status"), + [ + ("approve", "approve_pull_request", True, "APPROVED"), + ("unapprove", "unapprove_pull_request", False, "UNAPPROVED"), + ], +) +def test_cli_pr_approval_datacenter( + action: str, + function_name: str, + approved: bool, + participant_status: str, + datacenter_env: None, + capsys: pytest.CaptureFixture[str], +) -> None: + with patch(f"magpie_bitbucket.datacenter.{function_name}") as mock_operation: + mock_operation.return_value = { + "pull_request_id": "9", + "participant": { + "user": { + "slug": "alice", + "displayName": "Alice", + }, + "role": "REVIEWER", + "approved": approved, + "status": participant_status, + }, + } + + exit_code = main(["pr", action, "9"]) + + assert exit_code == 0 + mock_operation.assert_called_once() + args = mock_operation.call_args.args + assert args[1:] == ("9",) + + output = json.loads(capsys.readouterr().out) + assert output["backend"] == "bitbucket-datacenter" + assert output["operation"] == f"pull-request-{action}" + assert output["pull_request_id"] == "9" + assert output["approved"] is approved + + def test_write_request_rejects_redirect_for_delete( cloud_env: None, ) -> None: @@ -3211,24 +3447,106 @@ def test_cloud_remove_pull_request_changes_request_deletes_without_body( assert result == {"pull_request_id": "7"} -def test_datacenter_request_pull_request_changes_unsupported( +@pytest.mark.parametrize( + ("operation", "expected_status"), + [ + (datacenter.request_pull_request_changes, "NEEDS_WORK"), + (datacenter.remove_pull_request_changes_request, "UNAPPROVED"), + ], +) +@patch("magpie_bitbucket.datacenter._current_user_slug", return_value="alice") +@patch("magpie_bitbucket.client.urllib.request.build_opener") +def test_datacenter_pull_request_change_request_status( + mock_build_opener: MagicMock, + mock_current_user_slug: MagicMock, + operation: Any, + expected_status: str, datacenter_env: None, ) -> None: - with pytest.raises( - BitbucketError, - match="Data Center pull request change-request writes are not supported", - ): - datacenter.request_pull_request_changes(load_config(), "9") + mock_opener( + mock_build_opener, + { + "user": { + "slug": "alice", + "displayName": "Alice", + "name": "alice", + }, + "role": "REVIEWER", + "approved": False, + "status": expected_status, + }, + ) + + result = operation(load_config(), "9") + mock_current_user_slug.assert_called_once() -def test_datacenter_remove_pull_request_changes_request_unsupported( + requests = mock_build_opener.return_value.open.call_args_list + assert len(requests) == 1 + + status_request = requests[0].args[0] + assert status_request.full_url == ( + "https://bitbucket.example.test/rest/api/1.0/projects/MAGPIE/repos/magpie/" + "pull-requests/9/participants/alice" + ) + assert status_request.get_method() == "PUT" + assert json.loads(status_request.data.decode("utf-8")) == {"status": expected_status} + + assert result["pull_request_id"] == "9" + assert result["participant"]["status"] == expected_status + + +@pytest.mark.parametrize( + ("action", "function_name", "requested", "participant_status"), + [ + ( + "request-changes", + "request_pull_request_changes", + True, + "NEEDS_WORK", + ), + ( + "remove-request-changes", + "remove_pull_request_changes_request", + False, + "UNAPPROVED", + ), + ], +) +def test_cli_pr_change_request_datacenter( + action: str, + function_name: str, + requested: bool, + participant_status: str, datacenter_env: None, + capsys: pytest.CaptureFixture[str], ) -> None: - with pytest.raises( - BitbucketError, - match="Data Center pull request change-request writes are not supported", - ): - datacenter.remove_pull_request_changes_request(load_config(), "9") + with patch(f"magpie_bitbucket.datacenter.{function_name}") as mock_operation: + mock_operation.return_value = { + "pull_request_id": "9", + "participant": { + "user": { + "slug": "alice", + "displayName": "Alice", + }, + "role": "REVIEWER", + "approved": False, + "status": participant_status, + }, + } + + exit_code = main(["pr", action, "9"]) + + assert exit_code == 0 + mock_operation.assert_called_once() + args = mock_operation.call_args.args + assert args[1:] == ("9",) + + output = json.loads(capsys.readouterr().out) + assert output["backend"] == "bitbucket-datacenter" + assert output["operation"] == f"pull-request-{action}" + assert output["pull_request_id"] == "9" + assert output["changes_requested"] is requested def test_normalize_pull_request_change_request_requested() -> None: diff --git a/tools/spec-loop/specs/adapters.md b/tools/spec-loop/specs/adapters.md index 6d5276395..2d15ad198 100644 --- a/tools/spec-loop/specs/adapters.md +++ b/tools/spec-loop/specs/adapters.md @@ -194,9 +194,10 @@ by swapping the adapter, not the skill. On Cloud, `pr status` fetches the pull request first and reports its state and source commit alongside the `/statuses` build checks, matching the Data Center payload (#1526). - Confirmed pull-request comment writes cover Cloud and Data Center. - Additional Cloud-only writes cover approve/unapprove, request-changes, - decline, backend-neutral `reject`, and `pr merge --strategy + Confirmed pull-request comment, approve/unapprove, and + request-changes/remove-request-changes writes cover Cloud and Data Center. + Additional Cloud-only writes cover decline, backend-neutral `reject`, + and `pr merge --strategy {merge,squash,rebase} --expected-source-commit `, which re-checks the head commit immediately before the merge POST so a moved head fails closed (#1471); it does not enforce approval or build gates itself. @@ -210,7 +211,7 @@ by swapping the adapter, not the skill. upper-cased and de-duplicated (#1727). It is not a complete `contract:change-request` or `contract:tracker` backend yet; broader Jira handoff, Data Center - review/merge writes, broader repository permissions, and fuller Pipelines + post-review/decline/merge writes, broader repository permissions, and fuller Pipelines run/log/retry coverage remain tracked in #606. - `tools/gitlab/` (`magpie-gitlab`) — partial, read-only GitLab REST API v4 bridge, declared `contract:tracker + contract:source-control +