bitbucket

package module
v0.10.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 29, 2026 License: MIT Imports: 18 Imported by: 0

README

forge-bitbucket

Bitbucket release provider for forge

Go Reference Pipeline Coverage phpboyscout Go toolkit

Part of the phpboyscout Go toolkit. Full documentation lives on the core module's site: forge.go.phpboyscout.uk


Implements the forge.Provider contract over the Bitbucket Downloads API, speaking it over net/http.

Why there is no SDK here

Atlassian publishes no Go SDK. A maintained third-party client does exist — github.com/ktrysmt/go-bitbucket — and is deliberately not used: every method returns interface{}, so adopting it would replace typed structs with untyped map lookups throughout. That is a loss of compile-time safety rather than a saving.

The alternatives do not fit either. ctreminiom/go-atlassian covers only workspace webhooks, permissions and authentication for Bitbucket; gfleury/go-bitbucket-v1 targets Bitbucket Server / Data Center, a different product; wbrefvem/go-bitbucket has been unmaintained since 2019.

Hand-rolling turned out to have an unplanned benefit. The other three providers in this family wrap SDKs that dereference response fields without checking they are present, so a malformed or truncated response panics rather than erroring — they route every call through forge.GuardPanic as a result. This provider decodes into its own types with encoding/json, where an absent field is a zero value, so it was never exposed.

A deliberately partial provider

Bitbucket Downloads is a file bucket, not a release system. There are no tags and no release list, so two of the release methods opt out — and three optional capabilities are absent because the forge has no equivalent feature at all:

Method Behaviour
GetLatestRelease ✅ Synthesised from the downloads listing
GetReleaseByTag ErrNotSupported
ListReleases ErrNotSupported
DownloadReleaseAsset
ChecksumProvider ✅ Locates checksums.txt by exact name
SignatureProvider ✅ Locates checksums.txt.sig by exact name
KeyManager ✅ Uploads an SSH public key to the account (POST /2.0/users/{username}/ssh-keys)
Authenticator ❌ Not implemented — Bitbucket has no interactive login here
Repositories ✅ Enumerates a workspace. A Bitbucket project is not an enumerable namespace: a repository's canonical path is workspace/repo, so a project-scoped namespace could never satisfy the contract's containment guarantee
Contents ✅ Reads one file at a ref, bounded both by the reported size and by an io.LimitReader
Sites ❌ Not implemented — Bitbucket Cloud has no static site hosting
Snippets ❌ Not implemented — Bitbucket Cloud withdrew snippets (410 Gone, CHANGE-2770)
Issues / IssueFiler ❌ Not implemented — see below
Issues are not implemented, and will not be

Atlassian is removing Bitbucket Cloud Issues. They stopped being enablable on repositories not already using them in April 2026, and are removed entirely in mid-August 2026 (announcement).

So forge.Issues and forge.IssueFiler are not implemented here, and a caller's type assertion fails rather than the capability being present and empty. The other three first-party providers implement both.

Nothing else is affected: the sunset covers Issues and Wikis only, not Downloads, repositories or file contents.

Callers branch on ErrNotSupported and fall back — see optional capabilities. It implements both the checksum and signature optional interfaces, because without a release object there is nothing else to hang a manifest on.

SSH-key upload and login

Bitbucket authenticates with a username and app password, not an OAuth app, so it deliberately implements no Authenticator capability — there is no interactive device-flow login to offer. Setup falls back to manual app-password entry.

It does implement the optional KeyManager capability: UploadKey registers an OpenSSH-format public key on the account via POST /2.0/users/{username}/ssh-keys, authorised by the configured username and app password. With no credentials configured it returns forge.ErrNotSupported — Bitbucket exposes no unauthenticated key API — and the caller falls back to manual key entry.

Use it

import (
    "gitlab.com/phpboyscout/go/forge"

    _ "gitlab.com/phpboyscout/go/forge-bitbucket"
)

factory, err := forge.Lookup("bitbucket")
provider, err := factory(ctx, ep, cfg)
go get gitlab.com/phpboyscout/go/forge-bitbucket

Configuration

Bitbucket needs two credentials — a username and an app password — not a single token, so it holds two forge.CredentialSource values. Each resolves independently, which is what makes partial rotation possible.

The default composition, highest precedence first:

Key Purpose
bitbucket.keychain OS keychain entry holding a JSON blob with both fields
bitbucket.username / bitbucket.app_password Values from configuration
BITBUCKET_USERNAME / BITBUCKET_APP_PASSWORD Well-known fallbacks

The keychain holds one entry rather than two, because a username and app password are useless apart — and it is read once per construction, not once per field, so one secret costs one unlock prompt.

Set Settings.UsernameSource and Settings.AppPasswordSource to replace that order entirely, or to hand credentials in directly:

settings.UsernameSource = forge.StaticCredential(user)
settings.AppPasswordSource = forge.StaticCredential(appPassword)

[!IMPORTANT] bitbucket.username.env and bitbucket.app_password.env are no longer read. They were this module's spelling of forge's removed auth.env rung — a config value naming an environment variable — and where a credential lives is now your layer composition rather than a value inside the configuration.

Set BITBUCKET_USERNAME / BITBUCKET_APP_PASSWORD, put the value in bitbucket.username / bitbucket.app_password, or compose a source of your own. Configuration still carrying the old keys reports forge.ErrStaleAuthKeys — but only when nothing else resolved, so a stale key beside a working variable stays quiet.

Two details of that lookup are deliberate. It is bounded by a 5-second timeout, so a misbehaving remote-store backend cannot stall start-up. And a corrupt or incomplete blob aborts resolution rather than falling through — a broken keychain entry is surfaced, not silently masked by a stale literal further down the chain.

No provider refuses to build for want of a credential: whether the repository you go on to ask about is private is not knowable at construction, since one connection serves both. When a request IS refused and no credential resolved, this provider adds guidance naming the variables to set — a better error, not an earlier one.

How a release is synthesised

Downloads is a flat file list, so a "release" is assembled rather than fetched:

  1. List every file via GET /2.0/repositories/{workspace}/{repo}/downloads (paginated automatically).
  2. Apply the filename pattern to extract a version from each name.
  3. Group assets by the version they yielded.
  4. Sort by upload time, newest first — that group is the latest release.

Step 4 is worth remembering: re-uploading an old asset makes its version look latest, because recency is upload time, not version ordering.

Version detection

The default pattern matches GoReleaser-style names:

^.+?(?:_(v?\d+\.\d+\.\d+[^_]*))?_([A-Za-z]+)_([A-Za-z0-9_]+)\.tar\.gz$

Capture group 1 is the version. Override with the filename_pattern param when your naming differs, keeping that contract. The pattern is compiled under a ReDoS bound via regexutil, and an invalid pattern fails at provider construction rather than at first use.

[!IMPORTANT] When a filename yields no version, the provider falls back to the file's upload timestamp in RFC 3339 — so mytool update may report a version like 2026-03-31T12:00:00Z. This keeps self-update working for unversioned filenames, but it is rarely what you want: name your assets with a version, or set filename_pattern to match the ones you have.

Limitations
  • No release metadata. Downloads carries filenames and URLs, so there are no titles, descriptions or changelogs — release notes are unavailable.
  • Version detection is entirely filename-driven. There is no authoritative version anywhere else to fall back on.
  • Large repositories can be slow. Pagination is automatic, but every page is fetched before a release can be assembled.

Security

Credentials travel as basic auth, attached only to requests on the configured API host via forge.HostTrusted. Host, port and scheme must all match — the scheme check matters more here than elsewhere, since a downgrade would put a username and app password on the wire in cleartext.

Integration tests

Unit tests run hermetically against httptest fixtures. Two env-gated tests additionally exercise the live Bitbucket API, pinning the fixtures' links.self.href URL shape to reality (https://api.bitbucket.org/2.0/repositories/{ws}/{repo}/downloads/{file}) and running an end-to-end checksum-manifest resolution:

Test What it proves
TestIntegration_LiveDownloadsSelfLinkShape live links.self.href values are API-shaped, matching the unit fixtures
TestIntegration_ChecksumManifestResolution checksum lookup completes (success or ErrNotSupported) against the live API

Enable with INT_TEST=1 or INT_TEST_FORGE_BITBUCKET=1. The target must be a public repository with at least one file in Downloads; override the default (phpboyscout/forge-bitbucket-fixture) with INT_TEST_BITBUCKET_REPO="workspace/repo". Note that Free-plan workspaces cannot host Downloads, so the fixture repository has to live in a Standard-plan (or better) workspace.

Documentation

Guides, the provider contract, and how to author your own: forge.go.phpboyscout.uk.

API reference: pkg.go.dev.

License

See LICENSE.

Documentation

Overview

Package bitbucket provides a forge.Provider implementation for Bitbucket Cloud using the Downloads API. Bitbucket has no native "Releases" concept; version information is inferred from asset filenames using a configurable regular expression. Provider construction uses package-owned Settings; GTB config integration lives in SettingsFromConfig.

Alongside the release contract it implements the optional gitlab.com/phpboyscout/go/forge.KeyManager capability (SSH-key upload via the account SSH-keys API). It deliberately implements no gitlab.com/phpboyscout/go/forge.Authenticator: Bitbucket authenticates with a username and app password, so there is no interactive login to offer and setup falls back to manual credential entry.

The connection ladder stops one rung short here

Spec 0008's ladder offers a native-client rung (D5) — a constructor taking the platform SDK's own client — for the adapters that have one. This module has no platform SDK: it speaks the Bitbucket API over plain net/http, so its native unit IS the net/http.Client, and a rung taking one would duplicate gitlab.com/phpboyscout/go/forge.WithHTTPClient rather than add anything.

The absence is therefore deliberate, and the ladder here starts at the transport and client rungs, which this module does honour. See connection.go.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type BitbucketReleaseProvider

type BitbucketReleaseProvider struct {
	// contains filtered or unexported fields
}

BitbucketReleaseProvider implements forge.Provider for Bitbucket Cloud.

func NewReleaseProvider

func NewReleaseProvider(ctx context.Context, settings Settings) (*BitbucketReleaseProvider, error)

NewReleaseProvider constructs a BitbucketReleaseProvider from explicit typed settings.

Both credential halves are resolved here, bounded by ctx: a source the caller supplied may reach a keychain or a remote secret store. An error means every source for that field was broken rather than merely ABSENT — absence is not a failure, and construction succeeds without a credential.

It has to: one connection serves public and private repositories alike, so whether a credential is needed is knowable at the operation and not before. A refusal with none resolved carries guidance instead; see refuse.

func (*BitbucketReleaseProvider) Close added in v0.10.0

func (p *BitbucketReleaseProvider) Close(
	ctx context.Context, owner, repo string, number int,
) error

Close declines a pull request without merging it.

Bitbucket spells this as its own endpoint rather than a state change, which is why there is no PUT of state="DECLINED" here.

func (*BitbucketReleaseProvider) Create added in v0.10.0

func (p *BitbucketReleaseProvider) Create(
	ctx context.Context, owner, repo string, draft forge.PullRequestDraft,
) (forge.PullRequest, error)

Create opens a pull request.

func (*BitbucketReleaseProvider) DownloadChecksumManifest

func (p *BitbucketReleaseProvider) DownloadChecksumManifest(ctx context.Context, rel forge.Release, maxBytes int64) ([]byte, error)

DownloadChecksumManifest implements forge.ChecksumProvider by locating an uploaded `checksums.txt` by exact filename in the repository's downloads list. The filename regex used by [matchAssets] is intentionally tight around the binary pattern, so the manifest is not picked up there; this method bypasses the regex for the well-known manifest name.

Returns forge.ErrNotSupported when the downloads list contains no `checksums.txt`, so the caller treats it the same as "provider has no manifest support" and respects require_checksum policy.

func (*BitbucketReleaseProvider) DownloadReleaseAsset

func (p *BitbucketReleaseProvider) DownloadReleaseAsset(ctx context.Context, _, _ string, asset forge.ReleaseAsset) (io.ReadCloser, string, error)

DownloadReleaseAsset streams the asset at its BrowserDownloadURL.

func (*BitbucketReleaseProvider) DownloadSignature

func (p *BitbucketReleaseProvider) DownloadSignature(ctx context.Context, rel forge.Release, maxBytes int64) ([]byte, error)

DownloadSignature implements forge.SignatureProvider by locating an uploaded `checksums.txt.sig` by exact filename in the downloads list. Returns forge.ErrNotSupported when no signature file was uploaded, so the caller respects the require_signature policy.

func (*BitbucketReleaseProvider) Find added in v0.10.0

func (p *BitbucketReleaseProvider) Find(
	ctx context.Context, owner, repo, sourceBranch string,
) (forge.PullRequest, error)

Find returns the OPEN pull request opened from sourceBranch.

func (*BitbucketReleaseProvider) FindLastMerged added in v0.10.0

func (p *BitbucketReleaseProvider) FindLastMerged(
	ctx context.Context, owner, repo, sourceBranch string,
) (forge.PullRequest, error)

FindLastMerged returns the most recently merged pull request from sourceBranch, ordered by merge time.

Bitbucket records no merge time

The pull request carries created_on and updated_on and nothing that names when it merged. So updated_on is what orders here, and that is a WEAKER guarantee than the other two adapters give: a comment posted after a merge moves it.

It is still the best available, and it is right far more often than it is wrong — a merged pull request is usually not touched again. The contract's MergedAt is populated from it so a caller can see what it was ordered by rather than being told a merge time that does not exist.

func (*BitbucketReleaseProvider) GetFile added in v0.3.0

func (p *BitbucketReleaseProvider) GetFile(
	ctx context.Context, owner, repo, path, ref string, maxBytes int64,
) ([]byte, error)

GetFile reads one file at a ref without cloning.

The bound is enforced twice, and both are load-bearing. The size is checked from format=meta BEFORE the body is requested, so an oversized file costs a metadata request rather than a download; and the body itself is read through an io.LimitReader, so a server that under-reports its size still cannot stream past the bound. Bitbucket is the only adapter that gets both, because it is the only one not going through an SDK that buffers on its behalf.

func (*BitbucketReleaseProvider) GetLatestRelease

func (p *BitbucketReleaseProvider) GetLatestRelease(ctx context.Context, owner, repo string) (forge.Release, error)

GetLatestRelease returns a synthetic release built from the most recently uploaded Downloads that match the filename pattern.

func (*BitbucketReleaseProvider) GetReleaseByTag

func (p *BitbucketReleaseProvider) GetReleaseByTag(_ context.Context, _, _, _ string) (forge.Release, error)

GetReleaseByTag is not supported for Bitbucket Downloads.

func (*BitbucketReleaseProvider) ListReleases

func (p *BitbucketReleaseProvider) ListReleases(_ context.Context, _, _ string, _ int) ([]forge.Release, error)

ListReleases is not supported for Bitbucket Downloads.

func (*BitbucketReleaseProvider) ListRepositories added in v0.3.0

func (p *BitbucketReleaseProvider) ListRepositories(
	ctx context.Context,
	namespace string,
	opts forge.RepositoryListOptions,
	yield func(forge.Repository) bool,
) error

ListRepositories enumerates the repositories in a Bitbucket workspace.

Bitbucket needs no organisation/user resolution: one endpoint serves a workspace whether it belongs to a team or a person, so the namespace is used directly. That is a genuine simplification rather than an omission.

A Bitbucket "project" — a grouping of repositories inside a workspace — is NOT an enumerable namespace here. A repository's canonical path is workspace/repo, so a project-scoped namespace could never satisfy the contract's containment guarantee, and synthesising workspace/project/repo to make it look like it did would be a lie.

func (*BitbucketReleaseProvider) ResolveMergedCommit added in v0.10.0

func (p *BitbucketReleaseProvider) ResolveMergedCommit(
	ctx context.Context, owner, repo string, number int,
) (string, error)

ResolveMergedCommit returns the commit ON THE TARGET BRANCH that this pull request produced.

Candidates cheapest first: the recorded merge commit, then a reverse-lookup walk of the target branch. EVERY candidate goes through confirmOnBranch before it is returned.

The fallback may be unavailable here, and that is not a bug

Bitbucket's reverse lookup depends on an app a HUMAN installed — see commitClaimedBy. Where the recorded merge commit does not confirm and that endpoint is absent, this adapter has no second route and returns ErrNotFound. That is the contract working: a refusal rather than a guess is exactly what this method promises when it cannot establish an answer. Bitbucket simply reaches that refusal more often than the others.

func (*BitbucketReleaseProvider) SetAPIBase

func (p *BitbucketReleaseProvider) SetAPIBase(base string)

SetAPIBase overrides the Bitbucket API base URL. Intended for testing only.

func (*BitbucketReleaseProvider) Update added in v0.10.0

func (p *BitbucketReleaseProvider) Update(
	ctx context.Context, owner, repo string, number int, title, body string,
) error

Update replaces the title and body.

Both are sent unconditionally, as the contract requires: a forge distinguishes "field omitted, leave it" from "field sent empty, clear it", and sending both every time is what makes the caller's intent unambiguous.

func (*BitbucketReleaseProvider) UploadKey added in v0.2.0

func (p *BitbucketReleaseProvider) UploadKey(ctx context.Context, name string, publicKey []byte) error

UploadKey implements the optional forge.KeyManager capability: it registers an OpenSSH-format public key on the authenticated Bitbucket account via the account SSH-keys API (`POST /2.0/users/{username}/ssh-keys`). The configured username and app password authorise the call; name becomes the key's label in the account's key list.

Bitbucket has no unauthenticated key API and no interactive login, so an unconfigured provider returns an error wrapping forge.ErrNotSupported — the caller treats it exactly like "provider does not implement this capability" and falls back to manual app-password-authenticated entry.

type Settings

type Settings struct {
	// Endpoint addresses this instance. Type is [forge.SourceTypeBitbucket] and
	// also selects the configuration subtree read by [SettingsFromConfig]; Name
	// selects which configured source this is.
	//
	// Host is unused: the API base is hard-pinned to api.bitbucket.org, because
	// Bitbucket Cloud is the only instance this provider speaks to.
	Endpoint forge.Endpoint

	// UsernameSource and AppPasswordSource yield the two halves of a Bitbucket
	// credential. Nil means absent, which is only an error for a private
	// repository. To supply them directly:
	//
	//	UsernameSource:    forge.StaticCredential(user),
	//	AppPasswordSource: forge.StaticCredential(appPassword),
	UsernameSource    forge.CredentialSource
	AppPasswordSource forge.CredentialSource

	FilenamePattern string

	// Logger receives this provider's diagnostics. Nil discards them, and is
	// never [slog.Default]. See [forge.WithLogger] for the registry route.
	Logger *slog.Logger

	// ConfigWarning describes a configuration problem detected while building
	// these settings. It is NOT an error: the settings are usable and the
	// provider will build. The constructor logs it at WARN.
	//
	// [SettingsFromConfig] sets it when the configuration it was handed looks
	// like a pre-scoped subtree rather than the root — a mistake that resolves
	// no credential and reports nothing, so a diagnostic is the only way a
	// caller learns of it. See [forge.PreScopedConfig] and spec 0011.
	ConfigWarning error

	// HTTPTransport is the transport this provider builds its clients on, so
	// several providers share one connection pool and TLS session cache. Nil
	// means it builds its own, which is the default and always valid.
	//
	// This provider still builds the client, and so keeps its own redirect
	// policy. It is the rung to prefer. Set through the registry with
	// [forge.WithHTTPTransport].
	HTTPTransport http.RoundTripper

	// HTTPClient replaces the client this provider would have built for its own
	// API requests — redirect policy included, and the obligation with it.
	//
	// It is NOT used for asset downloads; see connection.go for why. Set
	// through the registry with [forge.WithHTTPClient].
	HTTPClient *http.Client
}

Settings contains the typed configuration needed to construct a Bitbucket release provider without binding the provider to any config container.

Bitbucket needs TWO credentials rather than one, so it holds two sources. They are separate because partial configuration is legitimate and occasionally useful during rotation — a username from the environment while the app password still comes from the keychain.

func SettingsFromConfig

func SettingsFromConfig(ctx context.Context, ep forge.Endpoint, cfg forge.Config) (Settings, error)

SettingsFromConfig adapts the bitbucket config subtree into typed provider settings.

The keychain blob is read ONCE here, not once per field: it holds both halves of the credential in a single entry, so two reads would mean two unlock prompts for one secret. The decoded fields are then closed over by the two composed sources.

cfg is the ROOT configuration, not a pre-scoped subtree: the endpoint resolves its own section, because which subtree a source reads is part of what the endpoint means.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL