From db990af0efa6e4b97d52a24d8e2ec813bc8d6c24 Mon Sep 17 00:00:00 2001 From: Kavya Katal Date: Tue, 1 Sep 2026 15:28:59 +0530 Subject: [PATCH] feat(bitbucket): add guarded cloud PR comment create --- tools/bitbucket/README.md | 18 +- tools/bitbucket/src/magpie_bitbucket/cli.py | 23 +++ tools/bitbucket/src/magpie_bitbucket/cloud.py | 22 +++ .../src/magpie_bitbucket/datacenter.py | 11 ++ .../src/magpie_bitbucket/normalize.py | 18 ++ tools/bitbucket/tests/test_bitbucket.py | 165 ++++++++++++++++++ 6 files changed, 254 insertions(+), 3 deletions(-) diff --git a/tools/bitbucket/README.md b/tools/bitbucket/README.md index 431b89028..d738ba85d 100644 --- a/tools/bitbucket/README.md +++ b/tools/bitbucket/README.md @@ -71,6 +71,7 @@ Implemented read-only commands: - `magpie-bitbucket pr commits ` - `magpie-bitbucket pr diff ` - `magpie-bitbucket pr discussion ` +- `magpie-bitbucket pr comment --body-file ` (Cloud-only write) - `magpie-bitbucket pr reviews ` - `magpie-bitbucket pr tasks ` - `magpie-bitbucket pr task ` @@ -142,6 +143,7 @@ surface: | Change requests | `commits[]` supplement / `pr commits ` | Partial read-only | Fetches the commit list associated with a pull request so partial Bitbucket `get` coverage can expose proposal commits. This does not mutate branches, refs, or repository history. | | Change requests | `diff` supplement / `pr diff ` | Partial read-only | Fetches the pull request unified diff so partial Bitbucket `get` coverage can expose proposal diffs. This does not mutate files, branches, refs, or repository history. | | 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, Cloud only | Creates one top-level Bitbucket Cloud pull-request comment from a caller-supplied body file after explicit caller-side confirmation. Data Center PR comment writes remain unsupported in this command. | | 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 | `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` | Not implemented | Follow-up work for #606. | @@ -193,6 +195,9 @@ uv run --project tools/bitbucket magpie-bitbucket pr diff 123 # Fetch pull request discussion/comments uv run --project tools/bitbucket magpie-bitbucket pr discussion 123 +# Create a Bitbucket Cloud pull request comment after caller-side confirmation +uv run --project tools/bitbucket magpie-bitbucket pr comment 123 --body-file /tmp/comment.txt + # Fetch pull request review state uv run --project tools/bitbucket magpie-bitbucket pr reviews 123 @@ -254,9 +259,16 @@ mutation, but it does **not** decide whether to mutate. Every write operation must be gated on **explicit user confirmation in the calling skill**; the bridge only executes an already-confirmed action. -The comment body is read from `--body-file` to avoid shell-quoting issues. +Comment bodies are read from `--body-file` to avoid shell-quoting issues. Missing or empty body files fail before any outbound write request is made. -Bitbucket Data Center native issue-comment writes remain unsupported. + +The bridge currently supports two narrow Cloud comment mutations: + +- issue comment creation +- top-level pull-request comment creation + +Bitbucket Data Center issue-comment and pull-request-comment writes remain +unsupported by these commands. All other Bitbucket mutations remain out of scope for the current bridge and must be introduced separately with the same confirmation discipline. @@ -273,6 +285,6 @@ Follow-up PRs can extend this bridge with: - Bitbucket issue write operations and additional tracker fields. - Linked Jira issue handoff through `tools/jira/`. -- Pull-request comment creation, review, approve, decline, and merge operations. +- Pull-request review, approve, decline, and merge operations. - Broader repository permission reads. - Fuller Bitbucket Pipelines run/log/retry coverage beyond read-only pull-request status reads. diff --git a/tools/bitbucket/src/magpie_bitbucket/cli.py b/tools/bitbucket/src/magpie_bitbucket/cli.py index 5854c5f5e..7ffb88e5d 100644 --- a/tools/bitbucket/src/magpie_bitbucket/cli.py +++ b/tools/bitbucket/src/magpie_bitbucket/cli.py @@ -120,6 +120,20 @@ def _build_parser() -> argparse.ArgumentParser: pr_discussion = pr_subparsers.add_parser("discussion", help="Fetch pull request discussion.") pr_discussion.add_argument("pull_request_id", help="Pull request ID to fetch discussion for.") + pr_comment = pr_subparsers.add_parser( + "comment", + help="Create a pull request comment after caller-side confirmation.", + ) + pr_comment.add_argument( + "pull_request_id", + help="Pull request ID to comment on.", + ) + pr_comment.add_argument( + "--body-file", + required=True, + help="Path to the confirmed comment body.", + ) + pr_reviews = pr_subparsers.add_parser("reviews", help="Fetch pull request review-state activity.") pr_reviews.add_argument("pull_request_id", help="Pull request ID to fetch review state for.") @@ -200,6 +214,15 @@ def _dispatch(args: argparse.Namespace, config: BitbucketConfig) -> dict[str, An raw = backend.get_pull_request_discussion(config, args.pull_request_id) return normalize.pull_request_discussion(config.kind, raw) + if args.subcommand == "pr" and args.pr_action == "comment": + body = _read_body_file(args.body_file) + raw = backend.create_pull_request_comment( + config, + args.pull_request_id, + body, + ) + return normalize.created_pull_request_comment(config.kind, raw) + if args.subcommand == "pr" and args.pr_action == "reviews": raw = backend.get_pull_request_reviews(config, args.pull_request_id) return normalize.pull_request_reviews(config.kind, raw) diff --git a/tools/bitbucket/src/magpie_bitbucket/cloud.py b/tools/bitbucket/src/magpie_bitbucket/cloud.py index f3423cc3b..7c20d4cba 100644 --- a/tools/bitbucket/src/magpie_bitbucket/cloud.py +++ b/tools/bitbucket/src/magpie_bitbucket/cloud.py @@ -401,6 +401,28 @@ def get_pull_request_status(config: BitbucketConfig, pull_request_id: str) -> di return combined +def create_pull_request_comment( + config: BitbucketConfig, + pull_request_id: str, + body: str, +) -> dict[str, Any]: + """Create one top-level comment on a Bitbucket Cloud pull request.""" + workspace = quote_path(require(config.workspace, "BITBUCKET_WORKSPACE")) + repo_slug = quote_path(require(config.repo_slug, "BITBUCKET_REPO_SLUG")) + pr_id = quote_path(pull_request_id) + url = f"{CLOUD_API_BASE}/repositories/{workspace}/{repo_slug}/pullrequests/{pr_id}/comments" + + comment = post_json( + url, + config, + {"content": {"raw": body}}, + ) + return { + "pull_request_id": pull_request_id, + "comment": comment, + } + + def get_pull_request_discussion(config: BitbucketConfig, pull_request_id: str) -> dict[str, Any]: """Fetch pull request comments from Bitbucket Cloud.""" workspace = quote_path(require(config.workspace, "BITBUCKET_WORKSPACE")) diff --git a/tools/bitbucket/src/magpie_bitbucket/datacenter.py b/tools/bitbucket/src/magpie_bitbucket/datacenter.py index 2d253796d..63fbda8d6 100644 --- a/tools/bitbucket/src/magpie_bitbucket/datacenter.py +++ b/tools/bitbucket/src/magpie_bitbucket/datacenter.py @@ -362,6 +362,17 @@ def _pull_request_source_commit(raw: dict[str, Any]) -> str: # We fetch the paginated feed here and filter comment-bearing activities during # normalization so review/merge/rescope lifecycle events are not exposed as # discussion comments. +def create_pull_request_comment( + config: BitbucketConfig, + pull_request_id: str, + body: str, +) -> dict[str, Any]: + """Reject pull-request comment creation for Data Center for now.""" + _ = (config, pull_request_id, body) + msg = "Bitbucket Data Center pull request comment writes are not supported by this command yet" + raise BitbucketError(msg) + + def get_pull_request_discussion(config: BitbucketConfig, pull_request_id: str) -> dict[str, Any]: """Fetch pull request activities from Bitbucket Data Center.""" project_key = quote_path(require(config.project_key, "BITBUCKET_PROJECT_KEY")) diff --git a/tools/bitbucket/src/magpie_bitbucket/normalize.py b/tools/bitbucket/src/magpie_bitbucket/normalize.py index 1892bb02b..8ec77bfae 100644 --- a/tools/bitbucket/src/magpie_bitbucket/normalize.py +++ b/tools/bitbucket/src/magpie_bitbucket/normalize.py @@ -259,6 +259,24 @@ def pull_request_list(kind: str, raw: dict[str, Any]) -> dict[str, Any]: } +def created_pull_request_comment( + kind: str, + raw: dict[str, Any], +) -> dict[str, Any]: + """Normalize the result of creating one pull request comment.""" + comment = raw.get("comment") + normalized = _cloud_comment(comment) if kind == "cloud" and isinstance(comment, dict) else {} + + return { + "ok": bool(normalized), + "backend": "bitbucket-cloud" if kind == "cloud" else "bitbucket-datacenter", + "operation": "pull-request-comment-create", + "pull_request_id": _string(raw.get("pull_request_id")), + "comment": normalized, + "raw": raw, + } + + def pull_request_discussion(kind: str, raw: dict[str, Any]) -> dict[str, Any]: """Normalize pull request discussion/comments from Bitbucket.""" values = raw.get("values") diff --git a/tools/bitbucket/tests/test_bitbucket.py b/tools/bitbucket/tests/test_bitbucket.py index 11cd1d0a0..ecd6801ee 100644 --- a/tools/bitbucket/tests/test_bitbucket.py +++ b/tools/bitbucket/tests/test_bitbucket.py @@ -37,6 +37,7 @@ ) from magpie_bitbucket.normalize import ( created_issue_comment, + created_pull_request_comment, issue, issue_attachments, issue_comments, @@ -2858,3 +2859,167 @@ def test_cli_issue_comment_missing_body_file_before_write( ) mock_create_issue_comment.assert_not_called() + + +@patch("magpie_bitbucket.client.urllib.request.build_opener") +def test_cloud_create_pull_request_comment_posts_json( + mock_build_opener: MagicMock, + cloud_env: None, +) -> None: + mock_opener( + mock_build_opener, + { + "id": 601, + "content": {"raw": "Confirmed PR comment."}, + "user": {"display_name": "Alice"}, + "deleted": False, + }, + ) + + result = cloud.create_pull_request_comment( + load_config(), + "7", + "Confirmed PR comment.", + ) + + request = mock_build_opener.return_value.open.call_args.args[0] + + assert request.full_url == ( + "https://api.bitbucket.org/2.0/repositories/apache/magpie/pullrequests/7/comments" + ) + assert request.get_method() == "POST" + assert request.get_header("Content-type") == "application/json" + assert json.loads(request.data.decode("utf-8")) == {"content": {"raw": "Confirmed PR comment."}} + assert result["pull_request_id"] == "7" + assert result["comment"]["id"] == 601 + + +def test_datacenter_create_pull_request_comment_unsupported( + datacenter_env: None, +) -> None: + with pytest.raises( + BitbucketError, + match="Data Center pull request comment writes are not supported", + ): + datacenter.create_pull_request_comment( + load_config(), + "9", + "Confirmed PR comment.", + ) + + +def test_normalize_created_cloud_pull_request_comment() -> None: + normalized = created_pull_request_comment( + "cloud", + { + "pull_request_id": "7", + "comment": { + "id": 601, + "content": {"raw": "Confirmed PR comment."}, + "user": {"display_name": "Alice"}, + "created_on": "2026-09-01T00:00:00Z", + "updated_on": "2026-09-01T00:00:01Z", + "deleted": False, + }, + }, + ) + + assert normalized["ok"] is True + assert normalized["backend"] == "bitbucket-cloud" + assert normalized["operation"] == "pull-request-comment-create" + assert normalized["pull_request_id"] == "7" + assert normalized["comment"]["id"] == "601" + assert normalized["comment"]["author"] == "Alice" + assert normalized["comment"]["body"] == "Confirmed PR comment." + + +@patch("magpie_bitbucket.cloud.create_pull_request_comment") +def test_cli_pr_comment_cloud( + mock_create_pull_request_comment: MagicMock, + cloud_env: None, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + body_file = tmp_path / "comment.txt" + body_file.write_text("Confirmed PR comment.", encoding="utf-8") + + mock_create_pull_request_comment.return_value = { + "pull_request_id": "7", + "comment": { + "id": 601, + "content": {"raw": "Confirmed PR comment."}, + "user": {"display_name": "Alice"}, + "deleted": False, + }, + } + + exit_code = main( + [ + "pr", + "comment", + "7", + "--body-file", + str(body_file), + ] + ) + + assert exit_code == 0 + + mock_create_pull_request_comment.assert_called_once() + args = mock_create_pull_request_comment.call_args.args + assert args[1:] == ("7", "Confirmed PR comment.") + + output = json.loads(capsys.readouterr().out) + assert output["operation"] == "pull-request-comment-create" + assert output["comment"]["id"] == "601" + + +@patch("magpie_bitbucket.cloud.create_pull_request_comment") +def test_cli_pr_comment_rejects_empty_body_before_write( + mock_create_pull_request_comment: MagicMock, + cloud_env: None, + tmp_path: Path, +) -> None: + body_file = tmp_path / "comment.txt" + body_file.write_text(" ", encoding="utf-8") + + with pytest.raises( + BitbucketError, + match="Comment body file must not be empty", + ): + main( + [ + "pr", + "comment", + "7", + "--body-file", + str(body_file), + ] + ) + + mock_create_pull_request_comment.assert_not_called() + + +@patch("magpie_bitbucket.cloud.create_pull_request_comment") +def test_cli_pr_comment_missing_body_file_before_write( + mock_create_pull_request_comment: MagicMock, + cloud_env: None, + tmp_path: Path, +) -> None: + body_file = tmp_path / "missing-comment.txt" + + with pytest.raises( + BitbucketError, + match="Body file not found", + ): + main( + [ + "pr", + "comment", + "7", + "--body-file", + str(body_file), + ] + ) + + mock_create_pull_request_comment.assert_not_called()