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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions tools/bitbucket/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ Implemented read-only commands:
- `magpie-bitbucket pr commits <id>`
- `magpie-bitbucket pr diff <id>`
- `magpie-bitbucket pr discussion <id>`
- `magpie-bitbucket pr comment <id> --body-file <path>` (Cloud-only write)
- `magpie-bitbucket pr reviews <id>`
- `magpie-bitbucket pr tasks <id>`
- `magpie-bitbucket pr task <id> <task-id>`
Expand Down Expand Up @@ -142,6 +143,7 @@ surface:
| Change requests | `commits[]` supplement / `pr commits <id>` | 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 <id>` | 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 <id>` | 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 <id> --body-file <path>` | 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 <id>` | 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 <id>` | 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. |
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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.
23 changes: 23 additions & 0 deletions tools/bitbucket/src/magpie_bitbucket/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")

Expand Down Expand Up @@ -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)
Expand Down
22 changes: 22 additions & 0 deletions tools/bitbucket/src/magpie_bitbucket/cloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
11 changes: 11 additions & 0 deletions tools/bitbucket/src/magpie_bitbucket/datacenter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
18 changes: 18 additions & 0 deletions tools/bitbucket/src/magpie_bitbucket/normalize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
165 changes: 165 additions & 0 deletions tools/bitbucket/tests/test_bitbucket.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
)
from magpie_bitbucket.normalize import (
created_issue_comment,
created_pull_request_comment,
issue,
issue_attachments,
issue_comments,
Expand Down Expand Up @@ -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()