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
2 changes: 1 addition & 1 deletion docs/labels-and-capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ it implements multiple contracts (e.g. `tools/gmail` provides both
| [`tools/dev`](../tools/dev/) | `substrate:framework-dev` | Framework dev-loop helpers |
| [`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/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-read-only`. 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, read-only merge-check context fetching, and read-only status fetching, and Cloud-only issue listing/fetching and issue comment fetching. The `partial-read-only` qualifier means this tool implements named read-only contract operations but does not satisfy the complete contract and must not be counted as a complete/selectable backend. `contract:tracker` coverage is partial and limited to Cloud issue reads until Bitbucket issue writes or linked Jira handoff coverage exist. |
| [`tools/bitbucket`](../tools/bitbucket/) | `contract:change-request` + `contract:tracker` | Coverage: `partial-read-only`. 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, read-only merge-check context fetching, and read-only status fetching, plus Cloud-only issue listing/fetching, issue comment fetching, and issue attachment metadata fetching. The `partial-read-only` qualifier means this tool implements named read-only contract operations but does not satisfy the complete contract and must not be counted as a complete/selectable backend. `contract:tracker` coverage is partial and limited to Cloud issue reads until Bitbucket issue writes or linked Jira handoff coverage exist. |
| [`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` | 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/)) and the pull-request review/merge gate (`change-request`; the ASF default backend, alongside `tools/jira-patch/` and `tools/mail-patch/` for SVN-first projects) |
| [`tools/github-body-field`](../tools/github-body-field/) | `contract:tracker` | Read or rewrite one `### Field` section of a GitHub issue body without bringing the body into agent context — substrate helper for the security-sync skills |
Expand Down
27 changes: 16 additions & 11 deletions tools/bitbucket/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ that use Bitbucket as a forge, pull-request review surface, or Jira-paired Atlas

This initial bridge implements a `partial-read-only` profile for
repository metadata context and pull-request discovery/fetching under
`contract:change-request`, with partial Cloud-only issue reads under
`contract:change-request`, with partial Cloud-only issue reads, comment reads, and attachment metadata reads under
`contract:tracker`. Partial adapters may implement named
contract verbs, but they do not satisfy the complete contract and must
not be advertised as complete/selectable backends.
Expand Down Expand Up @@ -64,6 +64,7 @@ Implemented read-only commands:
- `magpie-bitbucket issue list-open`
- `magpie-bitbucket issue get <id>`
- `magpie-bitbucket issue comments <id>`
- `magpie-bitbucket issue attachments <id>`
- `magpie-bitbucket pr list-open`
- `magpie-bitbucket pr get <id>`
- `magpie-bitbucket pr commits <id>`
Expand Down Expand Up @@ -108,14 +109,15 @@ This implementation covers read-only operations:
4. **Cloud issue listing:** list open Bitbucket Cloud issues where the repository issue tracker is enabled.
5. **Cloud issue fetch:** fetch one Bitbucket Cloud issue as partial read-only tracker context.
6. **Cloud issue comments fetch:** fetch comments for one Bitbucket Cloud issue as partial read-only tracker context.
7. **Pull-request listing:** list open pull requests as `contract:change-request` proposal summaries.
8. **Pull-request fetch:** fetch one pull request as a normalized proposal object.
9. **Pull-request commits fetch:** fetch commits associated with a pull request as normalized read-only output.
10. **Pull-request diff fetch:** fetch the pull request unified diff as normalized read-only output.
11. **Pull-request discussion fetch:** fetch a comments-only pull request discussion subset as normalized read-only output.
12. **Pull-request review-state fetch:** fetch reviewers, approvals, change-request signals, pending review requests, and normalized review activity.
13. **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.
14. **Pull-request status fetch:** fetch build/status checks for the pull request as normalized read-only output.
7. **Cloud issue attachments fetch:** fetch attachment metadata and links for one Bitbucket Cloud issue as partial read-only tracker context; file contents are not downloaded.
8. **Pull-request listing:** list open pull requests as `contract:change-request` proposal summaries.
9. **Pull-request fetch:** fetch one pull request as a normalized proposal object.
10. **Pull-request commits fetch:** fetch commits associated with a pull request as normalized read-only output.
11. **Pull-request diff fetch:** fetch the pull request unified diff as normalized read-only output.
12. **Pull-request discussion fetch:** fetch a comments-only pull request discussion subset as normalized read-only output.
13. **Pull-request review-state fetch:** fetch reviewers, approvals, change-request signals, pending review requests, and normalized review activity.
14. **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.
15. **Pull-request status fetch:** fetch build/status checks for the pull request as normalized read-only output.

The bridge supports two Bitbucket API flavours behind one command
surface:
Expand All @@ -139,7 +141,7 @@ surface:
| Change requests | `post_review` | Not implemented | Follow-up work for #606. |
| Change requests | `land` | Not implemented | Follow-up work for #606. |
| Change requests | `reject` | Not implemented | Follow-up work for #606. |
| Tracker | `issue list-open` / `issue get <id>` / `issue comments <id>` | Partial read-only, Cloud only | Lists and fetches Bitbucket Cloud issues and issue comments where the repository issue tracker is enabled. Bitbucket Data Center native issue reads/comments are unsupported; linked Jira handoff remains separate follow-up work. |
| Tracker | `issue list-open` / `issue get <id>` / `issue comments <id>` / `issue attachments <id>` | Partial read-only, Cloud only | Lists and fetches Bitbucket Cloud issues, issue comments, and issue attachment metadata/links where the repository issue tracker is enabled. Bitbucket Data Center native issue reads/comments/attachments are unsupported; linked Jira handoff remains separate follow-up work. |
| CI | `pr status <id>` | Partial read-only | Fetches build/status checks for a pull request. This does not trigger, retry, or mutate Pipelines/builds. |

## Invocation
Expand All @@ -163,6 +165,9 @@ uv run --project tools/bitbucket magpie-bitbucket issue get 123
# Fetch Bitbucket Cloud issue comments
uv run --project tools/bitbucket magpie-bitbucket issue comments 123

# Fetch Bitbucket Cloud issue attachment metadata and links
uv run --project tools/bitbucket magpie-bitbucket issue attachments 123

# List open pull requests
uv run --project tools/bitbucket magpie-bitbucket pr list-open

Expand Down Expand Up @@ -214,7 +219,7 @@ injected by the caller as `BITBUCKET_TOKEN` / `BITBUCKET_CLOUD_USER`.
Every successful command emits JSON to stdout. Failures return a
non-zero exit code with a human-readable error on stderr.

Fetched repository branch restriction policy, branch matcher patterns, users, groups, access keys, issue titles/descriptions, issue comments, issue reporter/assignee/commenter names, issue links,
Fetched repository branch restriction policy, branch matcher patterns, users, groups, access keys, issue titles/descriptions, issue comments, attachment names, uploader names when present, attachment links, raw attachment payloads, issue reporter/assignee/commenter names, issue links,
pull request descriptions, commit messages, diff hunks, file paths, comments,
reviewer names, review decisions/events, approval/change-request activity,
merge-check decisions/blockers, status descriptions, CI URLs, and raw Bitbucket
Expand Down
13 changes: 13 additions & 0 deletions tools/bitbucket/src/magpie_bitbucket/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,15 @@ def _build_parser() -> argparse.ArgumentParser:
issue_comments = issue_subparsers.add_parser("comments", help="Fetch issue comments.")
issue_comments.add_argument("issue_id", help="Issue ID to fetch comments for.")

issue_attachments = issue_subparsers.add_parser(
"attachments",
help="Fetch issue attachment metadata and links, not file contents.",
)
issue_attachments.add_argument(
"issue_id",
help="Issue ID whose attachment metadata to fetch.",
)

pr_parser = subparsers.add_parser("pr", help="Interact with Bitbucket pull requests.")
pr_subparsers = pr_parser.add_subparsers(dest="pr_action", required=True)
pr_subparsers.add_parser("list-open", help="List open pull requests.")
Expand Down Expand Up @@ -124,6 +133,10 @@ def _dispatch(args: argparse.Namespace, config: BitbucketConfig) -> dict[str, An
raw = backend.get_issue_comments(config, args.issue_id)
return normalize.issue_comments(config.kind, raw)

if args.subcommand == "issue" and args.issue_action == "attachments":
raw = backend.get_issue_attachments(config, args.issue_id)
return normalize.issue_attachments(config.kind, raw)

if args.subcommand == "pr" and args.pr_action == "list-open":
raw = backend.list_open_pull_requests(config)
return normalize.pull_request_list(config.kind, raw)
Expand Down
28 changes: 28 additions & 0 deletions tools/bitbucket/src/magpie_bitbucket/cloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,34 @@ def get_issue_comments(config: BitbucketConfig, issue_id: str) -> dict[str, Any]
return combined


def get_issue_attachments(config: BitbucketConfig, issue_id: str) -> dict[str, Any]:
"""Fetch attachment metadata and links for a Bitbucket Cloud issue."""
workspace = quote_path(require(config.workspace, "BITBUCKET_WORKSPACE"))
repo_slug = quote_path(require(config.repo_slug, "BITBUCKET_REPO_SLUG"))
issue = quote_path(issue_id)
url = f"{CLOUD_API_BASE}/repositories/{workspace}/{repo_slug}/issues/{issue}/attachments"

combined: dict[str, Any] = {
"issue_id": issue_id,
"values": [],
"paginated": True,
"pages": [],
}

seen_urls = {url}
while url:
page = get_json(url, config)
combined["pages"].append(page)

values = page.get("values")
if isinstance(values, list):
combined["values"].extend(item for item in values if isinstance(item, dict))

url = _validated_next_url(page.get("next"), seen_urls)

return combined


def list_open_pull_requests(config: BitbucketConfig) -> dict[str, Any]:
"""List all open pull requests from Bitbucket Cloud."""
workspace = quote_path(require(config.workspace, "BITBUCKET_WORKSPACE"))
Expand Down
9 changes: 9 additions & 0 deletions tools/bitbucket/src/magpie_bitbucket/datacenter.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,15 @@ def get_issue_comments(config: BitbucketConfig, issue_id: str) -> dict[str, Any]
raise BitbucketError(msg)


def get_issue_attachments(config: BitbucketConfig, issue_id: str) -> dict[str, Any]:
"""Reject native Bitbucket issue attachment fetch for Data Center."""
_ = (config, issue_id)
msg = (
"Bitbucket Data Center native issue attachments are not supported; use linked Jira coverage instead."
)
raise BitbucketError(msg)


def list_open_pull_requests(config: BitbucketConfig) -> dict[str, Any]:
"""List all open pull requests from Bitbucket Data Center."""
project_key = quote_path(require(config.project_key, "BITBUCKET_PROJECT_KEY"))
Expand Down
55 changes: 55 additions & 0 deletions tools/bitbucket/src/magpie_bitbucket/normalize.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,27 @@ def issue_comments(kind: str, raw: dict[str, Any]) -> dict[str, Any]:
}


def issue_attachments(kind: str, raw: dict[str, Any]) -> dict[str, Any]:
"""Normalize read-only Bitbucket issue attachments."""
values = raw.get("values")
if not isinstance(values, list):
values = []

attachments = [
_cloud_issue_attachment(item) if kind == "cloud" else _unsupported_issue_attachment(item)
for item in values
if isinstance(item, dict)
]

return {
"backend": "bitbucket-cloud" if kind == "cloud" else "bitbucket-datacenter",
"coverage": "partial-read-only",
"issue_id": _string(raw.get("issue_id")),
"attachments": attachments,
"raw": raw,
}


def pull_request_summary(kind: str, raw: dict[str, Any]) -> dict[str, Any]:
"""Normalize one pull request as a read-only change-request summary."""
if kind == "cloud":
Expand Down Expand Up @@ -1046,6 +1067,40 @@ def _datacenter_inline(raw: object) -> dict[str, Any] | None:
return inline or None


def _cloud_issue_attachment(raw: dict[str, Any]) -> dict[str, Any]:
name = _string(raw.get("name") or raw.get("filename"))
size = raw.get("size")
normalized_size = size if isinstance(size, int) and not isinstance(size, bool) and size >= 0 else None

return {
"id": _string(raw.get("id") or raw.get("uuid")) or name,
"name": name,
"filename": _string(raw.get("filename") or raw.get("name")),
"size": normalized_size,
"user": _cloud_user(raw.get("user")),
"created": _cloud_timestamp(raw.get("created_on")),
"updated": _cloud_timestamp(raw.get("updated_on")),
"download_url": _cloud_link(raw, "self") or _cloud_link(raw, "html"),
"permalink": _cloud_link(raw, "html"),
"raw": raw,
}


def _unsupported_issue_attachment(raw: dict[str, Any]) -> dict[str, Any]:
return {
"id": _string(raw.get("id")),
"name": None,
"filename": None,
"size": None,
"user": None,
"created": None,
"updated": None,
"download_url": None,
"permalink": None,
"raw": raw,
}


def _cloud_issue_comment(raw: dict[str, Any]) -> dict[str, Any]:
body = _cloud_issue_content(raw.get("content"))
return {
Expand Down
112 changes: 112 additions & 0 deletions tools/bitbucket/tests/test_bitbucket.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from magpie_bitbucket.client import BitbucketError, SameHostRedirectHandler, load_config, make_auth_header
from magpie_bitbucket.normalize import (
issue,
issue_attachments,
issue_comments,
issue_list,
pull_request,
Expand Down Expand Up @@ -2239,3 +2240,114 @@ def test_cli_issue_comments_cloud(
assert output["backend"] == "bitbucket-cloud"
assert output["issue_id"] == "7"
assert output["comments"][0]["body"] == "Looks good."


@patch("urllib.request.build_opener")
def test_cloud_get_issue_attachments_follows_next(mock_build_opener: MagicMock, cloud_env: None) -> None:
opener = mock_opener(
mock_build_opener,
{
"values": [{"id": 201, "name": "screenshot.png"}],
"next": "https://api.bitbucket.org/2.0/repositories/apache/magpie/issues/7/attachments?page=2",
},
{"values": [{"id": 202, "name": "trace.log"}]},
)

result = cloud.get_issue_attachments(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/attachments"
)
assert second_request.full_url.endswith("/issues/7/attachments?page=2")
assert result["issue_id"] == "7"
assert result["values"] == [
{"id": 201, "name": "screenshot.png"},
{"id": 202, "name": "trace.log"},
]


def test_datacenter_get_issue_attachments_unsupported(datacenter_env: None) -> None:
with pytest.raises(BitbucketError, match="Data Center native issue attachments are not supported"):
datacenter.get_issue_attachments(load_config(), "7")


def test_normalize_cloud_issue_attachments() -> None:
normalized = issue_attachments(
"cloud",
{
"issue_id": "7",
"values": [
{
"id": 201,
"name": "screenshot.png",
"filename": "screenshot.png",
"size": 12345,
"user": {"display_name": "Alice"},
"created_on": "2026-07-01T00:00:00Z",
"updated_on": "2026-07-01T01:00:00Z",
"links": {
"self": {
"href": "https://api.bitbucket.org/2.0/repositories/apache/magpie/issues/7/attachments/201"
},
"html": {"href": "https://bitbucket.org/apache/magpie/issues/7/attachments/201"},
},
}
],
},
)

assert normalized["backend"] == "bitbucket-cloud"
assert normalized["coverage"] == "partial-read-only"
assert normalized["issue_id"] == "7"
assert normalized["attachments"][0]["id"] == "201"
assert normalized["attachments"][0]["name"] == "screenshot.png"
assert normalized["attachments"][0]["filename"] == "screenshot.png"
assert normalized["attachments"][0]["size"] == 12345
assert normalized["attachments"][0]["user"] == "Alice"
assert normalized["attachments"][0]["created"] == "2026-07-01T00:00:00Z"
assert normalized["attachments"][0]["download_url"].endswith("/attachments/201")

documented_payload = issue_attachments(
"cloud",
{
"issue_id": "7",
"values": [
{
"name": "documented.txt",
"links": {
"self": {
"href": "https://api.bitbucket.org/2.0/repositories/apache/magpie/issues/7/attachments/documented.txt"
}
},
}
],
},
)
assert documented_payload["attachments"][0]["id"] == "documented.txt"
assert documented_payload["attachments"][0]["name"] == "documented.txt"
assert documented_payload["attachments"][0]["filename"] == "documented.txt"
assert documented_payload["attachments"][0]["size"] is None
assert documented_payload["attachments"][0]["download_url"].endswith("/attachments/documented.txt")


@patch("magpie_bitbucket.cloud.get_issue_attachments")
def test_cli_issue_attachments_cloud(
mock_get_issue_attachments: MagicMock,
cloud_env: None,
capsys: pytest.CaptureFixture[str],
) -> None:
mock_get_issue_attachments.return_value = {
"issue_id": "7",
"values": [{"id": 201, "name": "screenshot.png", "user": {"display_name": "Alice"}}],
}

exit_code = main(["issue", "attachments", "7"])

assert exit_code == 0
mock_get_issue_attachments.assert_called_once_with(load_config(), "7")
output = json.loads(capsys.readouterr().out)
assert output["backend"] == "bitbucket-cloud"
assert output["issue_id"] == "7"
assert output["attachments"][0]["name"] == "screenshot.png"
Loading