Skip to content

Commit

Permalink
feat: add bitbucket hvcs
Browse files Browse the repository at this point in the history
  • Loading branch information
beatreichenbach authored and relekang committed Feb 14, 2024
1 parent d2314f8 commit bbbbfeb
Show file tree
Hide file tree
Showing 5 changed files with 329 additions and 3 deletions.
4 changes: 2 additions & 2 deletions docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -567,8 +567,8 @@ Name of the remote to push to using ``git push -u $name <branch_name>``
""""""""""""""
The type of the remote VCS. Currently, Python Semantic Release supports ``"github"``,
``"gitlab"`` and ``"gitea"``. Not all functionality is available with all remote types,
but we welcome pull requests to help improve this!
``"gitlab"``, ``"gitea"`` and ``"bitbucket"``. Not all functionality is available with all
remote types, but we welcome pull requests to help improve this!
**Default:** ``"github"``
Expand Down
2 changes: 2 additions & 0 deletions semantic_release/cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@


class HvcsClient(str, Enum):
BITBUCKET = "bitbucket"
GITHUB = "github"
GITLAB = "gitlab"
GITEA = "gitea"
Expand All @@ -57,6 +58,7 @@ class HvcsClient(str, Enum):


_known_hvcs: Dict[HvcsClient, Type[hvcs.HvcsBase]] = {
HvcsClient.BITBUCKET: hvcs.Bitbucket,
HvcsClient.GITHUB: hvcs.Github,
HvcsClient.GITLAB: hvcs.Gitlab,
HvcsClient.GITEA: hvcs.Gitea,
Expand Down
3 changes: 2 additions & 1 deletion semantic_release/hvcs/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from semantic_release.hvcs._base import HvcsBase
from semantic_release.hvcs.bitbucket import Bitbucket
from semantic_release.hvcs.gitea import Gitea
from semantic_release.hvcs.github import Github
from semantic_release.hvcs.gitlab import Gitlab
from semantic_release.hvcs.token_auth import TokenAuth

__all__ = ["Gitea", "Github", "Gitlab", "HvcsBase", "TokenAuth"]
__all__ = ["Bitbucket", "Gitea", "Github", "Gitlab", "HvcsBase", "TokenAuth"]
125 changes: 125 additions & 0 deletions semantic_release/hvcs/bitbucket.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
"""Helper code for interacting with a Bitbucket remote VCS"""

# Note: Bitbucket doesn't support releases. But it allows users to use
# `semantic-release version` without having to specify `--no-vcs-release`.

from __future__ import annotations

import logging
import mimetypes
import os
from functools import lru_cache

from semantic_release.hvcs._base import HvcsBase
from semantic_release.hvcs.token_auth import TokenAuth
from semantic_release.hvcs.util import build_requests_session

log = logging.getLogger(__name__)

# Add a mime type for wheels
# Fix incorrect entries in the `mimetypes` registry.
# On Windows, the Python standard library's `mimetypes` reads in
# mappings from file extension to MIME type from the Windows
# registry. Other applications can and do write incorrect values
# to this registry, which causes `mimetypes.guess_type` to return
# incorrect values, which causes TensorBoard to fail to render on
# the frontend.
# This method hard-codes the correct mappings for certain MIME
# types that are known to be either used by python-semantic-release or
# problematic in general.
mimetypes.add_type("application/octet-stream", ".whl")
mimetypes.add_type("text/markdown", ".md")


class Bitbucket(HvcsBase):
"""Bitbucket helper class"""

API_VERSION = "2.0"
DEFAULT_DOMAIN = "bitbucket.org"
DEFAULT_API_DOMAIN = "api.bitbucket.org"
DEFAULT_ENV_TOKEN_NAME = "BITBUCKET_TOKEN"

def __init__(
self,
remote_url: str,
hvcs_domain: str | None = None,
hvcs_api_domain: str | None = None,
token: str | None = None,
) -> None:
self._remote_url = remote_url

if hvcs_domain is not None:
self.hvcs_domain = hvcs_domain
else:
api_url = os.getenv("BITBUCKET_SERVER_URL", self.DEFAULT_DOMAIN)
self.hvcs_domain = api_url.replace("https://", "")

# ref: https://developer.atlassian.com/cloud/bitbucket/rest/intro/#uri-uuid
if hvcs_api_domain is not None:
self.hvcs_api_domain = hvcs_api_domain
else:
api_url = os.getenv("BITBUCKET_API_URL", self.DEFAULT_API_DOMAIN)
self.hvcs_api_domain = api_url.replace("https://", "")

self.api_url = f"https://{self.hvcs_api_domain}/{self.API_VERSION}"

self.token = token
auth = None if not self.token else TokenAuth(self.token)
self.session = build_requests_session(auth=auth)

@lru_cache(maxsize=1)
def _get_repository_owner_and_name(self) -> tuple[str, str]:
# ref: https://support.atlassian.com/bitbucket-cloud/docs/variables-and-secrets/
if "BITBUCKET_REPO_FULL_NAME" in os.environ:
log.info("Getting repository owner and name from environment variables.")
owner, name = os.environ["BITBUCKET_REPO_FULL_NAME"].rsplit("/", 1)
return owner, name
return super()._get_repository_owner_and_name()

def compare_url(self, from_rev: str, to_rev: str) -> str:
"""
Get the Bitbucket comparison link between two version tags.
:param from_rev: The older version to compare.
:param to_rev: The newer version to compare.
:return: Link to view a comparison between the two versions.
"""
return (
f"https://{self.hvcs_domain}/{self.owner}/{self.repo_name}/"
f"branches/compare/{from_rev}%0D{to_rev}"
)

def remote_url(self, use_token: bool = True) -> str:
if not use_token:
# Note: Assume the user is using SSH.
return self._remote_url
if not self.token:
raise ValueError("Requested to use token but no token set.")
user = os.environ.get("BITBUCKET_USER")
if user:
# Note: If the user is set, assume the token is an app secret. This will work
# on any repository the user has access to.
# https://support.atlassian.com/bitbucket-cloud/docs/push-back-to-your-repository
return (
f"https://{user}:{self.token}@"
f"{self.hvcs_domain}/{self.owner}/{self.repo_name}.git"
)
else:
# Note: Assume the token is a repository token which will only work on the
# repository it was created for.
# https://support.atlassian.com/bitbucket-cloud/docs/using-access-tokens
return (
f"https://x-token-auth:{self.token}@"
f"{self.hvcs_domain}/{self.owner}/{self.repo_name}.git"
)

def commit_hash_url(self, commit_hash: str) -> str:
return (
f"https://{self.hvcs_domain}/{self.owner}/{self.repo_name}/"
f"commits/{commit_hash}"
)

def pull_request_url(self, pr_number: str | int) -> str:
return (
f"https://{self.hvcs_domain}/{self.owner}/{self.repo_name}/"
f"pull-requests/{pr_number}"
)
198 changes: 198 additions & 0 deletions tests/unit/semantic_release/hvcs/test_bitbucket.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import os
from unittest import mock

import pytest
from requests import Session

from semantic_release.hvcs.bitbucket import Bitbucket
from tests.const import EXAMPLE_REPO_NAME, EXAMPLE_REPO_OWNER


@pytest.fixture
def default_bitbucket_client():
remote_url = f"git@bitbucket.org:{EXAMPLE_REPO_OWNER}/{EXAMPLE_REPO_NAME}.git"
return Bitbucket(remote_url=remote_url)


@pytest.mark.parametrize(
(
"patched_os_environ, hvcs_domain, hvcs_api_domain, "
"expected_hvcs_domain, expected_hvcs_api_domain"
),
[
({}, None, None, Bitbucket.DEFAULT_DOMAIN, Bitbucket.DEFAULT_API_DOMAIN),
(
{"BITBUCKET_SERVER_URL": "https://special.custom.server/vcs/"},
None,
None,
"special.custom.server/vcs/",
Bitbucket.DEFAULT_API_DOMAIN,
),
(
{"BITBUCKET_API_URL": "https://api.special.custom.server/"},
None,
None,
Bitbucket.DEFAULT_DOMAIN,
"api.special.custom.server/",
),
(
{"BITBUCKET_SERVER_URL": "https://special.custom.server/vcs/"},
"https://example.com",
None,
"https://example.com",
Bitbucket.DEFAULT_API_DOMAIN,
),
(
{"BITBUCKET_API_URL": "https://api.special.custom.server/"},
None,
"https://api.example.com",
Bitbucket.DEFAULT_DOMAIN,
"https://api.example.com",
),
],
)
@pytest.mark.parametrize(
"remote_url",
[
f"git@bitbucket.org:{EXAMPLE_REPO_OWNER}/{EXAMPLE_REPO_NAME}.git",
f"https://bitbucket.org/{EXAMPLE_REPO_OWNER}/{EXAMPLE_REPO_NAME}.git",
],
)
@pytest.mark.parametrize("token", ("abc123", None))
def test_bitbucket_client_init(
patched_os_environ,
hvcs_domain,
hvcs_api_domain,
expected_hvcs_domain,
expected_hvcs_api_domain,
remote_url,
token,
):
with mock.patch.dict(os.environ, patched_os_environ, clear=True):
client = Bitbucket(
remote_url=remote_url,
hvcs_domain=hvcs_domain,
hvcs_api_domain=hvcs_api_domain,
token=token,
)

assert client.hvcs_domain == expected_hvcs_domain
assert client.hvcs_api_domain == expected_hvcs_api_domain
assert client.api_url == f"https://{client.hvcs_api_domain}/2.0"
assert client.token == token
assert client._remote_url == remote_url
assert hasattr(client, "session")
assert isinstance(getattr(client, "session", None), Session)


@pytest.mark.parametrize(
"patched_os_environ, expected_owner, expected_name",
[
({}, None, None),
({"BITBUCKET_REPO_FULL_NAME": "path/to/repo/foo"}, "path/to/repo", "foo"),
],
)
def test_bitbucket_get_repository_owner_and_name(
default_bitbucket_client, patched_os_environ, expected_owner, expected_name
):
with mock.patch.dict(os.environ, patched_os_environ, clear=True):
if expected_owner is None and expected_name is None:
assert (
default_bitbucket_client._get_repository_owner_and_name()
== super(
Bitbucket, default_bitbucket_client
)._get_repository_owner_and_name()
)
else:
assert default_bitbucket_client._get_repository_owner_and_name() == (
expected_owner,
expected_name,
)


def test_compare_url(default_bitbucket_client):
assert default_bitbucket_client.compare_url(
from_rev="revA", to_rev="revB"
) == "https://{domain}/{owner}/{repo}/branches/compare/revA%0DrevB".format(
domain=default_bitbucket_client.hvcs_domain,
owner=default_bitbucket_client.owner,
repo=default_bitbucket_client.repo_name,
)


@pytest.mark.parametrize(
"patched_os_environ, use_token, token, _remote_url, expected",
[
(
{"BITBUCKET_USER": "foo"},
False,
"",
"git@bitbucket.org:custom/example.git",
"git@bitbucket.org:custom/example.git",
),
(
{},
False,
"aabbcc",
"git@bitbucket.org:custom/example.git",
"git@bitbucket.org:custom/example.git",
),
(
{},
True,
"aabbcc",
"git@bitbucket.org:custom/example.git",
"https://x-token-auth:aabbcc@bitbucket.org/custom/example.git",
),
(
{"BITBUCKET_USER": "foo"},
False,
"aabbcc",
"git@bitbucket.org:custom/example.git",
"git@bitbucket.org:custom/example.git",
),
(
{"BITBUCKET_USER": "foo"},
True,
"aabbcc",
"git@bitbucket.org:custom/example.git",
"https://foo:aabbcc@bitbucket.org/custom/example.git",
),
],
)
def test_remote_url(
patched_os_environ,
use_token,
token,
_remote_url,
expected,
default_bitbucket_client,
):
with mock.patch.dict(os.environ, patched_os_environ, clear=True):
default_bitbucket_client._remote_url = _remote_url
default_bitbucket_client.token = token
assert default_bitbucket_client.remote_url(use_token=use_token) == expected


def test_commit_hash_url(default_bitbucket_client):
sha = "244f7e11bcb1e1ce097db61594056bc2a32189a0"
assert default_bitbucket_client.commit_hash_url(
sha
) == "https://{domain}/{owner}/{repo}/commits/{sha}".format(
domain=default_bitbucket_client.hvcs_domain,
owner=default_bitbucket_client.owner,
repo=default_bitbucket_client.repo_name,
sha=sha,
)


@pytest.mark.parametrize("pr_number", (420, "420"))
def test_pull_request_url(default_bitbucket_client, pr_number):
assert default_bitbucket_client.pull_request_url(
pr_number=pr_number
) == "https://{domain}/{owner}/{repo}/pull-requests/{pr_number}".format(
domain=default_bitbucket_client.hvcs_domain,
owner=default_bitbucket_client.owner,
repo=default_bitbucket_client.repo_name,
pr_number=pr_number,
)

0 comments on commit bbbbfeb

Please sign in to comment.