Bitbucket Build Status

Caution

This example is for reference only. It is not extensively tested, and it not intended to be a fully-fledged Concourse resource for production pipelines. Copy and paste at your own risk.

This example will (mostly) recreate the excellent SHyx0rmZ/bitbucket-build-status-resource - specifically version 1.6.0. This is an ideal candidate to show how boiler plate code can be massively reduced to a single module concerning itself with nothing but the internal logic of your resource type.

We only care about updating the resource, and so the OutOnlyConcourseResource seems like the best route.

Build Status Version

A quick glance through the original code shows that the version contains only the build status. Since the build status must be one of three specific values, the correct thing to do is to define an Enum:

class BuildStatus(Enum):
    SUCCESSFUL = auto()
    INPROGRESS = auto()
    FAILED = auto()

The benefit of using an enum (other than it being arguably the “correct” thing to do) is that we don’t need to keep checking membership, and we can rely on the enum to raise an error upon user error. We also only need to worry about updating the enum in a single place when we need to.

Next we define the version. To make things easier, we can forget about converting the enum to a string and simply rely on TypedVersion to do it for us:

@dataclass
class Version(TypedVersion):
    build_status: BuildStatus

Build Status Resource

Next we need to think about the resource. The hardest thing about this example is that there are a number of required parameters, which are actually only required in one of two circumstances. This unfortunately means that we can’t rely on Concourse Tools to automatically catch missing parameters.

The original solves this problem through the use of driver subclasses, but since the number of conditionals remain pretty much the same we will avoid that for now, and it can always be refactored later. We can however deal with the variables in sensible groups, and rely on the right errors being thrown if the user gives the wrong parameters.

Note

The original contains a number of deprecated parameters which have not been included in this example. These could be maintained quite easily.

When we collect all resource parameters and place them in a class it can seem quite daunting:

class Resource(OutOnlyConcourseResource[Version]):

    def __init__(self, repository: str | None = None, endpoint: str | None = None,
                 username: str | None = None, password: str | None = None,
                 client_id: str | None = None, client_secret: str | None = None,
                 verify_ssl: bool = True, driver: str = "Bitbucket Server",
                 debug: bool = False) -> None:
        super().__init__(Version)

Bitbucket Authentication

The first parameter group are the authentication parameters: username, password, client_id and client_secret. We’ll ignore that the latter two don’t make sense for Bitbucket Server, and let the request fail if a user decides to try. The most Pythonic thing to do is to save an auth attribute to be used instead of the individual values, which will clutter the instance otherwise. We want a function to filter them out. If the user has passed username and password, we want to use HTTPBasicAuth:

def create_auth(username: str | None = None,
                password: str | None = None,
                client_id: str | None = None,
                client_secret: str | None = None) -> AuthBase:
    if username is not None and password is not None:
        auth: AuthBase = HTTPBasicAuth(username, password)

If the user instead passes client_id and client_secret, we instead need to use OAuth to fetch a bearer token. Neither of these are available natively in requests, and so we need to implement our own. The original resource has the following:

class BitbucketOAuth(AuthBase):
    """
    Adds the correct auth token for OAuth access to bitbucket.com.
    """
    def __init__(self, access_token: str):
        self.access_token = access_token

    def __call__(self, request: requests.Request) -> requests.Request:
        request.headers["Authorization"] = f"Bearer {self.access_token}"
        return request

We can then copy the original code for requesting the access token into an alternative constructor for ease:

class BitbucketOAuth(AuthBase):
    """
    Adds the correct auth token for OAuth access to bitbucket.com.
    """
    def __init__(self, access_token: str):
        self.access_token = access_token

    def __call__(self, request: requests.Request) -> requests.Request:
        request.headers["Authorization"] = f"Bearer {self.access_token}"
        return request

    @classmethod
    def from_client_credentials(cls, client_id: str, client_secret: str) -> BitbucketOAuth:
        token_auth = HTTPBasicAuth(client_id, client_secret)

        url = "https://bitbucket.org/site/oauth2/access_token"
        data = {"grant_type": "client_credentials"}

        token_response = requests.post(url, auth=token_auth, data=data)
        token_response.raise_for_status()
        access_token = token_response.json()["access_token"]
        return cls(access_token)

That way the create_auth function becomes quite simple:

def create_auth(username: str | None = None,
                password: str | None = None,
                client_id: str | None = None,
                client_secret: str | None = None) -> AuthBase:
    if username is not None and password is not None:
        auth: AuthBase = HTTPBasicAuth(username, password)
    elif client_id is not None and client_secret is not None:
        auth = BitbucketOAuth.from_client_credentials(client_id, client_secret)
    else:
        raise ValueError("Must set username/password or OAuth credentials")
    return auth

Bitbucket Drivers

We still need to differentiate between each type of driver, in order to know which parameters to complain about. It may be considered overkill, but I would choose to define another Enum:

class Driver(Enum):
    SERVER = "Bitbucket Server"
    CLOUD = "Bitbucket Cloud"

Again, this is slightly more resilient to change than checking a string all the time. Of course, the driver parameter to the resource has to be a string, so a lookup is required:

try:
    self.driver = Driver(driver)
except ValueError:
    possible_values = {enum.value for enum in Driver._member_map_.values()}
    raise ValueError(f"Driver must be one of the following: "

Now we can just check against the enum throughout our code. The initialiser now looks like this:

class Resource(OutOnlyConcourseResource[Version]):

    def __init__(self, repository: str | None = None, endpoint: str | None = None,
                 username: str | None = None, password: str | None = None,
                 client_id: str | None = None, client_secret: str | None = None,
                 verify_ssl: bool = True, driver: str = "Bitbucket Server",
                 debug: bool = False) -> None:
        super().__init__(Version)
        try:
            self.driver = Driver(driver)
        except ValueError:
            possible_values = {enum.value for enum in Driver._member_map_.values()}
            raise ValueError(f"Driver must be one of the following: "
                             f"{possible_values}, not {driver!r}")

        self.auth = create_auth(username, password, client_id, client_secret)

        self.repository = repository
        self.endpoint = endpoint

        self.verify_ssl = verify_ssl
        self._debug = debug

        if self.driver is Driver.SERVER:
            if endpoint is None:
                raise ValueError("Must set endpoint when using Bitbucket Server.")
            else:
                endpoint = endpoint.rstrip("/")

        if self.driver is Driver.CLOUD:
            if repository is None:
                raise ValueError("Must set repository when using Bitbucket Cloud.")

Updating the Resource

We can now write the code to update the resource, which involves implementing publish_new_version(). The method takes the usual parameters, and then the additional arguments offered by the original:

Note

The original configures a few variables via files instead of direct parameters. This is likely due to it being implemented before the load var step was a thing. This implementation replaces them with direct variables.

def publish_new_version(self, sources_dir: Path, build_metadata: BuildMetadata,
                        repository: str, build_status: str, key: str | None = None,
                        name: str | None = None, build_url: str | None = None,
                        description: str | None = None,
                        commit_hash: str | None = None) -> tuple[Version, Metadata]:

First up is debugging. The original allows the user to pass debug to the resource and have additional information be shown in the console. This required an additional function to remember, but not so with Concourse tools: we can just print!

if self._debug:
    print("--DEBUG MODE--")

This will then show up in the console when debugging has been activated. We could go one further and simplify our code with a debug function, which will also allow coloured output to be printed:

def debug(self, *args: object, colour: str = Colour.CYAN, **kwargs: Any) -> None:
    if self._debug:
        colour_print(*args, colour=colour, **kwargs)

Remember creating that Build Status enum? We should make use of it here:

try:
    status = BuildStatus[build_status]
except KeyError:
    possible_values = set(BuildStatus._member_names_)
    raise ValueError(f"Build status must be one of the following: "
                     f"{possible_values}, not {build_status!r}")

Next we need to fetch the commit hash (if it hasn’t already been set). This is more or less a direct lift from the original code, except we can make use of the fact that sources_dir is both already available to us (no shenanigans with sys.argv), but is also a Path instance, which makes the code a bit shorter:

if commit_hash is None:
    if repository is None:
        raise ValueError("Missing repository parameter.")
    repo_path = sources_dir / repository
    mercurial_path = repo_path / ".hg"
    git_path = repo_path / ".git"

    if mercurial_path.exists():
        command = ["hg", "R", str(repo_path), "log",
                   "--rev", ".", "--template", r"{node}"]
    elif git_path.exists():
        command = ["git", "-C", str(repo_path), "rev-parse", "HEAD"]
    else:
        raise RuntimeError("Cannot detect a repository.")

    commit_hash = subprocess.check_output(command).strip().decode()

Tip

If I were coding this from scratch, I would make use of the GitPython package (and the Mercurial one if it were actually stable) as part of the benefit of using Concourse Tools is having immediate access to the entire Python ecosystem, but I admit that it might seem a touch excessive given the simple command we need to run.

Next we need to figure out the default parameters for the build status. This is where Concourse Tools truly shines, as we require access to the Build Metadata. The publish_new_version() method is passed a BuildMetadata instance which contains attributes for everything that Concourse makes available to the resource, as well as some helpful convenience functions for dealing with one-off builds and instanced pipelines. In particular, it includes a build_url() method for calculating an exact URL for the build (instanced pipelines and all), which means that this original code is condensed down from 39 lines to just one:

build_url = build_url or build_metadata.build_url()

The other defaults follow in a similar fashion:

key = key or build_metadata.BUILD_JOB_NAME or f"one-off-build-{build_metadata.BUILD_ID}"

self.debug(f"Build URL: {build_url}")

description = description or f"Concourse CI build, hijack as #{build_metadata.BUILD_ID}"

if name is None:
    if build_metadata.is_one_off_build:
        name = f"One-off build #{build_metadata.BUILD_ID}"
    else:
        name = f"{build_metadata.BUILD_JOB_NAME} #{build_metadata.BUILD_NAME}"

Next, we determine the URL to which to post() our request:

if self.driver is Driver.SERVER:
    post_url = f"{self.endpoint}/rest/build-status/1.0/commits/{commit_hash}"
    if self.verify_ssl is False:
        disable_ssl_warnings()
        self.debug("SSL warnings disabled\n")

else:
    post_url = f"https://api.bitbucket.org/2.0/repositories/{self.repository}/commit/{commit_hash}/statuses/build"

Tip

Added in version 0.8.0: The format_string() method can be used to substitute build metadata into the status description in order to allow users to reference the build:

description = build_metadata.format_string(description)

Finally we submit the request and return the new version:

data = {
    "state": status.name,
    "key": key,
    "name": name,
    "url": build_url,
    "description": description,
}

self.debug(f"Set build status: {data}")

response = requests.post(post_url, json=data, auth=self.auth, verify=self.verify_ssl)

self.debug(f"Request result: {response.json()}")

version = Version(status)
metadata = {
    "HTTP Status Code": str(response.status_code),
}
return version, metadata

Note

The original doesn’t return any metadata, but this example does in order to illustrate how it works.

Conclusion

The original repository scripts cover 12 Python files and a total of 433 lines. With Concourse tools, this same code is covered in 187 lines, and a single Python file:

  1# (C) Crown Copyright GCHQ
  2from __future__ import annotations
  3
  4from dataclasses import dataclass
  5from enum import Enum, auto
  6from pathlib import Path
  7import subprocess
  8from typing import Any
  9
 10import requests
 11from requests.auth import AuthBase, HTTPBasicAuth
 12from urllib3 import disable_warnings as disable_ssl_warnings
 13
 14from concoursetools import BuildMetadata
 15from concoursetools.additional import OutOnlyConcourseResource
 16from concoursetools.colour import Colour, colour_print
 17from concoursetools.typing import Metadata
 18from concoursetools.version import TypedVersion
 19
 20
 21class Driver(Enum):
 22    SERVER = "Bitbucket Server"
 23    CLOUD = "Bitbucket Cloud"
 24
 25
 26class BuildStatus(Enum):
 27    SUCCESSFUL = auto()
 28    INPROGRESS = auto()
 29    FAILED = auto()
 30
 31
 32class BitbucketOAuth(AuthBase):
 33    """
 34    Adds the correct auth token for OAuth access to bitbucket.com.
 35    """
 36    def __init__(self, access_token: str):
 37        self.access_token = access_token
 38
 39    def __call__(self, request: requests.Request) -> requests.Request:
 40        request.headers["Authorization"] = f"Bearer {self.access_token}"
 41        return request
 42
 43    @classmethod
 44    def from_client_credentials(cls, client_id: str, client_secret: str) -> BitbucketOAuth:
 45        token_auth = HTTPBasicAuth(client_id, client_secret)
 46
 47        url = "https://bitbucket.org/site/oauth2/access_token"
 48        data = {"grant_type": "client_credentials"}
 49
 50        token_response = requests.post(url, auth=token_auth, data=data)
 51        token_response.raise_for_status()
 52        access_token = token_response.json()["access_token"]
 53        return cls(access_token)
 54
 55
 56@dataclass
 57class Version(TypedVersion):
 58    build_status: BuildStatus
 59
 60
 61class Resource(OutOnlyConcourseResource[Version]):
 62
 63    def __init__(self, repository: str | None = None, endpoint: str | None = None,
 64                 username: str | None = None, password: str | None = None,
 65                 client_id: str | None = None, client_secret: str | None = None,
 66                 verify_ssl: bool = True, driver: str = "Bitbucket Server",
 67                 debug: bool = False) -> None:
 68        super().__init__(Version)
 69        try:
 70            self.driver = Driver(driver)
 71        except ValueError:
 72            possible_values = {enum.value for enum in Driver._member_map_.values()}
 73            raise ValueError(f"Driver must be one of the following: "
 74                             f"{possible_values}, not {driver!r}")
 75
 76        self.auth = create_auth(username, password, client_id, client_secret)
 77
 78        self.repository = repository
 79        self.endpoint = endpoint
 80
 81        self.verify_ssl = verify_ssl
 82        self._debug = debug
 83
 84        if self.driver is Driver.SERVER:
 85            if endpoint is None:
 86                raise ValueError("Must set endpoint when using Bitbucket Server.")
 87            else:
 88                endpoint = endpoint.rstrip("/")
 89
 90        if self.driver is Driver.CLOUD:
 91            if repository is None:
 92                raise ValueError("Must set repository when using Bitbucket Cloud.")
 93
 94    def publish_new_version(self, sources_dir: Path, build_metadata: BuildMetadata,
 95                            repository: str, build_status: str, key: str | None = None,
 96                            name: str | None = None, build_url: str | None = None,
 97                            description: str | None = None,
 98                            commit_hash: str | None = None) -> tuple[Version, Metadata]:
 99        self.debug("--DEBUG MODE--")
100
101        try:
102            status = BuildStatus[build_status]
103        except KeyError:
104            possible_values = set(BuildStatus._member_names_)
105            raise ValueError(f"Build status must be one of the following: "
106                             f"{possible_values}, not {build_status!r}")
107
108        if commit_hash is None:
109            if repository is None:
110                raise ValueError("Missing repository parameter.")
111            repo_path = sources_dir / repository
112            mercurial_path = repo_path / ".hg"
113            git_path = repo_path / ".git"
114
115            if mercurial_path.exists():
116                command = ["hg", "R", str(repo_path), "log",
117                           "--rev", ".", "--template", r"{node}"]
118            elif git_path.exists():
119                command = ["git", "-C", str(repo_path), "rev-parse", "HEAD"]
120            else:
121                raise RuntimeError("Cannot detect a repository.")
122
123            commit_hash = subprocess.check_output(command).strip().decode()
124
125        self.debug(f"Commit: {commit_hash}")
126
127        build_url = build_url or build_metadata.build_url()
128
129        key = key or build_metadata.BUILD_JOB_NAME or f"one-off-build-{build_metadata.BUILD_ID}"
130
131        self.debug(f"Build URL: {build_url}")
132
133        description = description or f"Concourse CI build, hijack as #{build_metadata.BUILD_ID}"
134
135        if name is None:
136            if build_metadata.is_one_off_build:
137                name = f"One-off build #{build_metadata.BUILD_ID}"
138            else:
139                name = f"{build_metadata.BUILD_JOB_NAME} #{build_metadata.BUILD_NAME}"
140
141        description = build_metadata.format_string(description)
142
143        if self.driver is Driver.SERVER:
144            post_url = f"{self.endpoint}/rest/build-status/1.0/commits/{commit_hash}"
145            if self.verify_ssl is False:
146                disable_ssl_warnings()
147                self.debug("SSL warnings disabled\n")
148
149        else:
150            post_url = f"https://api.bitbucket.org/2.0/repositories/{self.repository}/commit/{commit_hash}/statuses/build"
151
152        data = {
153            "state": status.name,
154            "key": key,
155            "name": name,
156            "url": build_url,
157            "description": description,
158        }
159
160        self.debug(f"Set build status: {data}")
161
162        response = requests.post(post_url, json=data, auth=self.auth, verify=self.verify_ssl)
163
164        self.debug(f"Request result: {response.json()}")
165
166        version = Version(status)
167        metadata = {
168            "HTTP Status Code": str(response.status_code),
169        }
170        return version, metadata
171
172    def debug(self, *args: object, colour: str = Colour.CYAN, **kwargs: Any) -> None:
173        if self._debug:
174            colour_print(*args, colour=colour, **kwargs)
175
176
177def create_auth(username: str | None = None,
178                password: str | None = None,
179                client_id: str | None = None,
180                client_secret: str | None = None) -> AuthBase:
181    if username is not None and password is not None:
182        auth: AuthBase = HTTPBasicAuth(username, password)
183    elif client_id is not None and client_secret is not None:
184        auth = BitbucketOAuth.from_client_credentials(client_id, client_secret)
185    else:
186        raise ValueError("Must set username/password or OAuth credentials")
187    return auth