diff --git a/.claude/settings.json b/.claude/settings.json index 29097932..72d45cf0 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -30,6 +30,7 @@ "allowedDomains": [ "github.com", "api.github.com", + "api.bitbucket.org", "raw.githubusercontent.com", "objects.githubusercontent.com", "codeload.github.com", diff --git a/docs/adapters/registry.md b/docs/adapters/registry.md index dc800900..0d167bbf 100644 --- a/docs/adapters/registry.md +++ b/docs/adapters/registry.md @@ -51,7 +51,7 @@ extension point = a documented, labelled slot with a tracking issue. | [`tools/forwarder-relay`](../../tools/forwarder-relay/) | ASF-security ([`tools/gmail/asf-relay.md`](../../tools/gmail/asf-relay.md)) | huntr.com, HackerOne, GHSA relay | | [`tools/scan-format`](../../tools/scan-format/) | ASVS | other scanner formats | | [`tools/vcs`](../../tools/vcs/) | Git, Mercurial | Subversion [\#602](https://github.com/apache/magpie/issues/602), Jujutsu [\#603](https://github.com/apache/magpie/issues/603), Fossil [\#604](https://github.com/apache/magpie/issues/604), Perforce [\#605](https://github.com/apache/magpie/issues/605) | -| Forge / tracker | [`github`](../../tools/github/), [`jira`](../../tools/jira/), [`sourcehut`](../../tools/sourcehut/) | GitLab [\#305](https://github.com/apache/magpie/issues/305), Forgejo/Gitea [\#310](https://github.com/apache/magpie/issues/310), Pagure [\#312](https://github.com/apache/magpie/issues/312), Bitbucket [\#606](https://github.com/apache/magpie/issues/606), Bugzilla [\#302](https://github.com/apache/magpie/issues/302) | +| Forge / tracker | [`github`](../../tools/github/), [`jira`](../../tools/jira/), [`bitbucket`](../../tools/bitbucket/) read-only foundation, [`sourcehut`](../../tools/sourcehut/) | GitLab [\#305](https://github.com/apache/magpie/issues/305), Forgejo/Gitea [\#310](https://github.com/apache/magpie/issues/310), Pagure [\#312](https://github.com/apache/magpie/issues/312), deeper Bitbucket/Jira coverage [\#606](https://github.com/apache/magpie/issues/606), Bugzilla [\#302](https://github.com/apache/magpie/issues/302) | | Agentic runtime | Claude Code | Codex [#313](https://github.com/apache/magpie/issues/313)–OpenHands [#322](https://github.com/apache/magpie/issues/322) | | Security cross-ref | — | OSV.dev [#311](https://github.com/apache/magpie/issues/311) | diff --git a/docs/labels-and-capabilities.md b/docs/labels-and-capabilities.md index 261f5e42..2a1012aa 100644 --- a/docs/labels-and-capabilities.md +++ b/docs/labels-and-capabilities.md @@ -253,6 +253,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` | Bitbucket Cloud and Bitbucket Data Center read-only bridge foundation for repository metadata and pull-request discovery/fetching. Future PRs will add Bitbucket Issues / Jira handoff coverage before claiming tracker completeness. | | [`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 | | [`tools/github-rollup`](../tools/github-rollup/) | `contract:tracker` | Append to (or create) the status-rollup comment on a GitHub issue without bringing the rollup body into agent context — substrate helper for every status-update-emitting skill | diff --git a/docs/vendor-neutrality.md b/docs/vendor-neutrality.md index ccb69fab..da8a316c 100644 --- a/docs/vendor-neutrality.md +++ b/docs/vendor-neutrality.md @@ -345,8 +345,9 @@ issue`, not hypothetical: [Codeberg / Gitea / Forgejo](https://github.com/apache/magpie/issues/310), [Pagure](https://github.com/apache/magpie/issues/312) (Fedora / `pagure.io`), -[Bitbucket](https://github.com/apache/magpie/issues/606) (deep Jira -pairing), and +[Bitbucket](https://github.com/apache/magpie/issues/606) (initial +[`tools/bitbucket`](../tools/bitbucket/) bridge; deeper Jira pairing and +write coverage tracked there), and [SourceHut](https://github.com/apache/magpie/issues/607) (email-patch review). Tracker-only surfaces are tracked the same way — e.g. [Bugzilla](https://github.com/apache/magpie/issues/302) — alongside the @@ -484,7 +485,7 @@ coverage without pretending one team can implement an open-ended set. |---|---|---|---| | LLM backend | ✅ by construction | Claude Code, Ollama, vLLM, Apache-hosted, Bedrock, direct Anthropic | Any endpoint meeting the capability floor + privacy gate | | Agentic runtime | ✅ by construction (`AGENTS.md` standard) | Claude Code; community use under Codex, Cursor, Gemini CLI, Copilot, OpenCode, Kiro | Runtime adapters [#313–#322](https://github.com/apache/magpie/issues?q=is%3Aissue+state%3Aopen+adapter+in%3Atitle) | -| Forge / tracker | ✅ by construction | GitHub, Jira, SourceHut; CVE/scan/relay via adapter contracts | GitLab [#305](https://github.com/apache/magpie/issues/305), Forgejo/Gitea [#310](https://github.com/apache/magpie/issues/310), Pagure [#312](https://github.com/apache/magpie/issues/312), Bitbucket [#606](https://github.com/apache/magpie/issues/606), Bugzilla [#302](https://github.com/apache/magpie/issues/302) | +| Forge / tracker | ✅ by construction | GitHub, Jira, SourceHut; Bitbucket read-only foundation; CVE/scan/relay via adapter contracts | GitLab [#305](https://github.com/apache/magpie/issues/305), Forgejo/Gitea [#310](https://github.com/apache/magpie/issues/310), Pagure [#312](https://github.com/apache/magpie/issues/312), full Bitbucket tracker/change-request/Jira coverage [#606](https://github.com/apache/magpie/issues/606), Bugzilla [#302](https://github.com/apache/magpie/issues/302) | | Communication channels | ✅ by construction | PonyMail / mail-archive reads | mbox [#304](https://github.com/apache/magpie/issues/304), IMAP [#303](https://github.com/apache/magpie/issues/303), Mailman 3 [#306](https://github.com/apache/magpie/issues/306); Discourse [#307](https://github.com/apache/magpie/issues/307), Zulip [#308](https://github.com/apache/magpie/issues/308), Matrix [#309](https://github.com/apache/magpie/issues/309) | | Source control (VCS) | ✅ by construction | **Git (complete)**, **Mercurial (complete)**; ASF SVN surface ([`tools/asf-svn`](../tools/asf-svn/): source control + dist.apache.org + authorization) | Subversion generic VCS binding [\#602](https://github.com/apache/magpie/issues/602) (detected); Jujutsu [\#603](https://github.com/apache/magpie/issues/603), Fossil [\#604](https://github.com/apache/magpie/issues/604), Perforce [\#605](https://github.com/apache/magpie/issues/605) (tracked) | | Project governance | ✅ by construction | ASF + non-ASF adopter profiles | Adopter config (modes, thresholds) | diff --git a/pyproject.toml b/pyproject.toml index d355d382..73b47888 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,6 +96,7 @@ package = false members = [ "tools/agent-guard", "tools/agent-isolation", + "tools/bitbucket", "tools/egress-gateway", "tools/cve-tool-vulnogram/generate-cve-json", "tools/cve-tool-vulnogram/oauth-api", diff --git a/tools/bitbucket/README.md b/tools/bitbucket/README.md new file mode 100644 index 00000000..24c0c382 --- /dev/null +++ b/tools/bitbucket/README.md @@ -0,0 +1,120 @@ + + +**Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)* + +- [Bitbucket forge bridge](#bitbucket-forge-bridge) + - [Prerequisites](#prerequisites) + - [Features](#features) + - [Invocation](#invocation) + - [Configuration](#configuration) + - [Output contract](#output-contract) + - [Write-path discipline](#write-path-discipline) + - [Planned follow-up coverage](#planned-follow-up-coverage) + + + + + +# Bitbucket forge bridge + +**Capability:** contract:change-request + +**Kind:** implementation + +**Vendor:** Atlassian + +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 provides a read-only foundation for repository +metadata and pull-request discovery/fetching. It is not a complete +`contract:change-request` backend yet; #606 remains open for the +remaining Bitbucket/Jira workflow coverage. Later PRs can extend the +same adapter with write operations, Bitbucket Issues, linked Jira +handoff, branch permissions, and Pipelines status reads. + +## Prerequisites + +- **Runtime:** Python 3.11+ run via `uv`; the bridge uses the Python standard library at runtime. +- **CLIs:** `uv` to run the bridge and its tests; no Bitbucket-specific CLI is required. +- **Credentials / auth:** `BITBUCKET_TOKEN` is required for authenticated Bitbucket API calls. Bitbucket Cloud also needs `BITBUCKET_CLOUD_USER`; Data Center uses `BITBUCKET_AUTH_SCHEME=Bearer` by default. +- **Network:** Bitbucket Cloud reaches `api.bitbucket.org`; Bitbucket Data Center reaches the configured `BITBUCKET_BASE_URL`. Adopters using Data Center must explicitly allow their own Bitbucket host in the secure egress configuration. +- **Optional:** `pytest`, `ruff`, and `mypy` run through `uv` for the test/type/lint harness. + +## Features + +This first implementation covers read-only 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. +3. **Pull-request listing:** list open pull requests as `contract:change-request` proposal summaries. +4. **Pull-request fetch:** fetch one pull request as a normalized proposal object. + +The bridge supports two Bitbucket API flavours behind one command +surface: + +- `BITBUCKET_KIND=cloud` +- `BITBUCKET_KIND=datacenter` + +## Invocation + +```bash +# Verify Bitbucket configuration and credentials +uv run --project tools/bitbucket magpie-bitbucket auth-check + +# Fetch repository metadata +uv run --project tools/bitbucket magpie-bitbucket repo get + +# List open pull requests +uv run --project tools/bitbucket magpie-bitbucket pr list-open + +# Fetch one pull request +uv run --project tools/bitbucket magpie-bitbucket pr get 123 +``` + +## Configuration + +The bridge is configured through environment variables. The calling +skill resolves adopter project configuration and exports these values; +the bridge does not read `/` files directly. + +| 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. For Bitbucket Cloud, use minimum read scopes for repositories and pull requests. | +| `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. | +| `BITBUCKET_REPO_SLUG` | Cloud and Data Center | Repository slug. | +| `BITBUCKET_BASE_URL` | Data Center | Base URL of the Bitbucket Data Center instance. | +| `BITBUCKET_PROJECT_KEY` | Data Center | Data Center project key. | + +## Output contract + +Every successful command emits JSON to stdout. Failures return a +non-zero exit code with a human-readable error on stderr. + +The bridge normalizes Bitbucket Cloud and Data Center responses into +stable fields before emitting output, so consuming skills do not need +to know which backend answered. + +## Write-path discipline + +This initial bridge is read-only. + +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 +whether to mutate. Calling skills must draft the proposed action, +surface it to the maintainer, wait for explicit confirmation, and only +then invoke the write command. + +## Planned follow-up coverage + +Follow-up PRs can extend this bridge with: + +- Bitbucket issue read/write operations, which will add tracker coverage. +- Linked Jira issue handoff through `tools/jira/`. +- Pull-request comment, review, approve, decline, and merge operations. +- Branch restriction and permission reads. +- Bitbucket Pipelines status reads for change-request `status`. diff --git a/tools/bitbucket/pyproject.toml b/tools/bitbucket/pyproject.toml new file mode 100644 index 00000000..7a498c7d --- /dev/null +++ b/tools/bitbucket/pyproject.toml @@ -0,0 +1,87 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "magpie-bitbucket" +version = "0.1.0" +description = "Bitbucket Cloud and Data Center bridge for Apache Magpie." +readme = "README.md" +requires-python = ">=3.11" +license = { text = "Apache-2.0" } +dependencies = [] + +[project.scripts] +magpie-bitbucket = "magpie_bitbucket:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/magpie_bitbucket"] + +[tool.ruff] +line-length = 110 +target-version = "py311" +src = ["src", "tests"] + +[tool.ruff.lint] +select = [ + "E", + "W", + "F", + "I", + "B", + "UP", + "SIM", + "C4", + "RUF", +] +ignore = [ + "E501", +] + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["B", "SIM"] + +[tool.mypy] +python_version = "3.11" +files = ["src", "tests"] +warn_unused_ignores = true +warn_redundant_casts = true +warn_unreachable = true +check_untyped_defs = true +no_implicit_optional = true +disallow_untyped_defs = true +disallow_incomplete_defs = true + +[[tool.mypy.overrides]] +module = "tests.*" +disallow_untyped_defs = false +disallow_incomplete_defs = false + +[tool.pytest.ini_options] +minversion = "8.0" +addopts = "-ra -q" +testpaths = ["tests"] + +[dependency-groups] +dev = [ + "mypy>=1.10", + "pytest>=8.0", + "ruff>=0.6", +] diff --git a/tools/bitbucket/src/magpie_bitbucket/__init__.py b/tools/bitbucket/src/magpie_bitbucket/__init__.py new file mode 100644 index 00000000..be9c017f --- /dev/null +++ b/tools/bitbucket/src/magpie_bitbucket/__init__.py @@ -0,0 +1,36 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Bitbucket forge bridge implementation for Apache Magpie.""" + +from __future__ import annotations + +import sys +from collections.abc import Sequence + +__all__ = ["main"] + + +def main(argv: Sequence[str] | None = None) -> int: + """CLI entry point.""" + from magpie_bitbucket.cli import main as cli_main + + try: + return cli_main(argv) + except Exception as exc: + print(f"magpie-bitbucket error: {exc}", file=sys.stderr) + return 1 diff --git a/tools/bitbucket/src/magpie_bitbucket/cli.py b/tools/bitbucket/src/magpie_bitbucket/cli.py new file mode 100644 index 00000000..459c673c --- /dev/null +++ b/tools/bitbucket/src/magpie_bitbucket/cli.py @@ -0,0 +1,103 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Command line interface for the Bitbucket bridge.""" + +from __future__ import annotations + +import argparse +import json +from collections.abc import Sequence +from typing import Any + +from magpie_bitbucket import cloud, datacenter, normalize +from magpie_bitbucket.client import BitbucketConfig, load_config + + +def main(argv: Sequence[str] | None = None) -> int: + """Run the magpie-bitbucket command line interface.""" + parser = _build_parser() + args = parser.parse_args(argv) + config = load_config() + + result = _dispatch(args, config) + _print_json(result) + return 0 + + +def _build_parser() -> argparse.ArgumentParser: + """Build the CLI parser.""" + parser = argparse.ArgumentParser( + prog="magpie-bitbucket", + description="Bitbucket Cloud and Data Center bridge for Apache Magpie.", + ) + subparsers = parser.add_subparsers(dest="subcommand", required=True) + + subparsers.add_parser("auth-check", help="Verify configured Bitbucket repository access.") + + repo_parser = subparsers.add_parser("repo", help="Interact with Bitbucket repositories.") + repo_subparsers = repo_parser.add_subparsers(dest="repo_action", required=True) + repo_subparsers.add_parser("get", help="Fetch repository metadata.") + + 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.") + + pr_get = pr_subparsers.add_parser("get", help="Fetch one pull request.") + pr_get.add_argument("pull_request_id", help="Pull request ID to fetch.") + + return parser + + +def _dispatch(args: argparse.Namespace, config: BitbucketConfig) -> dict[str, Any]: + """Dispatch parsed CLI arguments to the selected backend.""" + backend = _backend(config) + + if args.subcommand == "auth-check": + raw = backend.get_repository(config) + return { + "ok": True, + "backend": "bitbucket-cloud" if config.kind == "cloud" else "bitbucket-datacenter", + "repository": normalize.repository(config.kind, raw), + } + + if args.subcommand == "repo" and args.repo_action == "get": + raw = backend.get_repository(config) + return normalize.repository(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) + + if args.subcommand == "pr" and args.pr_action == "get": + raw = backend.get_pull_request(config, args.pull_request_id) + return normalize.pull_request(config.kind, raw) + + msg = "Unsupported command" + raise ValueError(msg) + + +def _backend(config: BitbucketConfig) -> Any: + """Return the module implementing the configured Bitbucket backend.""" + if config.kind == "cloud": + return cloud + return datacenter + + +def _print_json(data: dict[str, Any]) -> None: + """Print JSON output to stdout.""" + print(json.dumps(data, indent=2, sort_keys=True)) diff --git a/tools/bitbucket/src/magpie_bitbucket/client.py b/tools/bitbucket/src/magpie_bitbucket/client.py new file mode 100644 index 00000000..51d2e7bb --- /dev/null +++ b/tools/bitbucket/src/magpie_bitbucket/client.py @@ -0,0 +1,183 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Shared Bitbucket client configuration and HTTP helpers.""" + +from __future__ import annotations + +import base64 +import json +import os +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from typing import Any + +DEFAULT_TIMEOUT_SECONDS = 30 +_ALLOWED_AUTH_SCHEMES = {"basic", "bearer"} + + +class BitbucketError(Exception): + """Raised when the Bitbucket bridge cannot complete a request.""" + + +class NoAuthRedirectHandler(urllib.request.HTTPRedirectHandler): + """Reject redirects so Authorization is not forwarded to another host.""" + + def redirect_request( + self, + req: urllib.request.Request, + fp: Any, + code: int, + msg: str, + headers: Any, + newurl: str, + ) -> urllib.request.Request | None: + raise BitbucketError(f"Bitbucket request redirected to {newurl}; refusing to forward credentials") + + +@dataclass(frozen=True) +class BitbucketConfig: + """Environment-derived Bitbucket bridge configuration.""" + + kind: str + token: str | None + auth_scheme: str + username: str | None + workspace: str | None + repo_slug: str | None + base_url: str | None + project_key: str | None + + +def load_config() -> BitbucketConfig: + """Load Bitbucket bridge configuration from environment variables.""" + kind = os.environ.get("BITBUCKET_KIND", "cloud").strip().lower() + if kind not in {"cloud", "datacenter"}: + raise BitbucketError("BITBUCKET_KIND must be 'cloud' or 'datacenter'") + + default_auth_scheme = "Basic" if kind == "cloud" else "Bearer" + + return BitbucketConfig( + kind=kind, + token=os.environ.get("BITBUCKET_TOKEN"), + auth_scheme=os.environ.get("BITBUCKET_AUTH_SCHEME", default_auth_scheme), + username=os.environ.get("BITBUCKET_CLOUD_USER"), + workspace=os.environ.get("BITBUCKET_WORKSPACE"), + repo_slug=os.environ.get("BITBUCKET_REPO_SLUG"), + base_url=os.environ.get("BITBUCKET_BASE_URL"), + project_key=os.environ.get("BITBUCKET_PROJECT_KEY"), + ) + + +def require(value: str | None, name: str) -> str: + """Return a required config value or raise a readable bridge error.""" + if not value: + raise BitbucketError(f"{name} is required") + return value + + +def quote_path(value: str) -> str: + """Quote one URL path segment.""" + return urllib.parse.quote(value, safe="") + + +def make_auth_header(config: BitbucketConfig) -> str: + """Build the Authorization header for the selected Bitbucket backend.""" + token = require(config.token, "BITBUCKET_TOKEN") + scheme = config.auth_scheme.strip() + + if scheme.lower() not in _ALLOWED_AUTH_SCHEMES: + raise BitbucketError("BITBUCKET_AUTH_SCHEME must be 'Basic' or 'Bearer'") + + if scheme.lower() == "basic": + username = require(config.username, "BITBUCKET_CLOUD_USER") + raw = f"{username}:{token}".encode() + return f"Basic {base64.b64encode(raw).decode('ascii')}" + + return f"Bearer {token}" + + +def get_json(url: str, config: BitbucketConfig) -> dict[str, Any]: + """GET a Bitbucket API URL and parse the JSON response.""" + _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 + 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 _require_https(url: str) -> None: + """Require HTTPS for Bitbucket API URLs.""" + parsed = urllib.parse.urlparse(url) + if parsed.scheme != "https": + raise BitbucketError("Bitbucket API URLs must use HTTPS") + + +def _read_http_error(exc: urllib.error.HTTPError) -> str: + """Read the response body from an HTTPError when available.""" + try: + body = exc.read().decode("utf-8") + except Exception: + return exc.reason + + if not body: + return exc.reason + + try: + parsed = json.loads(body) + except json.JSONDecodeError: + return body + + if isinstance(parsed, dict): + error = parsed.get("error") + if isinstance(error, dict) and error.get("message"): + return str(error["message"]) + if parsed.get("message"): + return str(parsed["message"]) + if parsed.get("errors"): + return str(parsed["errors"]) + + return body diff --git a/tools/bitbucket/src/magpie_bitbucket/cloud.py b/tools/bitbucket/src/magpie_bitbucket/cloud.py new file mode 100644 index 00000000..23a9f8ad --- /dev/null +++ b/tools/bitbucket/src/magpie_bitbucket/cloud.py @@ -0,0 +1,69 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Bitbucket Cloud API operations.""" + +from __future__ import annotations + +from typing import Any + +from magpie_bitbucket.client import BitbucketConfig, get_json, quote_path, require + +CLOUD_API_BASE = "https://api.bitbucket.org/2.0" + + +def get_repository(config: BitbucketConfig) -> dict[str, Any]: + """Fetch repository metadata from Bitbucket Cloud.""" + workspace = quote_path(require(config.workspace, "BITBUCKET_WORKSPACE")) + repo_slug = quote_path(require(config.repo_slug, "BITBUCKET_REPO_SLUG")) + url = f"{CLOUD_API_BASE}/repositories/{workspace}/{repo_slug}" + return get_json(url, config) + + +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")) + repo_slug = quote_path(require(config.repo_slug, "BITBUCKET_REPO_SLUG")) + url = f"{CLOUD_API_BASE}/repositories/{workspace}/{repo_slug}/pullrequests?state=OPEN" + + combined: dict[str, Any] = { + "values": [], + "paginated": True, + "pages": [], + } + + 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)) + + next_url = page.get("next") + url = next_url if isinstance(next_url, str) else "" + + return combined + + +def get_pull_request(config: BitbucketConfig, pull_request_id: str) -> dict[str, Any]: + """Fetch one pull request from Bitbucket Cloud.""" + 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}" + return get_json(url, config) diff --git a/tools/bitbucket/src/magpie_bitbucket/datacenter.py b/tools/bitbucket/src/magpie_bitbucket/datacenter.py new file mode 100644 index 00000000..b546a4b0 --- /dev/null +++ b/tools/bitbucket/src/magpie_bitbucket/datacenter.py @@ -0,0 +1,80 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Bitbucket Data Center API operations.""" + +from __future__ import annotations + +from typing import Any + +from magpie_bitbucket.client import BitbucketConfig, get_json, quote_path, require + + +def _api_base(config: BitbucketConfig) -> str: + """Return the normalized Bitbucket Data Center REST API base URL.""" + base_url = require(config.base_url, "BITBUCKET_BASE_URL").rstrip("/") + return f"{base_url}/rest/api/1.0" + + +def get_repository(config: BitbucketConfig) -> dict[str, Any]: + """Fetch repository metadata from Bitbucket Data Center.""" + project_key = quote_path(require(config.project_key, "BITBUCKET_PROJECT_KEY")) + repo_slug = quote_path(require(config.repo_slug, "BITBUCKET_REPO_SLUG")) + url = f"{_api_base(config)}/projects/{project_key}/repos/{repo_slug}" + return get_json(url, config) + + +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")) + repo_slug = quote_path(require(config.repo_slug, "BITBUCKET_REPO_SLUG")) + base_url = f"{_api_base(config)}/projects/{project_key}/repos/{repo_slug}/pull-requests" + + start = 0 + combined: dict[str, Any] = { + "values": [], + "paginated": True, + "pages": [], + } + + while True: + page = get_json(f"{base_url}?state=OPEN&start={start}", 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)) + + if page.get("isLastPage") is True: + break + + next_start = page.get("nextPageStart") + if not isinstance(next_start, int): + break + + start = next_start + + return combined + + +def get_pull_request(config: BitbucketConfig, pull_request_id: str) -> dict[str, Any]: + """Fetch one pull request from Bitbucket Data Center.""" + 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) + url = f"{_api_base(config)}/projects/{project_key}/repos/{repo_slug}/pull-requests/{pr_id}" + return get_json(url, config) diff --git a/tools/bitbucket/src/magpie_bitbucket/normalize.py b/tools/bitbucket/src/magpie_bitbucket/normalize.py new file mode 100644 index 00000000..be96dbef --- /dev/null +++ b/tools/bitbucket/src/magpie_bitbucket/normalize.py @@ -0,0 +1,240 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Normalize Bitbucket Cloud and Data Center responses.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +READ_ONLY_LABELS = ["bitbucket", "read-only", "partial-change-request"] + + +def repository(kind: str, raw: dict[str, Any]) -> dict[str, Any]: + """Normalize repository metadata from Bitbucket Cloud or Data Center.""" + if kind == "cloud": + return { + "backend": "bitbucket-cloud", + "id": _string(raw.get("uuid") or raw.get("full_name") or raw.get("slug")), + "name": raw.get("name"), + "slug": raw.get("slug"), + "description": raw.get("description"), + "is_private": raw.get("is_private"), + "main_branch": _cloud_main_branch(raw), + "links": _cloud_links(raw), + "capabilities": { + "repository_metadata": "read", + "pull_requests": "read", + "issues": "not_implemented", + "writes": "not_implemented", + }, + "raw": raw, + } + + return { + "backend": "bitbucket-datacenter", + "id": _string(raw.get("id") or raw.get("slug") or raw.get("name")), + "name": raw.get("name"), + "slug": raw.get("slug"), + "description": raw.get("description"), + "is_private": _datacenter_private(raw), + "main_branch": _datacenter_main_branch(raw), + "links": _datacenter_links(raw), + "capabilities": { + "repository_metadata": "read", + "pull_requests": "read", + "issues": "not_implemented", + "writes": "not_implemented", + }, + "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": + return { + "backend": "bitbucket-cloud", + "id": _string(raw.get("id")), + "title": raw.get("title"), + "author": _cloud_user(raw.get("author")), + "state": _normalize_state(raw.get("state")), + "created": _cloud_timestamp(raw.get("created_on")), + "updated": _cloud_timestamp(raw.get("updated_on")), + "source": _cloud_branch(raw.get("source")), + "target": _cloud_branch(raw.get("destination")), + "permalink": _cloud_link(raw, "html"), + "labels": READ_ONLY_LABELS, + } + + return { + "backend": "bitbucket-datacenter", + "id": _string(raw.get("id")), + "title": raw.get("title"), + "author": _datacenter_user(raw.get("author")), + "state": _normalize_state(raw.get("state")), + "created": _epoch_millis_to_iso(raw.get("createdDate")), + "updated": _epoch_millis_to_iso(raw.get("updatedDate")), + "source": _datacenter_branch(raw.get("fromRef")), + "target": _datacenter_branch(raw.get("toRef")), + "permalink": _datacenter_link(raw), + "labels": READ_ONLY_LABELS, + } + + +def pull_request(kind: str, raw: dict[str, Any]) -> dict[str, Any]: + """Normalize one pull request as a read-only change-request proposal.""" + summary = pull_request_summary(kind, raw) + summary["description"] = raw.get("description") + summary["mergeable"] = "unknown" + summary["checks"] = "none" + summary["diff"] = None + summary["commits"] = None + summary["raw"] = raw + return summary + + +def pull_request_list(kind: str, raw: dict[str, Any]) -> dict[str, Any]: + """Normalize a Bitbucket pull-request list response.""" + values = raw.get("values") + if not isinstance(values, list): + values = [] + + return { + "backend": "bitbucket-cloud" if kind == "cloud" else "bitbucket-datacenter", + "coverage": "read-only-partial-change-request", + "pull_requests": [pull_request_summary(kind, item) for item in values if isinstance(item, dict)], + "raw": raw, + } + + +def _string(value: object) -> str | None: + """Convert a value to string while preserving missing values as None.""" + if value is None: + return None + return str(value) + + +def _normalize_state(value: object) -> str: + """Normalize backend-specific PR states to change-request lifecycle words.""" + state = str(value or "").lower() + if state in {"open", "opened"}: + return "open" + if state in {"merged", "fulfilled"}: + return "merged" + if state in {"declined", "superseded"}: + return "declined" + return state or "unknown" + + +def _cloud_timestamp(value: object) -> str | None: + """Return a Cloud timestamp string when present.""" + return _string(value) + + +def _epoch_millis_to_iso(value: object) -> str | None: + """Convert Bitbucket Data Center epoch milliseconds to UTC ISO-8601.""" + if isinstance(value, int | float): + return datetime.fromtimestamp(value / 1000, tz=UTC).isoformat().replace("+00:00", "Z") + return _string(value) + + +def _cloud_main_branch(raw: dict[str, Any]) -> str | None: + mainbranch = raw.get("mainbranch") + if isinstance(mainbranch, dict): + value = mainbranch.get("name") + return _string(value) + return _string(mainbranch) + + +def _cloud_links(raw: dict[str, Any]) -> dict[str, str]: + links = raw.get("links") + if not isinstance(links, dict): + return {} + normalized: dict[str, str] = {} + for name, value in links.items(): + if isinstance(value, dict) and isinstance(value.get("href"), str): + normalized[name] = value["href"] + return normalized + + +def _cloud_link(raw: dict[str, Any], name: str) -> str | None: + links = _cloud_links(raw) + return links.get(name) + + +def _cloud_user(raw: object) -> str | None: + if not isinstance(raw, dict): + return None + return _string(raw.get("display_name") or raw.get("nickname") or raw.get("username") or raw.get("uuid")) + + +def _cloud_branch(raw: object) -> str | None: + if not isinstance(raw, dict): + return None + branch = raw.get("branch") + if isinstance(branch, dict): + return _string(branch.get("name")) + return None + + +def _datacenter_private(raw: dict[str, Any]) -> bool | None: + public = raw.get("public") + if isinstance(public, bool): + return not public + return None + + +def _datacenter_main_branch(raw: dict[str, Any]) -> str | None: + branch = raw.get("defaultBranch") + if isinstance(branch, dict): + return _string(branch.get("displayId") or branch.get("id")) + return _string(branch) + + +def _datacenter_links(raw: dict[str, Any]) -> dict[str, str]: + links = raw.get("links") + if not isinstance(links, dict): + return {} + + normalized: dict[str, str] = {} + for name, value in links.items(): + if isinstance(value, list) and value: + first = value[0] + if isinstance(first, dict) and isinstance(first.get("href"), str): + normalized[name] = first["href"] + return normalized + + +def _datacenter_link(raw: dict[str, Any]) -> str | None: + return _datacenter_links(raw).get("self") + + +def _datacenter_user(raw: object) -> str | None: + if not isinstance(raw, dict): + return None + user = raw.get("user") + if isinstance(user, dict): + return _string(user.get("displayName") or user.get("name") or user.get("emailAddress")) + return _string(raw.get("displayName") or raw.get("name") or raw.get("emailAddress")) + + +def _datacenter_branch(raw: object) -> str | None: + if not isinstance(raw, dict): + return None + return _string(raw.get("displayId") or raw.get("id")) diff --git a/tools/bitbucket/src/magpie_bitbucket/py.typed b/tools/bitbucket/src/magpie_bitbucket/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/tools/bitbucket/tests/test_bitbucket.py b/tools/bitbucket/tests/test_bitbucket.py new file mode 100644 index 00000000..51b0998e --- /dev/null +++ b/tools/bitbucket/tests/test_bitbucket.py @@ -0,0 +1,338 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from __future__ import annotations + +import json +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from magpie_bitbucket import cloud, datacenter +from magpie_bitbucket.cli import main +from magpie_bitbucket.client import BitbucketError, load_config, make_auth_header +from magpie_bitbucket.normalize import pull_request, pull_request_list, repository + + +@pytest.fixture +def cloud_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("BITBUCKET_KIND", "cloud") + monkeypatch.setenv("BITBUCKET_CLOUD_USER", "alice@example.test") + monkeypatch.setenv("BITBUCKET_TOKEN", "token-123") + monkeypatch.setenv("BITBUCKET_WORKSPACE", "apache") + monkeypatch.setenv("BITBUCKET_REPO_SLUG", "magpie") + + +@pytest.fixture +def datacenter_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("BITBUCKET_KIND", "datacenter") + monkeypatch.setenv("BITBUCKET_TOKEN", "token-123") + monkeypatch.setenv("BITBUCKET_BASE_URL", "https://bitbucket.example.test/") + monkeypatch.setenv("BITBUCKET_PROJECT_KEY", "MAGPIE") + monkeypatch.setenv("BITBUCKET_REPO_SLUG", "magpie") + + +def make_mock_response(body: dict[str, Any]) -> MagicMock: + response = MagicMock() + response.read.return_value = json.dumps(body).encode() + return response + + +def mock_opener(mock_build_opener: MagicMock, *bodies: dict[str, Any]) -> MagicMock: + opener = MagicMock() + opener.open.side_effect = [ + MagicMock( + __enter__=MagicMock(return_value=make_mock_response(body)), __exit__=MagicMock(return_value=None) + ) + for body in bodies + ] + mock_build_opener.return_value = opener + return opener + + +def test_load_config_defaults_to_cloud(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("BITBUCKET_KIND", raising=False) + config = load_config() + assert config.kind == "cloud" + assert config.auth_scheme == "Basic" + + +def test_load_config_rejects_unknown_kind(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("BITBUCKET_KIND", "server") + with pytest.raises(BitbucketError, match="BITBUCKET_KIND must be 'cloud' or 'datacenter'"): + load_config() + + +def test_make_auth_header_basic(cloud_env: None) -> None: + config = load_config() + assert make_auth_header(config).startswith("Basic ") + + +def test_make_auth_header_rejects_unknown_scheme(cloud_env: None, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("BITBUCKET_AUTH_SCHEME", "Digest") + config = load_config() + + with pytest.raises(BitbucketError, match="BITBUCKET_AUTH_SCHEME must be 'Basic' or 'Bearer'"): + make_auth_header(config) + + +def test_make_auth_header_bearer(datacenter_env: None) -> None: + config = load_config() + assert make_auth_header(config) == "Bearer token-123" + + +@patch("urllib.request.build_opener") +def test_cloud_get_repository_url(mock_build_opener: MagicMock, cloud_env: None) -> None: + opener = mock_opener(mock_build_opener, {"name": "Magpie", "slug": "magpie"}) + result = cloud.get_repository(load_config()) + + request = opener.open.call_args.args[0] + assert request.full_url == "https://api.bitbucket.org/2.0/repositories/apache/magpie" + assert opener.open.call_args.kwargs["timeout"] == 30 + assert result["name"] == "Magpie" + + +@patch("urllib.request.build_opener") +def test_cloud_list_open_pull_requests_follows_next(mock_build_opener: MagicMock, cloud_env: None) -> None: + opener = mock_opener( + mock_build_opener, + { + "values": [{"id": 1, "title": "One"}], + "next": "https://api.bitbucket.org/2.0/repositories/apache/magpie/pullrequests?page=2", + }, + {"values": [{"id": 2, "title": "Two"}]}, + ) + result = cloud.list_open_pull_requests(load_config()) + + 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/pullrequests?state=OPEN" + ) + assert ( + second_request.full_url + == "https://api.bitbucket.org/2.0/repositories/apache/magpie/pullrequests?page=2" + ) + assert [item["id"] for item in result["values"]] == [1, 2] + + +@patch("urllib.request.build_opener") +def test_cloud_get_pull_request_url(mock_build_opener: MagicMock, cloud_env: None) -> None: + opener = mock_opener(mock_build_opener, {"id": 7, "title": "Fix docs"}) + result = cloud.get_pull_request(load_config(), "7") + + request = opener.open.call_args.args[0] + assert request.full_url == "https://api.bitbucket.org/2.0/repositories/apache/magpie/pullrequests/7" + assert result["title"] == "Fix docs" + + +@patch("urllib.request.build_opener") +def test_datacenter_get_repository_url(mock_build_opener: MagicMock, datacenter_env: None) -> None: + opener = mock_opener(mock_build_opener, {"name": "Magpie", "slug": "magpie"}) + result = datacenter.get_repository(load_config()) + + request = opener.open.call_args.args[0] + assert request.full_url == "https://bitbucket.example.test/rest/api/1.0/projects/MAGPIE/repos/magpie" + assert result["slug"] == "magpie" + + +@patch("urllib.request.build_opener") +def test_datacenter_list_open_pull_requests_follows_next_page_start( + mock_build_opener: MagicMock, + datacenter_env: None, +) -> None: + opener = mock_opener( + mock_build_opener, + {"values": [{"id": 1, "title": "One"}], "isLastPage": False, "nextPageStart": 25}, + {"values": [{"id": 2, "title": "Two"}], "isLastPage": True}, + ) + result = datacenter.list_open_pull_requests(load_config()) + + 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://bitbucket.example.test/rest/api/1.0/projects/MAGPIE/repos/magpie/pull-requests?state=OPEN&start=0" + ) + assert ( + second_request.full_url + == "https://bitbucket.example.test/rest/api/1.0/projects/MAGPIE/repos/magpie/pull-requests?state=OPEN&start=25" + ) + assert [item["id"] for item in result["values"]] == [1, 2] + + +@patch("urllib.request.build_opener") +def test_datacenter_get_pull_request_url(mock_build_opener: MagicMock, datacenter_env: None) -> None: + opener = mock_opener(mock_build_opener, {"id": 9, "title": "Fix tests"}) + result = datacenter.get_pull_request(load_config(), "9") + + request = opener.open.call_args.args[0] + assert ( + request.full_url + == "https://bitbucket.example.test/rest/api/1.0/projects/MAGPIE/repos/magpie/pull-requests/9" + ) + assert result["title"] == "Fix tests" + + +def test_normalize_cloud_repository() -> None: + raw = { + "uuid": "{abc}", + "name": "Magpie", + "slug": "magpie", + "description": "Agent-assisted maintainership", + "is_private": False, + "mainbranch": {"name": "main"}, + "links": {"html": {"href": "https://bitbucket.org/apache/magpie"}}, + } + + result = repository("cloud", raw) + + assert result["backend"] == "bitbucket-cloud" + assert result["id"] == "{abc}" + assert result["main_branch"] == "main" + assert result["links"]["html"] == "https://bitbucket.org/apache/magpie" + assert result["capabilities"]["issues"] == "not_implemented" + + +def test_normalize_datacenter_repository_accepts_string_default_branch() -> None: + raw = { + "id": 101, + "name": "Magpie", + "slug": "magpie", + "public": False, + "defaultBranch": "refs/heads/main", + "links": {"self": [{"href": "https://bitbucket.example.test/projects/MAGPIE/repos/magpie"}]}, + } + + result = repository("datacenter", raw) + + assert result["backend"] == "bitbucket-datacenter" + assert result["id"] == "101" + assert result["is_private"] is True + assert result["main_branch"] == "refs/heads/main" + + +def test_normalize_cloud_pull_request() -> None: + raw = { + "id": 12, + "title": "Fix docs", + "state": "OPEN", + "created_on": "2026-07-01T00:00:00Z", + "updated_on": "2026-07-02T00:00:00Z", + "author": {"display_name": "Alice"}, + "source": {"branch": {"name": "fix-docs"}}, + "destination": {"branch": {"name": "main"}}, + "links": {"html": {"href": "https://bitbucket.org/apache/magpie/pull-requests/12"}}, + "description": "Updates docs.", + } + + result = pull_request("cloud", raw) + + assert result["backend"] == "bitbucket-cloud" + assert result["id"] == "12" + assert result["state"] == "open" + assert result["author"] == "Alice" + assert result["source"] == "fix-docs" + assert result["target"] == "main" + assert result["mergeable"] == "unknown" + assert result["checks"] == "none" + assert result["diff"] is None + assert result["commits"] is None + assert result["labels"] == ["bitbucket", "read-only", "partial-change-request"] + + +def test_normalize_datacenter_pull_request_timestamp() -> None: + raw = { + "id": 13, + "title": "Fix tests", + "state": "OPEN", + "createdDate": 1780000000000, + "updatedDate": 1780000001000, + "author": {"user": {"displayName": "Bob"}}, + "fromRef": {"displayId": "fix-tests"}, + "toRef": {"displayId": "main"}, + "links": { + "self": [{"href": "https://bitbucket.example.test/projects/MAGPIE/repos/magpie/pull-requests/13"}] + }, + "description": "Updates tests.", + } + + result = pull_request("datacenter", raw) + + assert result["backend"] == "bitbucket-datacenter" + assert result["id"] == "13" + assert result["state"] == "open" + assert result["author"] == "Bob" + assert result["source"] == "fix-tests" + assert result["target"] == "main" + assert result["created"] == "2026-05-28T20:26:40Z" + assert result["updated"] == "2026-05-28T20:26:41Z" + + +def test_normalize_pull_request_list() -> None: + raw = { + "values": [ + {"id": 1, "title": "One", "state": "OPEN"}, + {"id": 2, "title": "Two", "state": "MERGED"}, + ] + } + + result = pull_request_list("cloud", raw) + + assert result["backend"] == "bitbucket-cloud" + assert result["coverage"] == "read-only-partial-change-request" + assert [item["id"] for item in result["pull_requests"]] == ["1", "2"] + assert [item["state"] for item in result["pull_requests"]] == ["open", "merged"] + + +@patch("magpie_bitbucket.cloud.get_repository") +def test_cli_auth_check_cloud( + mock_get_repository: MagicMock, cloud_env: None, capsys: pytest.CaptureFixture[str] +) -> None: + mock_get_repository.return_value = { + "uuid": "{abc}", + "name": "Magpie", + "slug": "magpie", + } + + exit_code = main(["auth-check"]) + + captured = capsys.readouterr() + output = json.loads(captured.out) + assert exit_code == 0 + assert output["ok"] is True + assert output["backend"] == "bitbucket-cloud" + assert output["repository"]["name"] == "Magpie" + + +@patch("magpie_bitbucket.cloud.list_open_pull_requests") +def test_cli_pr_list_open_cloud( + mock_list_open_pull_requests: MagicMock, + cloud_env: None, + capsys: pytest.CaptureFixture[str], +) -> None: + mock_list_open_pull_requests.return_value = {"values": [{"id": 1, "title": "One", "state": "OPEN"}]} + + exit_code = main(["pr", "list-open"]) + + captured = capsys.readouterr() + output = json.loads(captured.out) + assert exit_code == 0 + assert output["pull_requests"][0]["id"] == "1" + assert output["pull_requests"][0]["state"] == "open" diff --git a/tools/bitbucket/uv.lock b/tools/bitbucket/uv.lock new file mode 100644 index 00000000..229a9fce --- /dev/null +++ b/tools/bitbucket/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "magpie-bitbucket" +version = "0.1.0" +source = { editable = "." } diff --git a/tools/egress-gateway/src/egress_gateway/allowlist.py b/tools/egress-gateway/src/egress_gateway/allowlist.py index c1dbcc7d..10ad8798 100644 --- a/tools/egress-gateway/src/egress_gateway/allowlist.py +++ b/tools/egress-gateway/src/egress_gateway/allowlist.py @@ -39,6 +39,7 @@ { "github.com", "api.github.com", + "api.bitbucket.org", "pypi.org", "docs.google.com", "nvd.nist.gov", diff --git a/tools/sandbox-lint/expected.json b/tools/sandbox-lint/expected.json index 29097932..72d45cf0 100644 --- a/tools/sandbox-lint/expected.json +++ b/tools/sandbox-lint/expected.json @@ -30,6 +30,7 @@ "allowedDomains": [ "github.com", "api.github.com", + "api.bitbucket.org", "raw.githubusercontent.com", "objects.githubusercontent.com", "codeload.github.com", diff --git a/tools/spec-loop/specs/adapters.md b/tools/spec-loop/specs/adapters.md index c8e5fe68..1eee6df4 100644 --- a/tools/spec-loop/specs/adapters.md +++ b/tools/spec-loop/specs/adapters.md @@ -2,7 +2,7 @@ https://www.apache.org/licenses/LICENSE-2.0 --> --- -title: Adapters (Gmail / PonyMail / Jira / GitHub / mail-source / SourceHut / maildir / VCS / change-request) +title: Adapters (Gmail / PonyMail / Jira / GitHub / Bitbucket / mail-source / SourceHut / maildir / VCS / change-request) status: experimental kind: feature mode: infra @@ -10,7 +10,7 @@ source: > MISSION.md § Rationale ("ASF integrations live behind clean configuration boundaries; non-ASF adopters swap them") and § Technical scope (extensible adapter layer). Implemented in tools/gmail/, - tools/ponymail/, tools/jira/, tools/github/, tools/mail-source/, + tools/ponymail/, tools/jira/, tools/github/, tools/bitbucket/, tools/mail-source/, tools/sourcehut/, tools/maildir/, tools/vcs/, tools/change-request/, tools/asf-svn/, tools/mail-archive/, tools/mail-patch/, tools/jira-patch/, tools/forwarder-relay/, tools/github-body-field/, @@ -24,7 +24,7 @@ acceptance: redactor before any LLM read. --- -# Adapters (Gmail / PonyMail / Jira / GitHub / SourceHut / maildir / VCS / change-request) +# Adapters (Gmail / PonyMail / Jira / GitHub / Bitbucket / SourceHut / maildir / VCS / change-request) ## What it does @@ -47,6 +47,12 @@ 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 + Data Center bridge foundation. Supports repository metadata reads, open + pull-request listing, and single pull-request fetching behind one CLI + surface. It is not a complete `contract:change-request` backend yet; + deeper Jira handoff, issue operations, review/merge writes, branch + permissions, and Pipelines status 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`) @@ -126,7 +132,7 @@ by swapping the adapter, not the skill. ## Validation ```bash -for t in gmail maildir ponymail jira github; do +for t in gmail maildir ponymail jira github bitbucket; do uv run --project tools/$t --group dev pytest || echo "check tools/$t test setup" done uv run --project tools/vcs --group dev pytest || echo "check tools/vcs test setup" @@ -136,6 +142,9 @@ uv run --project tools/vcs --group dev pytest || echo "check tools/vcs test setu - `experimental` overall — adapter coverage varies; a new adopter system (e.g. GitLab, a different mail backend) is a gap the plan pass records. +- **Bitbucket adapter is new and intentionally partial.** `tools/bitbucket/` + currently provides read-only repository and pull-request discovery only; + #606 remains open for full tracker/change-request coverage. - **SourceHut adapter is new and untested end-to-end.** `tools/sourcehut/` ships the GraphQL-based bridge (ticket, patchset, CI, repo), but no adopter pilot has exercised it; signal/roster heuristics may change. diff --git a/uv.lock b/uv.lock index d41a4386..c7b83b7e 100644 --- a/uv.lock +++ b/uv.lock @@ -21,6 +21,7 @@ members = [ "github-body-field", "github-rollup", "jira-bridge", + "magpie-bitbucket", "magpie-maildir", "magpie-sourcehut", "magpie-vcs", @@ -631,6 +632,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, ] +[[package]] +name = "magpie-bitbucket" +version = "0.1.0" +source = { editable = "tools/bitbucket" } + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.10" }, + { name = "pytest", specifier = ">=8.0" }, + { name = "ruff", specifier = ">=0.6" }, +] + [[package]] name = "magpie-maildir" version = "0.1.0"