From b35ff35ea85e7e055c01e7fdc6974f1791f563d3 Mon Sep 17 00:00:00 2001 From: Kavya Katal Date: Mon, 10 Aug 2026 10:33:01 +0530 Subject: [PATCH] feat(bitbucket): add guarded cloud issue comment create --- docs/labels-and-capabilities.md | 2 +- tools/bitbucket/README.md | 69 +++++--- tools/bitbucket/src/magpie_bitbucket/cli.py | 38 +++- .../bitbucket/src/magpie_bitbucket/client.py | 45 +++++ tools/bitbucket/src/magpie_bitbucket/cloud.py | 32 +++- .../src/magpie_bitbucket/datacenter.py | 14 ++ .../src/magpie_bitbucket/normalize.py | 15 ++ tools/bitbucket/tests/test_bitbucket.py | 165 ++++++++++++++++++ tools/spec-loop/specs/adapters.md | 17 +- 9 files changed, 362 insertions(+), 35 deletions(-) diff --git a/docs/labels-and-capabilities.md b/docs/labels-and-capabilities.md index f8977022..aa0031a7 100644 --- a/docs/labels-and-capabilities.md +++ b/docs/labels-and-capabilities.md @@ -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, Cloud-only pull-request task listing/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/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 Cloud-only issue listing/fetching, issue comment fetching, issue attachment metadata fetching, and narrowly scoped issue comment creation. 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. `contract:tracker` coverage remains partial: Cloud issue reads and confirmed issue-comment creation are supported, while broader issue writes and linked Jira handoff coverage remain incomplete. | | [`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 | diff --git a/tools/bitbucket/README.md b/tools/bitbucket/README.md index 3ccf297e..431b8902 100644 --- a/tools/bitbucket/README.md +++ b/tools/bitbucket/README.md @@ -6,7 +6,7 @@ **Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)* - [Bitbucket forge bridge](#bitbucket-forge-bridge) - - [Partial read-only roadmap](#partial-read-only-roadmap) + - [Partial coverage roadmap](#partial-coverage-roadmap) - [Prerequisites](#prerequisites) - [Features](#features) - [Operation coverage](#operation-coverage) @@ -25,7 +25,7 @@ **Capability:** contract:change-request + contract:tracker -**Coverage:** `partial-read-only` +**Coverage:** `partial` **Kind:** implementation @@ -34,7 +34,7 @@ Bitbucket Cloud and Bitbucket Data Center bridge for Magpie adopters that use Bitbucket as a forge, pull-request review surface, or Jira-paired Atlassian backend. -This initial bridge implements a `partial-read-only` profile for +This bridge implements a `partial` profile for repository metadata context and pull-request discovery/fetching under `contract:change-request`, with partial Cloud-only issue reads, comment reads, and attachment metadata reads under `contract:tracker`. Partial adapters may implement named @@ -43,18 +43,18 @@ not be advertised as complete/selectable backends. Repository metadata reads are currently bridge context for Bitbucket pull-request workflows, not a complete `contract:source-control` -backend. `contract:tracker` coverage is partial and currently limited to +backend. `contract:tracker` coverage is partial and currently includes Bitbucket Cloud issue listing/fetching where the repository issue tracker is enabled. #606 remains open for the remaining Bitbucket/Jira workflow coverage. Later PRs can extend the same adapter with write operations, linked Jira handoff, issue write operations, and fuller Pipelines run/log/retry coverage. -## Partial read-only roadmap +## Partial coverage roadmap -The Bitbucket bridge currently provides partial read-only coverage for -repository and pull-request review context. It intentionally does not claim -full Bitbucket backend parity. +The Bitbucket bridge currently provides partial coverage for repository, +tracker, and pull-request context, plus one narrowly scoped Bitbucket Cloud +tracker write. It intentionally does not claim full Bitbucket backend parity. Implemented read-only commands: @@ -64,6 +64,7 @@ Implemented read-only commands: - `magpie-bitbucket issue list-open` - `magpie-bitbucket issue get ` - `magpie-bitbucket issue comments ` +- `magpie-bitbucket issue comment --body-file ` (Cloud-only write) - `magpie-bitbucket issue attachments ` - `magpie-bitbucket pr list-open` - `magpie-bitbucket pr get ` @@ -87,11 +88,12 @@ The `pr reviews` command provides partial read-only review-state coverage, including reviewers, approvals, change-request signals, and related review activity where exposed by the configured Bitbucket backend. -Write operations are intentionally out of scope for the current bridge -coverage. Future write support, such as commenting, approving, declining, -merging, creating issues, changing branches, or triggering builds, should be -designed separately with explicit human-in-the-loop approval, narrow command -surfaces, and maintainer review. +Write coverage is intentionally narrow. The bridge can create one +Bitbucket Cloud issue comment after the calling skill has obtained explicit +user confirmation. Other writes, such as editing/deleting comments, approving, +declining, merging, creating/updating issues, changing branches, or triggering +builds, remain out of scope and should be added separately with narrow command +surfaces and maintainer review. ## Prerequisites @@ -103,7 +105,7 @@ surfaces, and maintainer review. ## Features -This implementation covers read-only operations: +This implementation primarily covers read-only operations, plus one narrow Cloud issue-comment write: 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. @@ -112,15 +114,16 @@ This implementation covers read-only operations: 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. **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 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. +8. **Cloud issue comment create:** create one Bitbucket Cloud issue comment from a confirmed body file; the calling skill is responsible for explicit user confirmation before invoking the bridge. +9. **Pull-request listing:** list open pull requests as `contract:change-request` proposal summaries. +10. **Pull-request fetch:** fetch one pull request as a normalized proposal object. +11. **Pull-request commits fetch:** fetch commits associated with a pull request as normalized read-only output. +12. **Pull-request diff fetch:** fetch the pull request unified diff as normalized read-only output. +13. **Pull-request discussion fetch:** fetch a comments-only pull request discussion subset as normalized read-only output. +14. **Pull-request review-state fetch:** fetch reviewers, approvals, change-request signals, pending review requests, and normalized review activity. +15. **Pull-request tasks fetch:** list Bitbucket Cloud pull-request tasks and fetch one task as partial read-only change-request context. +16. **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. +17. **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: @@ -145,6 +148,7 @@ surface: | 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 ` / `issue comments ` / `issue attachments ` | 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. | +| Tracker | `issue comment --body-file ` | Partial write, Cloud only | Creates one Bitbucket Cloud issue comment from a caller-supplied body file. The calling skill must obtain explicit user confirmation before invoking this mutation. Bitbucket Data Center native issue comment writes are unsupported; linked Jira coverage remains separate. | | CI | `pr status ` | Partial read-only | Fetches build/status checks for a pull request. This does not trigger, retry, or mutate Pipelines/builds. | ## Invocation @@ -168,6 +172,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 +# Create a Bitbucket Cloud issue comment after caller-side confirmation +uv run --project tools/bitbucket magpie-bitbucket issue comment 123 --body-file /tmp/comment.txt + # Fetch Bitbucket Cloud issue attachment metadata and links uv run --project tools/bitbucket magpie-bitbucket issue attachments 123 @@ -215,7 +222,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. Most read-only PR/repository commands should use minimum read scopes. `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. The Cloud issue-comment write requires credentials permitted to write issue comments. `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. | @@ -228,7 +235,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, attachment names, uploader names when present, attachment links, raw attachment payloads, issue reporter/assignee/commenter names, issue links, +Fetched repository branch restriction policy, branch matcher patterns, users, groups, access keys, issue titles/descriptions, fetched or created 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 @@ -242,7 +249,17 @@ to know which backend answered. ## Write-path discipline -This initial bridge is read-only. +The bridge can execute the narrowly scoped Bitbucket Cloud issue-comment +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. +Missing or empty body files fail before any outbound write request is made. +Bitbucket Data Center native issue-comment writes remain unsupported. + +All other Bitbucket mutations remain out of scope for the current bridge and +must be introduced separately with the same confirmation discipline. Future write commands will follow the same discipline as the GitHub and Jira tools: the bridge may execute a mutation, but it must not decide diff --git a/tools/bitbucket/src/magpie_bitbucket/cli.py b/tools/bitbucket/src/magpie_bitbucket/cli.py index 1ed085bc..5854c5f5 100644 --- a/tools/bitbucket/src/magpie_bitbucket/cli.py +++ b/tools/bitbucket/src/magpie_bitbucket/cli.py @@ -22,10 +22,27 @@ import argparse import json from collections.abc import Sequence +from pathlib import Path from typing import Any from magpie_bitbucket import cloud, datacenter, normalize -from magpie_bitbucket.client import BitbucketConfig, load_config +from magpie_bitbucket.client import BitbucketConfig, BitbucketError, load_config + + +def _read_body_file(value: str) -> str: + """Read a confirmed write body from disk.""" + path = Path(value) + try: + body = path.read_text(encoding="utf-8") + except FileNotFoundError as exc: + raise BitbucketError(f"Body file not found: {path}") from exc + except OSError as exc: + raise BitbucketError(f"Failed to read body file {path}: {exc}") from exc + + if not body.strip(): + raise BitbucketError("Comment body file must not be empty") + + return body def main(argv: Sequence[str] | None = None) -> int: @@ -64,6 +81,20 @@ 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_comment = issue_subparsers.add_parser( + "comment", + help="Create a comment on an issue after caller-side confirmation.", + ) + issue_comment.add_argument( + "issue_id", + help="Issue ID to comment on.", + ) + issue_comment.add_argument( + "--body-file", + required=True, + help="Path to the confirmed comment body.", + ) + issue_attachments = issue_subparsers.add_parser( "attachments", help="Fetch issue attachment metadata and links, not file contents.", @@ -140,6 +171,11 @@ 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 == "comment": + body = _read_body_file(args.body_file) + raw = backend.create_issue_comment(config, args.issue_id, body) + return normalize.created_issue_comment(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) diff --git a/tools/bitbucket/src/magpie_bitbucket/client.py b/tools/bitbucket/src/magpie_bitbucket/client.py index 85ae7f5a..57dfdc14 100644 --- a/tools/bitbucket/src/magpie_bitbucket/client.py +++ b/tools/bitbucket/src/magpie_bitbucket/client.py @@ -189,6 +189,51 @@ def get_json(url: str, config: BitbucketConfig) -> dict[str, Any]: raise BitbucketError(f"Failed to parse JSON response from {url}") from exc +def post_json( + url: str, + config: BitbucketConfig, + payload: dict[str, Any], +) -> dict[str, Any]: + """POST JSON to a Bitbucket API URL and parse the JSON response.""" + _require_https(url) + data = json.dumps(payload).encode("utf-8") + request = urllib.request.Request( + url, + data=data, + headers={ + "Accept": "application/json", + "Authorization": make_auth_header(config), + "Content-Type": "application/json", + }, + method="POST", + ) + + # Writes never follow redirects: repeating a mutation at a redirected + # location is less safe than failing and requiring the caller to retry. + 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 + 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 get_text(url: str, config: BitbucketConfig, accept: str = "text/plain") -> dict[str, Any]: """GET a Bitbucket API URL and return a text response with metadata.""" _require_https(url) diff --git a/tools/bitbucket/src/magpie_bitbucket/cloud.py b/tools/bitbucket/src/magpie_bitbucket/cloud.py index 073105ab..f3423cc3 100644 --- a/tools/bitbucket/src/magpie_bitbucket/cloud.py +++ b/tools/bitbucket/src/magpie_bitbucket/cloud.py @@ -22,7 +22,15 @@ from typing import Any from urllib.parse import urlparse -from magpie_bitbucket.client import BitbucketConfig, BitbucketError, get_json, get_text, quote_path, require +from magpie_bitbucket.client import ( + BitbucketConfig, + BitbucketError, + get_json, + get_text, + post_json, + quote_path, + require, +) CLOUD_API_BASE = "https://api.bitbucket.org/2.0" @@ -115,6 +123,28 @@ def get_issue(config: BitbucketConfig, issue_id: str) -> dict[str, Any]: return get_json(url, config) +def create_issue_comment( + config: BitbucketConfig, + issue_id: str, + body: str, +) -> dict[str, Any]: + """Create one comment on 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}/comments" + + comment = post_json( + url, + config, + {"content": {"raw": body}}, + ) + return { + "issue_id": issue_id, + "comment": comment, + } + + def get_issue_comments(config: BitbucketConfig, issue_id: str) -> dict[str, Any]: """Fetch comments for a Bitbucket Cloud issue.""" 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 4a93ee5c..35f04d51 100644 --- a/tools/bitbucket/src/magpie_bitbucket/datacenter.py +++ b/tools/bitbucket/src/magpie_bitbucket/datacenter.py @@ -93,6 +93,20 @@ def get_issue(config: BitbucketConfig, issue_id: str) -> dict[str, Any]: raise BitbucketError(msg) +def create_issue_comment( + config: BitbucketConfig, + issue_id: str, + body: str, +) -> dict[str, Any]: + """Reject native Bitbucket issue comment creation for Data Center.""" + _ = (config, issue_id, body) + msg = ( + "Bitbucket Data Center native issue comment writes are not supported; " + "use linked Jira coverage instead." + ) + raise BitbucketError(msg) + + def get_issue_comments(config: BitbucketConfig, issue_id: str) -> dict[str, Any]: """Reject native Bitbucket issue comment fetch for Data Center.""" _ = (config, issue_id) diff --git a/tools/bitbucket/src/magpie_bitbucket/normalize.py b/tools/bitbucket/src/magpie_bitbucket/normalize.py index d14e241f..1892bb02 100644 --- a/tools/bitbucket/src/magpie_bitbucket/normalize.py +++ b/tools/bitbucket/src/magpie_bitbucket/normalize.py @@ -143,6 +143,21 @@ def issue_list(kind: str, raw: dict[str, Any]) -> dict[str, Any]: } +def created_issue_comment(kind: str, raw: dict[str, Any]) -> dict[str, Any]: + """Normalize the result of creating one Bitbucket issue comment.""" + comment = raw.get("comment") + normalized = _cloud_issue_comment(comment) if kind == "cloud" and isinstance(comment, dict) else {} + + return { + "ok": bool(normalized), + "backend": "bitbucket-cloud" if kind == "cloud" else "bitbucket-datacenter", + "operation": "issue-comment-create", + "issue_id": _string(raw.get("issue_id")), + "comment": normalized, + "raw": raw, + } + + def issue_comments(kind: str, raw: dict[str, Any]) -> dict[str, Any]: """Normalize read-only Bitbucket issue comments.""" values = raw.get("values") diff --git a/tools/bitbucket/tests/test_bitbucket.py b/tools/bitbucket/tests/test_bitbucket.py index 42886169..8d518a7d 100644 --- a/tools/bitbucket/tests/test_bitbucket.py +++ b/tools/bitbucket/tests/test_bitbucket.py @@ -19,6 +19,7 @@ import json import urllib.request +from pathlib import Path from typing import Any from unittest.mock import MagicMock, patch @@ -28,6 +29,7 @@ from magpie_bitbucket.cli import main from magpie_bitbucket.client import BitbucketError, SameHostRedirectHandler, load_config, make_auth_header from magpie_bitbucket.normalize import ( + created_issue_comment, issue, issue_attachments, issue_comments, @@ -2579,3 +2581,166 @@ def test_cli_pr_task_cloud( output = json.loads(capsys.readouterr().out) assert output["pull_request_id"] == "7" assert output["task"]["id"] == "11" + + +@patch("urllib.request.build_opener") +def test_cloud_create_issue_comment_posts_json( + mock_build_opener: MagicMock, + cloud_env: None, +) -> None: + opener = mock_opener( + mock_build_opener, + { + "id": 501, + "content": {"raw": "Confirmed comment."}, + "user": {"display_name": "Alice"}, + "deleted": False, + }, + ) + + result = cloud.create_issue_comment( + load_config(), + "7", + "Confirmed comment.", + ) + + request = opener.open.call_args.args[0] + assert request.full_url == ("https://api.bitbucket.org/2.0/repositories/apache/magpie/issues/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 comment."}} + assert result["issue_id"] == "7" + assert result["comment"]["id"] == 501 + + +def test_datacenter_create_issue_comment_unsupported( + datacenter_env: None, +) -> None: + with pytest.raises( + BitbucketError, + match="Data Center native issue comment writes are not supported", + ): + datacenter.create_issue_comment( + load_config(), + "7", + "Confirmed comment.", + ) + + +def test_normalize_created_cloud_issue_comment() -> None: + normalized = created_issue_comment( + "cloud", + { + "issue_id": "7", + "comment": { + "id": 501, + "content": {"raw": "Confirmed comment."}, + "user": {"display_name": "Alice"}, + "created_on": "2026-08-10T00:00:00Z", + "deleted": False, + "links": {"html": {"href": ("https://bitbucket.org/apache/magpie/issues/7#comment-501")}}, + }, + }, + ) + + assert normalized["ok"] is True + assert normalized["backend"] == "bitbucket-cloud" + assert normalized["operation"] == "issue-comment-create" + assert normalized["issue_id"] == "7" + assert normalized["comment"]["id"] == "501" + assert normalized["comment"]["author"] == "Alice" + assert normalized["comment"]["body"] == "Confirmed comment." + + +@patch("magpie_bitbucket.cloud.create_issue_comment") +def test_cli_issue_comment_cloud( + mock_create_issue_comment: MagicMock, + cloud_env: None, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + body_file = tmp_path / "comment.txt" + body_file.write_text("Confirmed comment.", encoding="utf-8") + + mock_create_issue_comment.return_value = { + "issue_id": "7", + "comment": { + "id": 501, + "content": {"raw": "Confirmed comment."}, + "user": {"display_name": "Alice"}, + "deleted": False, + }, + } + + exit_code = main( + [ + "issue", + "comment", + "7", + "--body-file", + str(body_file), + ] + ) + + assert exit_code == 0 + mock_create_issue_comment.assert_called_once_with( + load_config(), + "7", + "Confirmed comment.", + ) + + output = json.loads(capsys.readouterr().out) + assert output["ok"] is True + assert output["operation"] == "issue-comment-create" + assert output["comment"]["id"] == "501" + + +@patch("magpie_bitbucket.cloud.create_issue_comment") +def test_cli_issue_comment_rejects_empty_body_before_write( + mock_create_issue_comment: MagicMock, + cloud_env: None, + tmp_path: Path, +) -> None: + body_file = tmp_path / "empty.txt" + body_file.write_text(" ", encoding="utf-8") + + with pytest.raises( + BitbucketError, + match="Comment body file must not be empty", + ): + main( + [ + "issue", + "comment", + "7", + "--body-file", + str(body_file), + ] + ) + + mock_create_issue_comment.assert_not_called() + + +@patch("magpie_bitbucket.cloud.create_issue_comment") +def test_cli_issue_comment_missing_body_file_before_write( + mock_create_issue_comment: MagicMock, + cloud_env: None, + tmp_path: Path, +) -> None: + missing = tmp_path / "missing.txt" + + with pytest.raises( + BitbucketError, + match="Body file not found", + ): + main( + [ + "issue", + "comment", + "7", + "--body-file", + str(missing), + ] + ) + + mock_create_issue_comment.assert_not_called() diff --git a/tools/spec-loop/specs/adapters.md b/tools/spec-loop/specs/adapters.md index 48d2c710..56920327 100644 --- a/tools/spec-loop/specs/adapters.md +++ b/tools/spec-loop/specs/adapters.md @@ -47,17 +47,18 @@ by swapping the adapter, not the skill. Sub-adapters: `tools/github-body-field/` (reads GitHub issue/PR body structured field sets) and `tools/github-rollup/` (aggregates multi-repo PR state into a single view). -- `tools/bitbucket/` — initial read-only Bitbucket Cloud and Bitbucket +- `tools/bitbucket/` — partial Bitbucket Cloud and Bitbucket Data Center bridge foundation. Supports repository metadata reads, - read-only branch restriction context, Cloud-only issue listing/fetching, issue comment fetching, and issue attachment metadata fetching, + read-only branch restriction context, Cloud-only issue listing/fetching, issue comment fetching, issue attachment metadata fetching, and confirmed issue comment creation, open pull-request listing, single pull-request fetching, read-only pull-request commit fetching, read-only pull-request diff fetching, comments-only pull-request discussion fetching, read-only pull-request review-state fetching, read-only merge-check context fetching, and read-only pull-request status fetching behind one CLI surface. It is not a complete `contract:change-request` or `contract:tracker` backend yet; - deeper Jira handoff, issue writes, review/merge writes, broader repository - permissions, and fuller Pipelines run/log/retry coverage remain tracked in #606. + deeper Jira handoff, broader issue writes, review/merge writes, broader + repository permissions, and fuller Pipelines run/log/retry coverage remain + tracked in #606. - `tools/sourcehut/` — SourceHut (sr.ht) forge bridge: ticket tracking (`todo.sr.ht`), mailing-list patchset review (`lists.sr.ht`), CI build status (`builds.sr.ht`), and repository reads (`git.sr.ht`/`hg.sr.ht`) @@ -152,9 +153,13 @@ uv run --project tools/vcs --group dev pytest || echo "check tools/vcs test setu context, pull-request discovery, pull-request fetching, read-only pull-request commit fetching, read-only pull-request diff fetching, comments-only pull-request discussion fetching, read-only review-state fetching, Cloud-only pull-request task listing/fetching, read-only merge-check - context fetching, read-only pull-request status fetching, and Cloud-only issue listing/fetching, issue comment fetching, and issue attachment metadata fetching; + context fetching, read-only pull-request status fetching, and Cloud-only issue listing/fetching, issue comment fetching, issue attachment metadata fetching, and confirmed issue comment creation; #606 remains open for full tracker/change-request coverage. -- Fetched Bitbucket descriptions, issue titles/descriptions, issue comments, attachment names, uploader names when present, attachment links, raw attachment payloads, issue reporter/assignee/commenter names, issue links, branch restriction policy, commit messages, diff hunks, file paths, comments, pull-request task content, task creator/resolver names, reviewer names, review decisions/events, approval/change-request activity, merge-check decisions/blockers, status descriptions, +- Bitbucket write operations follow the framework write-path discipline: + the calling skill must obtain explicit user confirmation before invoking a + mutation. The bridge executes only the confirmed action; current write + coverage is limited to Bitbucket Cloud issue-comment creation. +- Fetched Bitbucket descriptions, issue titles/descriptions, fetched or created issue comments, attachment names, uploader names when present, attachment links, raw attachment payloads, issue reporter/assignee/commenter names, issue links, branch restriction policy, commit messages, diff hunks, file paths, comments, pull-request task content, task creator/resolver names, reviewer names, review decisions/events, approval/change-request activity, merge-check decisions/blockers, status descriptions, CI URLs, and raw payloads are external data, never agent instructions; private or embargoed content must follow the approved-LLM/privacy gate before model use.