Documentation
¶
Index ¶
- Constants
- Variables
- func AppendIdempotencyKey(body, key string) string
- func As(p Provider, target any) bool
- func GuardPanic(fn func() error) (err error)
- func GuardPanicValue[T any](fn func() (T, error)) (value T, err error)
- func HasLiveMention(s string) bool
- func HostTrusted(rawURL, trustedBaseURL string, opts ...TrustOption) bool
- func IdempotencySearchText(key string) string
- func InertMentions(s string) string
- func NormaliseLogger(logger *slog.Logger) *slog.Logger
- func NormalizeHostURL(host string) (string, error)
- func ParseRetryAfter(header http.Header, now time.Time) (time.Duration, bool)
- func PreScopedConfig(ep Endpoint, cfg Config, witness ...string) bool
- func RateLimited(cause error, retryAfter time.Duration) error
- func RedactingHandler(handler slog.Handler) slog.Handler
- func Register(sourceType string, factory ProviderFactory) error
- func Registered(sourceType string) bool
- func RegisteredTypes() []string
- func RenderAssetFooter(body string, assets []ReleaseAssetSource) string
- func RetryAfter(err error) (time.Duration, bool)
- func Sanitise(s string) string
- func SplitRepoPath(path string) (owner, repo string)
- func Unregister(sourceType string) bool
- type Authenticator
- type ChecksumProvider
- type Comment
- type CommentDraft
- type CommentQuery
- type Config
- type Contents
- type CredentialSource
- type DeviceCode
- type DraftPublisher
- type Endpoint
- type GitRemote
- type Issue
- type IssueCommenter
- type IssueDraft
- type IssueFiler
- type IssueQuery
- type IssueState
- type Issues
- type KeyManager
- type MergeRequest
- type MergeRequestCommenter
- type MergeRequestDraft
- type MergeRequestState
- type MergeRequests
- type Option
- type Options
- type Prompter
- type Provider
- type ProviderFactory
- type ProviderUnwrapper
- type PullRequest
- type PullRequestCommenter
- type PullRequestDraft
- type PullRequestState
- type PullRequests
- type Release
- type ReleaseAsset
- type ReleaseAssetPublisher
- type ReleaseAssetSource
- type ReleaseDraft
- type ReleasePublisher
- type Repositories
- type Repository
- type RepositoryCreator
- type RepositoryDraft
- type RepositoryListOptions
- type SignatureProvider
- type Site
- type SiteState
- type SiteStatus
- type SiteTLS
- type SiteURLSource
- type Sites
- type Snippet
- type SnippetCreateOptions
- type SnippetFile
- type SnippetScope
- type SnippetVisibility
- type Snippets
- type TrustOption
- type Visibility
- type WikiPage
- type Wikis
Examples ¶
Constants ¶
const ( SourceTypeGitHub = "github" SourceTypeGitLab = "gitlab" SourceTypeBitbucket = "bitbucket" SourceTypeGitea = "gitea" SourceTypeCodeberg = "codeberg" SourceTypeDirect = "direct" )
SourceType constants name the first-party release providers, as a convenience for callers configuring one.
They are NOT a closed set, and declaring one here does not register it: every provider except direct ships as its own module and registers itself when blank-imported. The registry accepts any string, so a provider this module has never heard of works exactly as well — which is the point. Downstream consumers may declare their own constants.
const ( // DefaultMaxChecksumsSize bounds a checksums manifest. A GoReleaser // manifest for a typical multi-OS release is ~1 KiB, so 1 MiB is roughly // 1000x headroom while still capping a hostile stream. DefaultMaxChecksumsSize int64 = 1 << 20 // DefaultMaxSignatureSize bounds a detached signature. An OpenPGP // signature over a manifest is a few hundred bytes; 1 MiB is generous. DefaultMaxSignatureSize int64 = 1 << 20 )
Size bounds for the optional capability interfaces.
These are DEFAULTS, not mandates. The caller passes the bound it wants to ChecksumProvider.DownloadChecksumManifest and SignatureProvider.DownloadSignature, and the provider enforces whatever it receives — so a tool shipping unusually large artefacts can raise the ceiling without every provider needing a configuration knob of its own.
The split is deliberate: the caller owns the *policy*, the provider owns the *enforcement*. Making these constants here and mandatory in the providers would move policy to the wrong side of the boundary.
const ( MergeRequestStateUnknown = PullRequestStateUnknown MergeRequestStateOpen = PullRequestStateOpen MergeRequestStateClosed = PullRequestStateClosed MergeRequestStateMerged = PullRequestStateMerged )
The state constants under their GitLab spelling. Same type, same values.
AssetFooterHeading is the heading a rendered location footer sits under.
It is exported so a caller can find the footer in a body it did not write, and so the conformance harness asserts on the same string every provider produces.
const DefaultAuthKey = "auth.value"
DefaultAuthKey is the configuration key ConfigCredential reads when the caller names none.
It is a DEFAULT, not a convention this module imposes. A consumer whose configuration already spells the credential differently — because their keychain layer declares `platforms.gitlab.token`, say — passes that key instead and reshapes nothing.
const DefaultMaxFileSize int64 = 1 << 20
DefaultMaxFileSize bounds a single file read by Contents.GetFile. The files this capability exists to read are configuration markers of a few kilobytes, so 1 MiB is generous while still capping a hostile or merely enormous response.
As with DefaultMaxChecksumsSize, this is a DEFAULT and not a mandate: the caller passes the bound it wants and the provider enforces whatever it receives.
const MaxRetryAfter = time.Hour
MaxRetryAfter caps what RetryAfter will report.
A retry delay is SERVER-CONTROLLED input, and a caller that sleeps on it without a bound has handed the forge the ability to wedge it: a broken instance sending `Retry-After: 999999999`, or a hostile one sending it deliberately, would otherwise stall the caller for eleven days.
An hour is longer than any real forge asks for — GitHub's primary rate limit resets hourly at worst — and short enough that honouring it in full is a decision a caller can live with. A caller that genuinely wants to wait longer reads the platform's own error out of the chain and decides for itself, which is a deliberate act rather than an accident.
Variables ¶
var ( // ErrProviderNotFound is returned by Lookup when no factory has been // registered for the requested source type. ErrProviderNotFound = errors.NewSentinel("forge.provider_not_found", "no release provider registered for source type") // ErrNotSupported is returned by provider methods that are not applicable // for the underlying platform (e.g. ListReleases on Bitbucket). ErrNotSupported = errors.NewSentinel("forge.not_supported", "operation not supported by this release provider") // ErrReleaseNotFound is returned when a requested release — by tag, or the // latest — does not exist. // // It is distinct from [ErrNotFound], which says the REPOSITORY is not there. // A caller checking for updates wants those apart: one means "nothing to // update to yet", the other means the source is wrong. // // Every first-party provider returns it for a tag that does not resolve, // and the conformance harness holds them to it, so errors.Is against it is // reliable across the set. // // Which not-found sentinel a bare 404 carries is a provider's decision, // because the status cannot say what was addressed. forge-gitlab reports a // 404 from its release listing as ErrNotFound, a missing PROJECT being what // that status means there. Branch on both where you only care that // something was absent. ErrReleaseNotFound = errors.NewSentinel("forge.release_not_found", "release not found") )
var ( // expired, or revoked. Typically an HTTP 401. // // It is the only refusal here that authorises discarding a cached // connection. A consumer reusing providers may invalidate on this and // re-resolve; see the forge/pool documentation for why doing so on the // others is harmful rather than merely wasteful. ErrUnauthorized = errors.NewSentinel("forge.unauthorized", "the credential is not valid") // ErrForbidden reports that the credential IS valid and lacks the permission // for this request. Typically an HTTP 403 that is not a rate limit. // // Never invalidate a credential on this: re-resolving produces the same // valid credential, which is refused again, which prompts another // invalidation. The loop is silent and self-sustaining. ErrForbidden = errors.NewSentinel("forge.forbidden", "the credential lacks permission for this request") // ErrRateLimited reports that the caller is being throttled. An HTTP 429, or // a 403 carrying rate-limit evidence — GitHub signals a limit that way. // // Never invalidate a credential on this either, and the reason is worse than // a loop: a consumer reusing providers resolves credentials concurrently // when invalidated, so invalidating here aims a burst of resolutions at an // API that is already refusing. That is how a throttle becomes a lockout. // // Use [RetryAfter] to find out how long to wait, when the forge said. ErrRateLimited = errors.NewSentinel("forge.rate_limited", "the caller is rate limited") )
The refusals a forge can return, distinguished because they want different responses from a caller.
Responding identically to all of them is wrong in three of the four cases: a missing release is often tolerable, a lapsed credential wants re-authentication, a permission failure wants neither, and a rate limit wants a wait. A caller that can only see "an error" is forced to treat every one as fatal, or to match on message text.
Each is added to the error chain rather than replacing what the platform said, so errors.Is answers "which refusal" while the platform's own error remains reachable with errors.As.
var ( // ErrAlreadyExists reports that the thing being created is already there. // // For [ReleasePublisher.CreateRelease] that means the tag already carries a // release. Every forge refuses this, so no emulation is needed — but they // refuse it three different ways, and only GitHub documents which. A // provider must map its platform's answer onto this sentinel rather than // leaving a caller to recognise three shapes. // // It is deliberately NOT one of the refusal sentinels ([ErrUnauthorized], // [ErrForbidden], [ErrRateLimited]). A collision is not a refusal, and a // caller asking "was I turned away?" must not find this among the answers. ErrAlreadyExists = errors.NewSentinel( "forge.already_exists", "already exists") // ErrNotHonoured reports that an operation SUCCEEDED, but some part of what // was asked for could not be applied. // // # Read this before writing the usual error check // // This is Go's (n > 0, err != nil) shape, and it has a sharp edge. The // reflexive idiom // // rel, err := p.Create(ctx, owner, repo, draft) // if err != nil { // return err // } // // treats a SUCCESSFUL creation as a failure. A caller who then retries gets // [ErrAlreadyExists] for a release that was already correct, and is left // holding two errors about work that went right the first time. // // Two guarantees make it safe to handle, and a provider MUST honour both: // // - The returned value is populated whenever the thing exists, whatever // the error says. A provider must never return a usable Release // alongside an error meaning the create failed. So a caller can check // the result without knowing this sentinel exists. // - ErrNotHonoured never travels alone. It is only ever returned with a // valid result, which makes errors.Is a complete test for "this // succeeded, partially". // // The error carries a hint naming what was dropped, reachable with // errors.Hints, so a log says which part rather than merely that one. // // The idiom that is correct: // // rel, err := p.Create(ctx, owner, repo, draft) // if err != nil && !errors.Is(err, forge.ErrNotHonoured) { // return err // a real failure; nothing was created // } // // rel exists either way. Log err if non-nil: something was dropped. ErrNotHonoured = errors.NewSentinel( "forge.not_honoured", "the operation succeeded, but some of what was asked for could not be applied") )
This file defines the OPTIONAL release-publishing capability a forge provider MAY implement: creating a release object against a tag, amending its notes, and attaching files to it.
It follows the same contract as every other optional capability. A caller discovers it with As and treats both "the provider does not implement the interface" and a returned ErrNotSupported identically, by falling back.
What this does NOT do
It does not create the tag. A tag is a git object and needs no forge API, so that responsibility lives in gitlab.com/phpboyscout/go/repo alongside clone, commit and branch. This capability requires the tag to exist already and REFUSES when it does not — see ReleasePublisher.CreateRelease, where the reason is sharper than it first looks.
It does not delete a release. PullRequests carries no Merge for the same reason: publishing and unpublishing a release are maintainer acts in this estate, and a verb here would make them a library call. ReleasePublisher.UpdateRelease amends what a release says, which covers correcting notes after the fact without offering a way to remove one.
It does not decide WHEN to release, or what version to cut. That is the consumer's, and it has no business in a forge contract.
The credential that can create a release and should not
A credential may be fully authorised to create a release and still be the wrong one to use, because ITS WRITES DO NOT TRIGGER DOWNSTREAM AUTOMATION. Where that is true, a release created with it is real and the build that was supposed to follow never runs — a failure that looks like nothing happening rather than like an error.
This contract cannot detect it. It does not resolve credentials (that is the consumer's composition, stated once in their config stack) and cannot see where a token came from. So the hazard is named here and nowhere enforced, and a caller wiring this capability up is expected to know which of their credentials is the releasing one.
var ErrAlreadyRegistered = errors.NewSentinel("forge.already_registered", "a provider is already registered for this source type")
ErrAlreadyRegistered is returned by Register when a factory is already registered for the requested source type.
var ErrConflict = errors.NewSentinel(
"forge.conflict", "the state moved between the read and the write")
ErrConflict reports that the state moved under the caller and the write did not happen. Re-read, re-apply, call again.
It is deliberately NOT wiki-shaped. "Something changed between your read and your write" is a thing a forge contract wants a name for more than once, and calling it ErrWikiConflict would guarantee a second sentinel for the second occurrence.
Why it is not one of the existing values ¶
Each of them would tell a caller something untrue:
- ErrAlreadyExists means a collision on CREATE. Nothing was created; the page exists and has moved on.
- ErrUnauthorized, ErrForbidden and ErrRateLimited mean the caller was turned away. It was not. It lost a race.
- ErrNotHonoured means the work was done and part was dropped. Nothing was written at all.
- A plain error means the caller made a programming mistake. It did not, and this is the one failure here it can actually recover from.
A provider never retries on a caller's behalf — that rule is unchanged. Only the caller knows what it was appending, so only the caller can merge.
var ErrInvalidEndpoint = errors.NewSentinel("forge.invalid_endpoint", "invalid endpoint")
ErrInvalidEndpoint reports an Endpoint that cannot address a provider.
var ErrNotFound = errors.NewSentinel("forge.not_found", "not found")
ErrNotFound is returned when a requested resource does not exist, as distinct from a request that failed.
The distinction is the point. A caller asking "does this repository carry a zensical.toml?" must be able to tell "no" from "I could not find out", because the two demand opposite handling: treat a transient 500 as "no" and the repository silently drops out of the corpus; treat a 404 as an error and discovery fails on every candidate that legitimately lacks the file — which is most of them.
Unlike ErrReleaseNotFound, this sentinel ships with a conformance check, so errors.Is(err, ErrNotFound) is safe to rely on across the first-party providers rather than aspirational.
var ErrProviderPanic = errors.NewSentinel("forge.provider_panic", "provider panicked")
ErrProviderPanic is returned when a provider's underlying client panicked rather than returning an error.
A caller can branch on it, but the useful response is almost always the same as for any other provider failure: retry, or give up on this call. What it tells you that an ordinary error does not is that the fault is in a dependency's response handling, not in the request you made — so retrying the same request will probably panic again.
var ErrStaleAuthKeys = errors.NewSentinel("forge.stale_auth_keys", "configuration uses auth.env or auth.keychain, which are no longer read")
ErrStaleAuthKeys reports configuration still carrying `auth.env` or `auth.keychain`, which this module no longer reads.
See the migration table in the README: an environment-variable reference becomes an env layer in your config stack (or EnvCredential), and a keychain reference becomes a config-keychain layer (or a CredentialSource of your own).
This is reported rather than ignored because the failure it would otherwise cause is silent: configuration that looks correct, a provider with no credential, and a 401 far from the cause. It is deliberately NOT fatal on its own — see ConfigCredential.
Functions ¶
func AppendIdempotencyKey ¶ added in v0.5.0
AppendIdempotencyKey returns body with the marker for key appended, or body unchanged when key is empty.
It MUST be applied after Sanitise, never before. Sanitising a body that already carries the marker would feed the key to the redactor, and an opaque key of 41 or more characters would be rewritten to a redaction marker — silently breaking the at-most-once guarantee, because the pre-create search would then look for a key the issue does not carry.
func As ¶ added in v0.3.0
As walks p's ProviderUnwrapper chain, finds the first provider in it assignable to the interface pointed to by target, sets target to that provider, and reports whether it found one. It mirrors errors.As: use it to discover an optional capability through an arbitrary stack of decorators rather than a single type assertion that a forwarding decorator would defeat.
var km forge.KeyManager
if forge.As(provider, &km) {
// provider (or something it wraps) can upload keys
}
target must be a non-nil pointer to an interface type; As panics otherwise, as errors.As does for a malformed target. A provider that itself implements the capability is matched before any unwrapping, so As is always at least as capable as a plain assertion.
func GuardPanic ¶ added in v0.6.0
GuardPanic runs fn and converts a panic into an error wrapping ErrProviderPanic, capturing the stack so the fault is diagnosable.
Why this exists ¶
A forge SDK decodes a network response, and more than one of them dereferences a field of that response without checking it is present. Two live examples, both found by giving a first-party provider's own test fixture a payload one field short of complete:
- gitlab.com/gitlab-org/api/client-go calls reflect.TypeOf(raw["id"]).Kind() when unmarshalling an issue. An absent id makes TypeOf return nil, and Kind() panics.
- gitea.dev/sdk dereferences issue.Repository.FullName on a compatibility path taken whenever its server-version probe fails.
Real instances always send those fields, so both are latent — but the input is a NETWORK RESPONSE, which is not ours to guarantee. A truncated body, a misbehaving proxy, a captive portal, or an instance a version older than the SDK expects all produce the same crash. In a long-running unattended process, that is not an error path: it is the process ending.
This module already treats a response as untrusted everywhere else — bounded reads, fail-closed decoding, host pinning. A panic is the one way an untrusted response could still take a caller down, so it is routed through one helper like every other trust-boundary check.
Wrap the DEPENDENCY, not your own logic ¶
A blanket recover is an anti-pattern because it hides genuine bugs. Keep the guarded region as small as the third-party call itself:
var issues []*sdk.Issue
err := forge.GuardPanic(func() error {
var e error
issues, _, e = client.Issues.List(ctx, project)
return e
})
Mapping the result, validating it, and everything else stay OUTSIDE. Then a recovered panic is the dependency's, and a panic in provider code still fails loudly where it can be found and fixed.
Panics Go itself declines to make recoverable — a concurrent map write, an out-of-memory throw — are unaffected: recover does not see them, and neither does this.
func GuardPanicValue ¶ added in v0.6.0
GuardPanicValue is GuardPanic for a call that yields a value, saving the caller a captured variable.
The same rule applies: fn should contain the third-party call and nothing else, so a recovered panic is unambiguously the dependency's.
func HasLiveMention ¶ added in v0.5.0
HasLiveMention reports whether s still contains an @-token that a forge could activate.
It is the assertion half of InertMentions, for a caller that wants to prove no generated payload can notify anyone rather than trusting that the neutralisation ran. Consumers with a requirement to that effect can assert on it directly in their own tests.
Defined in terms of InertMentions deliberately: two separate implementations of "what counts as a mention" would eventually disagree, and the way that failure presents is an assertion passing over a payload that still notifies.
func HostTrusted ¶
func HostTrusted(rawURL, trustedBaseURL string, opts ...TrustOption) bool
HostTrusted reports whether rawURL points at the same host as trustedBaseURL, and is safe to attach a credential to.
Why this exists ¶
Release metadata is author-controlled. An asset's download URL comes back from the forge API as data, and on most platforms a release author can set it to any URL at all. A provider that attaches its API token to whatever URL the metadata names will happily send that token to an attacker's server: the victim only has to install a tool whose release was published, or edited, by someone hostile.
So a provider must pin the credential to the host it authenticated against. This is that check, and it fails closed — an unparseable URL, a missing host, or any mismatch returns false, because the cost of a false negative is an unauthenticated request that may 404, while the cost of a false positive is a leaked credential.
What counts as trusted ¶
Both the host (including port) and the scheme must match:
- Host comparison is case-insensitive, since DNS names are. Ports are part of the comparison: a different port is a different service.
- The scheme must match too, which is stricter than comparing hosts alone. Without it, a base of https://git.example.com would trust http://git.example.com — and the credential would go out in plaintext to a host an attacker on the network path can impersonate. A downgrade is exactly what an attacker who can rewrite release metadata would choose.
Usage is the same shape in every provider:
if p.token != "" && forge.HostTrusted(downloadURL, p.baseURL) {
req.Header.Set("Authorization", "token "+p.token)
}
Strictness is the default, deliberately. Where a deployment genuinely needs more — assets on a separate storage domain, or a lab install with no TLS — WithAdditionalHosts and WithInsecureSchemeDowngrade widen it explicitly, at the call site, where the decision is visible and greppable.
Note what this does NOT do: it does not stop the download. An asset hosted elsewhere is still fetched, just without the credential attached — which is the correct behaviour for a public asset on a CDN.
func IdempotencySearchText ¶ added in v0.5.0
IdempotencySearchText returns the text a provider searches for to find an issue already filed under key.
Exposed so the marker format lives in exactly one place: a provider that composed the search string itself would eventually compose it differently from AppendIdempotencyKey, and the guarantee would fail silently rather than loudly.
func InertMentions ¶ added in v0.5.0
InertMentions renders every @-token in s inert, so publishing the result cannot notify whoever owns the handle.
A handle is rewritten from @name to `name` — the @ is REMOVED and the name is wrapped in backticks.
Removing the @ is the defence; the backticks are presentation ¶
This distinction is the whole design, and it is easy to get backwards. It is tempting to neutralise a mention by wrapping it in backticks alone, on the assumption that a forge does not linkify inside inline code. GitLab documents that @username and @groupname notify, and that @all is rendered as plain text — but says NOTHING about mentions inside code spans or fenced blocks.
A neutralisation resting on that would rest on undocumented behaviour, on the one path where being wrong pages an uninvolved person. So the @ is removed, which needs no assumption about any renderer, and the backticks are there to make the result read as a handle rather than as prose.
What is covered, and how confident each case is ¶
- @everyone and @here are chat syntax with no special meaning on any of the four forges — but "everyone" is a perfectly registrable username, so on a forge they are an ordinary mention of whoever holds it. LIVE HAZARD.
- Chat role and channel syntax such as <@12345> is caught because the @ sits at a word boundary inside it.
- Fullwidth (@) and small (﹫) commercial-at are neutralised as a PRECAUTION, not because they are known to activate. The four forges match an ASCII @; this guards against one that normalises before matching. Do not remove it believing it defends a demonstrated vector, and do not cite it as one.
Email addresses are left alone (see [isWordChar]).
Idempotent: a handle already rendered inert has no @ left to match.
Why this is a scanner rather than a regular expression ¶
A single regexp replacement cannot do this correctly, and the fuzzer proved it: the boundary test has to look at the character BEFORE the @, but after a replacement that character has changed. Given "@0@00", one pass neutralises the first handle and leaves "`0`@00" — where @00 is now preceded by a backtick and is a perfectly live mention of the user named 00.
Iterating to a fixed point would fix the correctness and cost O(n²) on input like "@a@a@a…", which is not acceptable for something applied by default.
So the scan is single-pass and tracks the last EMITTED rune, because the emitted text is what gets published and therefore what a forge will parse.
func NormaliseLogger ¶ added in v0.7.0
NormaliseLogger returns a logger a provider can use unconditionally: never nil, and always redacting.
A nil logger becomes one backed by slog.DiscardHandler rather than slog.Default, because a library that writes to the default logger has taken a position on the consumer's output stream. Providers holding a Settings.Logger field call this directly; those built through the registry get it via NewOptions.
func NormalizeHostURL ¶ added in v0.3.0
NormalizeHostURL canonicalises a forge instance host into a scheme-qualified origin ("https://host[:port]") suitable both for constructing an API client and for pinning credentials with HostTrusted.
Why this exists ¶
Providers derive their API base and their credential pin from a configured host. Some accept a bare hostname ("git.example.com"), others a full URL. A bare host is a trap for pinning: HostTrusted parses its base with url.Parse, and a value with no scheme parses to an empty Host — so the pin can never match and the credential is silently never attached. Routing every adapter's host through this one helper removes that seam.
It accepts either a bare host ("git.example.com", "git.example.com:3000") or a full URL ("http://git.example.com/path?x=1"); in the URL case only the scheme, host and port are retained. A bare host defaults to the https scheme, because that is what a working pin requires and downgrading to http is never the safe default.
It returns an error for an empty host, or one that cannot be parsed into a host component.
func ParseRetryAfter ¶ added in v0.13.0
ParseRetryAfter reads a reset time from a forge's response headers, in the three dialects the first-party set actually speaks.
It exists once here rather than in each adapter for the reason in MaxRetryAfter: this parses attacker-influenced input, and five implementations of that is five chances to get the bound wrong. A provider whose SDK has already parsed the value — go-github exposes a typed reset — passes it to RateLimited directly and does not need this.
now is taken as a parameter so the calculation is testable without a clock.
Reports false when the headers say nothing, say something unparseable, or name a moment that has already passed. A reset in the past is not "retry now": it means the clocks disagree or the response is stale, and guessing from it is how a caller ends up hammering a throttled API.
func PreScopedConfig ¶ added in v0.17.0
PreScopedConfig reports whether cfg looks like a subtree ALREADY SCOPED to ep, rather than the root configuration Endpoint.Section expects.
The mistake it exists to catch ¶
Every adapter's SettingsFromConfig takes the ROOT configuration, because the endpoint resolves its own section — which subtree a source reads is part of what the endpoint means. Hand it a pre-scoped subtree instead and the adapter looks one level too deep (`gitlab.gitlab.auth.value`), finds nothing, resolves no credential, and RETURNS NORMALLY.
It is worse than a quiet failure because it can appear to work. Having resolved nothing from configuration, the credential falls through to the well-known environment variable — so it behaves correctly wherever that is set and fails where it is not, which is usually CI rather than a laptop, and usually as a permission error rather than a configuration one.
It fires only when resolution has ALREADY failed ¶
The section is checked first and a populated one returns false immediately, so a WORKING CONFIGURATION NEVER REACHES the second clause. This cannot break one. That is the property that makes warning on it safe rather than merely cautious — see spec 0011 D1 and D2.
It is a heuristic, so warn rather than refuse ¶
A caller with an unusual but valid layout must still be able to build a provider. The right response to a true positive is a diagnostic naming the fix, not a failure: the caller is already going to fail for want of a credential, and this only says why.
witness ¶
The keys that exist INSIDE a source's section. There is no universal one: four first-party adapters read DefaultAuthKey, and forge-bitbucket reads `username` and `app_password` because it authenticates with basic auth. An adapter passes what it actually reads.
Passing NO witness keys makes this permanently return false. That is inert rather than safe, and it is the failure mode to guard against — a check with the wrong keys is as useless as no check and considerably harder to notice.
Implements https://gitlab.com/phpboyscout/go/forge/-/wikis/specs/0011-detect-a-pre-scoped-config
func RateLimited ¶ added in v0.13.0
RateLimited marks err as a rate-limit refusal, recording when the limit resets if the forge said.
retryAfter is how long to wait. A non-positive duration means the forge did not say, which RetryAfter reports as unknown rather than as "retry now" — a caller must not be able to mistake silence for permission. A duration beyond MaxRetryAfter is capped; see there for why.
The returned error satisfies errors.Is(err, ErrRateLimited) and leaves cause reachable with errors.As.
func RedactingHandler ¶ added in v0.7.0
RedactingHandler wraps h so no attribute value can carry a credential out of the process.
Why the handler, and not the call sites ¶
A debug log of a request, a response body, or an error from a forge API is precisely where a token leaks. A convention that every call site must remember to redact is a convention one call site will get wrong, and the next person copies the wrong one. Wrapping at the handler makes redaction the only path — including for a consumer's own logging inside a custom CredentialSource, provided it goes through the logger this module was given.
redact.String is idempotent and never panics, which is what makes it safe to apply to every attribute unconditionally. Wrapping is idempotent too: handing an already-wrapped handler back returns it unchanged rather than nesting.
Redaction recognises well-known credential shapes; it cannot recognise every one. A self-hosted GitLab or Gitea token may match nothing. That is why this module additionally never logs a credential value — see Options.
func Register ¶
func Register(sourceType string, factory ProviderFactory) error
Register associates a source type string with a ProviderFactory. Safe to call concurrently.
It returns ErrAlreadyRegistered rather than overwriting. Silently overwriting would let a blank-imported provider module displace another with no diagnostic, with initialisation order deciding the winner — so the failure would surface later, as the wrong provider being used, far from its cause.
Returning an error rather than panicking keeps the decision with the caller. A provider module registering from init() has nothing sensible to do with a failure and should panic at its own call site, where the panic names the module at fault:
func init() {
if err := forge.Register("mycorp-git", newProvider); err != nil {
panic("myforge: " + err.Error())
}
}
Code building a registry programmatically — a plugin host, a test, a tool choosing providers at runtime — can instead handle the error. That case is the reason this does not panic on your behalf.
To register conditionally, consult Registered. To replace a provider deliberately, call Unregister first.
func Registered ¶
Registered reports whether a factory is registered for sourceType. Use it to register conditionally without tripping Register's duplicate panic.
func RegisteredTypes ¶
func RegisteredTypes() []string
RegisteredTypes returns a sorted slice of all currently registered source type strings. Used for generating user-facing error messages.
func RenderAssetFooter ¶ added in v0.22.0
func RenderAssetFooter(body string, assets []ReleaseAssetSource) string
RenderAssetFooter appends a markdown footer listing every location-shaped asset, and returns the body unchanged when there are none.
It lives on the contract rather than in each adapter so that the footer reads identically on every forge that needs one. A caller reading a release body back should not have to know which provider wrote it.
Assets carrying Content are skipped: they are real assets wherever this is called, so listing them would advertise the same file twice.
func RetryAfter ¶ added in v0.13.0
RetryAfter reports how long to wait before retrying, and whether the forge said at all.
The bool is the load-bearing half. A forge that gives no reset time is reported as unknown rather than as a zero duration, so a caller cannot read "it did not say" as "retry now" — which, against an API that is already throttling, is the worst available response.
The duration is bounded by MaxRetryAfter.
func Sanitise ¶ added in v0.5.0
Sanitise makes text safe to publish to a forge, returning it with secrets redacted and @-mentions rendered inert.
It is what IssueFiler.CreateIssue applies by default. Callers building forge payloads by other routes should apply it themselves.
Idempotent: Sanitise(Sanitise(s)) == Sanitise(s).
What it costs ¶
Secret redaction is redact.String, which is calibrated for log lines rather than user-authored prose. Its broadest pattern rewrites any run of 41 or more token characters, so a SHA-256 digest in a bug report becomes a redaction marker while a 40-character git SHA-1 survives. That tradeoff is documented upstream and inherited here deliberately: a mangled digest is annoying, and a published credential is an incident.
func SplitRepoPath ¶ added in v0.4.0
SplitRepoPath divides a canonical repository path into the owner and repository parts that the provider methods take.
Repositories reports identity as a single path, while Contents and Sites take owner and repo separately, so every caller has to bridge the two. On forges with nested namespaces the owner may itself contain separators:
SplitRepoPath("phpboyscout/go/forge") // "phpboyscout/go", "forge"
SplitRepoPath("acme/tool") // "acme", "tool"
SplitRepoPath("tool") // "", "tool"
It splits on the LAST separator, which is the whole reason it exists here rather than at each call site: splitting on the first instead yields a request for a repository that does not exist, and a 404 is indistinguishable from the file being absent.
func Unregister ¶
Unregister removes the factory registered for sourceType, reporting whether one was present. It exists so that deliberately replacing a built-in provider stays possible now that Register refuses to overwrite silently — the intent has to be stated rather than assumed.
Like Register, this mutates process-wide state: call it during start-up, not from a test that runs under t.Parallel().
Types ¶
type Authenticator ¶ added in v0.2.0
type Authenticator interface {
// Login runs the provider's interactive authentication flow, surfacing any
// user-facing step through p, and returns a usable API token on success. It
// blocks until the user completes the flow, ctx is cancelled, or the flow's own
// deadline (e.g. the device-code expiry) elapses.
//
// Returns [ErrNotSupported] when interactive login is unavailable in the
// provider's current configuration; the caller then falls back to manual entry.
Login(ctx context.Context, p Prompter) (token string, err error)
}
Authenticator is an OPTIONAL capability implemented by providers that support an interactive login flow (e.g. an OAuth device flow) yielding an API token.
Providers that only accept a pre-issued token or app password do not implement it — or implement it and return ErrNotSupported when their current configuration cannot perform an interactive login. The caller treats both cases identically: fall back to manual token/app-password entry.
Example ¶
ExampleAuthenticator shows the caller-side capability-detection pattern: use the interactive login when the provider offers it, otherwise fall back to a manual token.
package main
import (
"context"
"fmt"
"time"
"gitlab.com/phpboyscout/go/forge"
)
// capturePrompter records the DeviceCode it was shown, standing in for a CLI.
type capturePrompter struct {
dc forge.DeviceCode
shown bool
failWith error
}
func (c *capturePrompter) ShowDeviceCode(_ context.Context, dc forge.DeviceCode) error {
if c.failWith != nil {
return c.failWith
}
c.dc = dc
c.shown = true
return nil
}
// fakeProvider optionally implements Authenticator + KeyManager, to exercise the
// capability-detection contract a caller uses.
type fakeProvider struct {
token string
loginErr error
uploadErr error
uploaded []byte
}
func (f *fakeProvider) Login(ctx context.Context, p forge.Prompter) (string, error) {
if err := p.ShowDeviceCode(ctx, forge.DeviceCode{
UserCode: "WDJB-MJHT", VerificationURI: "https://example.test/device", ExpiresIn: 15 * time.Minute,
}); err != nil {
return "", err
}
return f.token, f.loginErr
}
func (f *fakeProvider) UploadKey(_ context.Context, _ string, publicKey []byte) error {
f.uploaded = publicKey
return f.uploadErr
}
func main() {
var provider any = &fakeProvider{token: "gho_example"}
if auth, ok := provider.(forge.Authenticator); ok {
tok, err := auth.Login(context.Background(), &capturePrompter{})
if err == nil {
fmt.Println("logged in, token:", tok)
}
} else {
fmt.Println("provider has no interactive login; prompt for a token")
}
}
Output: logged in, token: gho_example
type ChecksumProvider ¶
type ChecksumProvider interface {
// DownloadChecksumManifest returns the raw bytes of the checksums
// manifest for the given release.
//
// The implementation MUST enforce maxBytes and return an error when the
// response exceeds it, so a hostile server cannot stream indefinitely.
// Enforcement belongs here, at the trust boundary, not in the caller: a
// provider reaching a URL of its own composition is the only code that
// sees the response before it is buffered. [DefaultMaxChecksumsSize] is
// a reasonable bound, but the CALLER chooses the value and passes it —
// tools shipping unusually large artefacts legitimately raise it.
//
// Returns [ErrNotSupported] when the provider is configured in a
// way that disables checksum retrieval (e.g. the Direct
// provider's `checksum_url_template` param is empty). The caller
// treats [ErrNotSupported] exactly like "provider does not
// implement this interface" — fall back to asset-by-name lookup
// if one is available, otherwise respect the require_checksum
// policy.
DownloadChecksumManifest(ctx context.Context, rel Release, maxBytes int64) ([]byte, error)
}
ChecksumProvider is an OPTIONAL interface implemented by release providers that can fetch a checksums manifest by means other than a standard release-asset download. The canonical case is the Direct provider's `checksum_url_template` param, which composes a URL from a template that may point outside the release-asset listing entirely.
The update flow does a type assertion at runtime — providers that do not implement this interface fall back to the default behaviour of locating `checksums.txt` by filename within the release's asset list. This keeps third-party Provider implementations source-compatible: they gain the feature by opting in, not by implementing a new required method.
type Comment ¶ added in v0.5.0
type Comment struct {
ID int64
Author string
Body string
CreatedAt time.Time
UpdatedAt time.Time
// System reports a comment the FORGE generated — a label change, a state
// transition, an assignment — rather than one a person wrote.
//
// A caller relaying comments to an audience outside the tracker must skip
// these, or it republishes triage mechanics as though they were answers.
System bool
}
Comment is one comment on an issue.
type CommentDraft ¶ added in v0.26.0
type CommentDraft struct {
// Body is the comment text, in the forge's markdown flavour. REQUIRED.
Body string
// IdempotencyKey makes posting at-most-once across a retry. Empty opts out.
//
// When it is non-empty the provider MUST look for an existing comment
// carrying the key before posting, and MUST return that comment rather than
// posting a second. The key is written into the comment so the search can
// find it, using [AppendIdempotencyKey] — the same visible marker
// [IssueFiler] uses, for the reason recorded there: an HTML comment is
// hidden on three of the four forges and renders as literal text on
// Bitbucket.
//
// # The limits, stated rather than discovered
//
// - It is NOT atomic. Two concurrent posts with the same key can both
// find nothing and both post. This defends against a RETRY, not against
// concurrent callers.
// - The key is published, because it lives in a readable comment body.
// - It costs a full listing of the target's comments, not one filtered
// request. No forge offers a server-side search over comments — checked
// at the pinned client versions — so the provider pages through them and
// matches locally. On a busy pull request that is several requests.
//
// A provider confirms the marker is actually present rather than trusting a
// fuzzy match, exactly as [IssueFiler.CreateIssue] does: a near-miss
// returning someone else's comment is worse than posting a duplicate.
IdempotencyKey string
// Unsanitised sends Body verbatim, skipping the secret redaction otherwise
// applied. A comment reaches the same audience an issue does, so it gets
// the same defence by default.
Unsanitised bool
}
CommentDraft is what a caller supplies to post a comment.
type CommentQuery ¶ added in v0.5.0
type CommentQuery struct {
// Since limits comments to those created at or after this time. Zero means
// all comments.
//
// Providers whose forge supports it filter server-side. Those whose forge
// does not — GitLab has no such parameter — request newest-first and stop
// as soon as they pass Since, so a caller still pays only for what it asked
// for.
//
// Because those two routes produce opposite orders, NO ORDER IS PROMISED.
// Forcing one to match the other would mean buffering the whole set, which
// is what Since exists to avoid. Sort what you collect if order matters.
Since time.Time
// PageSize hints at the provider's internal page size.
PageSize int
}
CommentQuery narrows what Issues.ListComments returns.
type Contents ¶ added in v0.4.0
type Contents interface {
// GetFile returns the bytes of path in owner/repo at ref.
//
// ref is REQUIRED. A read at "whatever the default branch is right now" is
// not reproducible, and a caller recording provenance needs to know what it
// read.
//
// Returns ErrNotFound when the file does not exist at that ref — which is an
// ordinary answer to "does this repository carry X?", not a failure. A
// permission error is NOT ErrNotFound: a caller must never be told a file is
// absent because its token was too weak.
//
// The implementation MUST enforce maxBytes. Enforcement belongs here, at the
// trust boundary, for the same reason it does on [ChecksumProvider]: the
// provider is the only code that sees the response before it is buffered.
// Where the underlying SDK exposes a reader, use io.LimitedReader; where it
// buffers internally, check the size the API reports BEFORE fetching and
// re-check the length after. [DefaultMaxFileSize] is a reasonable bound, but
// the CALLER chooses the value and passes it.
GetFile(ctx context.Context, owner, repo, path, ref string, maxBytes int64) ([]byte, error)
}
Contents is an OPTIONAL capability implemented by providers that can read a single file at a ref without cloning.
It exists so a caller can test a repository for a marker file — a docsite config, a manifest — across a whole namespace without cloning every candidate to discover that most of them do not have it.
type CredentialSource ¶ added in v0.7.0
CredentialSource yields a credential when a provider needs one.
Why a function ¶
Every interesting implementation is a one-liner: a literal the caller already holds, a single configuration read, a closure over a caller's own Vault client. An interface would make each of those declare a type to express what a closure expresses directly.
The two empty cases are different ¶
Returning ("", nil) means NOT CONFIGURED, which is legitimate — a public repository needs no credential — and lets FirstCredential try the next source. Returning an error means BROKEN, and is retained rather than discarded (see FirstCredential).
This module's own sources perform no I/O, so none of them can block. The context is here for the ones a consumer writes: a source reaching a keychain, Vault or SSM does block, and adding the parameter afterwards would be a breaking change.
func ConfigCredential ¶ added in v0.7.0
func ConfigCredential(cfg Config, key string) CredentialSource
ConfigCredential reads a credential from a SINGLE configuration key.
An empty key uses DefaultAuthKey. The key is read verbatim from whichever Config is handed in, which needs no scoping rule and serves both shapes:
forge.ConfigCredential(sub, "") // gitlab.auth.value, via Sub forge.ConfigCredential(root, "platforms.gitlab.token") // the consumer's own layout
A nil cfg yields ("", nil): provider factories already support config-free public lookups, so an absent configuration container is not an error.
One key, not a ladder ¶
This module used to walk four rungs of its own — an environment-variable reference, a keychain reference, a literal, then a well-known variable. That was a second precedence system layered inside whatever precedence the consumer had already composed, and two precedence systems in one path is how configuration becomes unpredictable. Ordering now belongs to the config stack, where it is stated once and is inspectable.
The stale-key report ¶
Configuration migrated in code but not on disk would otherwise fail silently. When the configured key yields nothing AND the removed keys are still present, this returns ErrStaleAuthKeys instead of ("", nil). Composed with FirstCredential that produces the right outcome either way: a working well-known variable still wins and the error is discarded, while a configuration with nothing else left reports the migration rather than a bare absence.
func EnvCredential ¶ added in v0.7.0
func EnvCredential(name string) CredentialSource
EnvCredential yields the value of a named environment variable.
An empty name yields ("", nil) rather than reading anything, so a provider composing a default with no well-known variable to fall back on needs no branch.
func FirstCredential ¶ added in v0.7.0
func FirstCredential(sources ...CredentialSource) CredentialSource
FirstCredential yields the first non-empty credential its sources produce.
Errors fall through, but are not lost ¶
A source returning ("", nil) is ABSENT and the next is tried. A source returning an error is BROKEN: it is also skipped, but its error is retained and returned — joined with any others — only when NO source produced a value.
Both extremes are worse. Aborting on the first error would let a transient outage in one source fail construction while a perfectly good credential sat unread in the next. Discarding errors, which this module used to do, makes a misconfigured source indistinguishable from an absent one — the silent absence that surfaces later as a confusing 401, and the reason the old resolution chain was replaced.
So: if something resolved, the failure was genuinely immaterial and is not reported. If nothing did, the caller is told why rather than merely that.
func StaticCredential ¶ added in v0.7.0
func StaticCredential(token string) CredentialSource
StaticCredential yields a credential the caller already holds.
This is the entry point for anyone not composing configuration through gitlab.com/phpboyscout/go/config — a test, an embedded caller, or a tool that obtained a token from its own login flow. The credential is passed as a credential rather than wrapped in a configuration container that exists only to be read back out.
type DeviceCode ¶ added in v0.2.0
type DeviceCode struct {
// UserCode is the short code the user types at VerificationURI.
UserCode string
// VerificationURI is where the user enters UserCode to authorize the request.
VerificationURI string
// VerificationURIComplete, when non-empty, is a URL that pre-fills the code
// (some providers supply it so the user need not type UserCode).
VerificationURIComplete string
// ExpiresIn is how long UserCode remains valid.
ExpiresIn time.Duration
}
DeviceCode carries the user-facing details of a device-authorization prompt — the "open this URL and enter this code" step of an OAuth device flow.
type DraftPublisher ¶ added in v0.27.0
type DraftPublisher interface {
// PublishRelease publishes the release on tagName, making it visible to
// everyone.
//
// # It is SAFE to call when nothing was stranded
//
// A release that is already published is returned with a NIL ERROR. Not
// [ErrAlreadyExists]: the caller asked for a published release on that tag
// and there is one.
//
// This is the guarantee a re-running release tool depends on. A verb whose
// whole purpose is recovering from an interrupted run has to be safe to
// call when the run was not, in fact, interrupted — otherwise every caller
// writes the same errors.Is dance and half of them get it wrong.
//
// Both platforms make it free. Measured on 2026-09-07: a publish of an
// already-published release answers 200 on GitHub and on Gitea 1.27.3, with
// published_at UNCHANGED, so a repeat does not re-date the release.
//
// # A tag carrying MORE THAN ONE release is refused
//
// GitHub permits several releases on one tag — a spike created three — so a
// provider can find more than one candidate, and it MUST NOT PICK.
//
// It returns a plain error naming every candidate, and publishes nothing.
// Deliberately not a sentinel: there is nothing a caller can do
// programmatically, because choosing which of two drafts is the real
// release is a judgement about intent and this contract has no verb to
// remove the other. Naming them is the whole of the help available, and
// guessing would publish the wrong release under a tag people trust with no
// delete to undo it.
//
// A conformant provider can no longer CREATE that state, but repositories
// carry it today, and those are exactly the ones this verb is for.
//
// # Returns
//
// The published release, and one of:
//
// - nil, when it is now published — whether this call published it or it
// already was.
// - an error wrapping [ErrNotFound] when the tag carries NO RELEASE AT
// ALL. That is not the same as a stranded draft, and a caller that
// confuses them will create a release it meant to finish.
//
// It means only that, which is why a provider must not reach for it
// when a write was accepted and answered with nothing: the release
// exists at that point and has probably just been published, and a
// caller branching as this sentence instructs would create a duplicate.
// A plain error is the honest answer there.
//
// On a forge where a draft is only visible to a caller with write
// access, ErrNotFound also covers "your token cannot see it". GitHub is
// one: drafts appear in the release listing for a writer and nowhere
// else, so a read-scoped token is told a draft it cannot see is absent.
// - a plain error when the tag carries more than one release, or a
// refusal sentinel. Nothing was published.
//
// # What it does NOT do
//
// It does not attach anything. A caller wanting to be sure the assets are
// complete enumerates them with [Release.GetAssets] first; publishing what
// is there is the whole of this verb.
//
// It does not un-publish, and there is no discard. Destroying published
// things is a maintainer act in this estate rather than a library call,
// which is why [ReleasePublisher] carries no delete and [PullRequests]
// carries no Merge. A provider's internal discard of a draft it created
// moments earlier remains unreachable from here.
PublishRelease(ctx context.Context, owner, repo, tagName string) (Release, error)
}
DraftPublisher is an OPTIONAL capability implemented by providers whose forge models an unpublished release.
It is separate from ReleasePublisher rather than a method on it, because GitLab models no draft at all and would otherwise have to stub a refusal on an interface it implements completely. A provider that cannot do a thing not implementing the interface reads better than one that implements it and says no.
Discover it with As, and treat a failed assertion and ErrNotSupported identically.
type Endpoint ¶ added in v0.12.0
type Endpoint struct {
// Type selects the provider implementation — the string it registered under.
Type string
// Host selects the instance. Empty means the provider's own default, which
// for a hosted forge is its public one and for a provider with no notion of
// a host is simply unused.
Host string
// Name selects which configured source of that type this is, and scopes the
// configuration subtree the provider reads. Empty means the default source,
// reading the bare <type> subtree.
//
// It exists because Type and Host together do not always identify a
// connection. Two credentials for one host are two sources; so are two
// download sources of a provider that never looks at Host at all.
//
// It must not contain a dot. The subtree is composed as <type>.<name>, and a
// dotted name would nest a level deeper than intended in configuration
// backends that treat dots as path separators — silently reading a subtree
// nobody meant.
Name string
}
Endpoint addresses a forge instance.
It is comparable, which is the point: a consumer that reuses providers can key on it directly, with no derived hash and no subset of a wider struct. A key built from a subset can return a provider constructed from inputs the caller never passed, and the caller cannot tell — a cache hit looks exactly like a cache miss.
Every field is connection identity. What a provider is asked to operate ON — an owner, a repository — is a parameter of the operation, not of the connection, and stays on the method that needs it.
Never serialise one into a single string ¶
Keep the three fields separate end to end: separate configuration keys, separate flags, separate struct fields. Host legitimately carries ':' (a port) and '/' (a path), so any inline "type:host:name" spelling collides with its own payload. A sibling module hit exactly this addressing a KMS key, where "service:keyid" is unusable because a key ARN is itself full of colons.
func (Endpoint) Section ¶ added in v0.12.0
Section returns the configuration subtree this endpoint reads, walking the Config one level at a time rather than composing a dotted key.
Two hops rather than one dotted lookup, deliberately: whether "a.b" means a nested path or a literal key is the Config implementation's business, and composing the key here would make the meaning depend on which backend the consumer happened to supply.
A nil cfg yields nil, so a provider supporting configuration-free construction needs no branch.
type GitRemote ¶ added in v0.4.0
type GitRemote struct {
// CloneURL is the URL to clone from, as reported by the API.
//
// It is DATA from the forge, not a constant, so a caller attaching a
// credential to it must pin it first with [HostTrusted] — the same rule that
// governs release asset URLs.
CloneURL string
// DefaultBranch is the branch a clone lands on, and the natural ref to pass
// to [Contents.GetFile].
DefaultBranch string
}
GitRemote is the git half of a Repository — the coordinates needed to clone it.
It is separated from the container metadata because the two are genuinely different facts: on GitLab and Bitbucket a forge container can exist, carry a description and publish a site while having no GIT REPOSITORY behind it.
type Issue ¶ added in v0.5.0
type Issue struct {
// Number is the PER-PROJECT issue number — GitLab's iid, GitHub's number,
// Gitea's index. It is not the forge's global ID, because the global ID is
// not what a URL or a human uses, and it is the number a caller polls with.
Number int
Title string
Body string
State IssueState
Labels []string
// URL is the human-facing web URL, and for many callers the single most
// important field: it is what gets shown to the person the issue was filed
// for.
URL string
Author string
CreatedAt time.Time
UpdatedAt time.Time
// ClosedAt is zero while the issue is open.
ClosedAt time.Time
// ClosedBy is empty when the forge does not report it.
ClosedBy string
}
Issue is a forge's issue, carrying what a caller needs to identify it, judge it, and link a human to it.
type IssueCommenter ¶ added in v0.26.0
type IssueCommenter interface {
// CreateIssueComment posts a comment on the issue numbered number.
//
// Returns an error wrapping [ErrNotFound] when no such issue exists. A
// permission refusal is NOT ErrNotFound: a caller must never be told an
// issue is absent because its token was too weak.
//
// When draft.IdempotencyKey is set and a comment already carries it, the
// EXISTING comment is returned and nothing is posted. See
// [CommentDraft.IdempotencyKey] for what that guarantee does and does not
// cover.
//
// # The order is load-bearing
//
// Body is sanitised with [Sanitise] unless draft.Unsanitised is set, and the
// key is appended AFTER that with [AppendIdempotencyKey] — the same order
// [IssueFiler.CreateIssue] requires, for the same reason. Sanitising
// afterwards feeds the key to the redactor, which rewrites a long opaque
// string, and the next search then looks for a key that was never written.
// Every retry posts a duplicate, silently.
CreateIssueComment(
ctx context.Context, owner, repo string, number int, draft CommentDraft,
) (Comment, error)
}
IssueCommenter is an OPTIONAL capability implemented by providers that can post a comment on an existing issue.
Providers whose forge has no issue tracker do not implement it and remain conformant. Bitbucket Cloud is permanently in that position: Atlassian withdrew its issue tracker, and the endpoint now answers 410 Gone.
type IssueDraft ¶ added in v0.5.0
type IssueDraft struct {
Title string
Body string
// Labels are the label NAMES to attach, and EVERY ONE MUST ALREADY EXIST
// on the repository. A name that matches no label is not sent and is
// reported back — see [IssueFiler.CreateIssue].
//
// The rule is the same one [PullRequestDraft.Labels] states, deliberately:
// a caller learning it once should not find it means something else on the
// neighbouring type in this package.
//
// Matching is EXACT, so "Support" does not resolve to a "support" label.
// A case-insensitive match is the failure that is silently wrong rather
// than loudly wrong — it binds a caller's name onto a label somebody else
// wrote, files against it, and reports success.
//
// Setting a label NEVER CREATES ONE, even on the forges that would. GitLab
// and GitHub both create an unknown name on the spot; Gitea cannot, because
// its API takes label IDs. The contract declines rather than guaranteeing
// what one adapter provably cannot honour — and because this is the
// capability that files a report into a project THE CALLER MAY NOT OWN, so
// creating a label as a side effect turns a typo into a permanent artefact
// on somebody else's tracker.
//
// Empty or nil attaches nothing.
//
// Specified by spec 0017:
// https://gitlab.com/phpboyscout/go/forge/-/wikis/specs/0017-one-rule-for-labels
Labels []string
// IdempotencyKey makes filing at-most-once across a retry. Empty opts out,
// and a create is then a plain create.
//
// It is PUBLISHED: the provider writes it into the issue body, where anyone
// who can read the issue can read it. Use an opaque value derived for this
// purpose — never a session token, a user identifier, or anything else
// whose disclosure would matter.
IdempotencyKey string
// Unsanitised sends Title and Body verbatim, skipping the secret redaction
// and mention neutralisation [IssueFiler.CreateIssue] applies by default.
//
// Named to be uncomfortable, because it should be. The default exists
// because both failures it prevents are irreversible and land on someone
// who is not the caller.
//
// The legitimate use is content the caller has already sanitised under a
// policy of its own, or content where the default's false positives — most
// often a SHA-256 digest — would corrupt the report.
Unsanitised bool
}
IssueDraft is what a caller supplies to file an issue.
type IssueFiler ¶ added in v0.5.0
type IssueFiler interface {
// CreateIssue files an issue and returns it as the forge recorded it —
// including the Number and URL, which a caller needs to link a human to it
// and to poll it later.
//
// # Sanitisation
//
// Title and Body are passed through [Sanitise] before sending, unless
// draft.Unsanitised is set. See that function for what it removes and why
// it is safe to apply by default.
//
// # At-most-once
//
// When draft.IdempotencyKey is non-empty, the provider MUST look for an
// existing issue carrying that key before creating one, and MUST return
// the existing issue rather than filing a second. The key is written into
// the created issue so the search can find it.
//
// The guarantee has limits, and they are stated here rather than
// discovered:
//
// - It is NOT atomic. Two concurrent creates with the same key can both
// find nothing and both file. This defends against a RETRY, not
// against concurrent callers.
// - The key is published, because it lives in a readable issue body.
// - It costs one extra search per create.
//
// The key is appended AFTER sanitisation. Sanitising afterwards would feed
// the key to the redactor, and a long opaque key would be rewritten —
// silently breaking the guarantee, because the search would then look for a
// key the issue does not carry.
//
// # Read this before writing the usual error check
//
// When draft.Labels is set and some label could not be applied, CreateIssue
// returns the FILED ISSUE together with an error wrapping [ErrNotHonoured].
// So the reflexive idiom
//
// issue, err := f.CreateIssue(ctx, owner, repo, draft)
// if err != nil {
// return err
// }
//
// treats a filed report as a failure. The idiom that is correct:
//
// issue, err := f.CreateIssue(ctx, owner, repo, draft)
// if err != nil && !errors.Is(err, forge.ErrNotHonoured) {
// return err // a real failure; nothing was filed
// }
// // issue exists either way. Log err if non-nil: a label was dropped.
//
// A caller that sets no labels can never see this, and its error handling
// does not change.
//
// Getting it wrong is less costly here than elsewhere, and only because of
// the at-most-once guarantee above: a caller that retries on the sentinel
// finds the existing issue rather than filing a duplicate — PROVIDED it set
// an IdempotencyKey. Without one, the retry files a second report.
//
// # Why a dropped label is reported rather than refused
//
// A report exists to be filed. Losing it over a marker is the worse
// outcome, and the marker is the decoration — so the issue is created and
// the loss is named, never the reverse.
//
// A name that matches no label is caught by resolving draft.Labels against
// the repository's labels before sending. That listing MUST be paginated:
// GitLab's default page is 20, GitHub pages by the Link header, and Gitea
// keeps repository and organisation labels on SEPARATE endpoints where the
// repository listing does not include the organisation's. Resolving against
// a truncated set reports a label absent that the caller can see in the UI.
//
// The hint on the returned error names the labels that did not land,
// reachable with errors.Hints.
CreateIssue(ctx context.Context, owner, repo string, draft IssueDraft) (Issue, error)
}
IssueFiler is an OPTIONAL capability implemented by providers that can create an issue.
It is a separate interface from Issues so that the write is visible in the type system rather than buried in prose: a deployment trusted to read a tracker but not to post to it declines this one and says so structurally.
It is no longer the module's only write — Snippets and PullRequests also write — but it is the only one that publishes to a tracker other people read, and the only one with no way to undo itself.
type IssueQuery ¶ added in v0.5.0
type IssueQuery struct {
// Text matches against title and body. Empty matches everything.
//
// This is a FILTER, not a relevance ranking. Providers use server-side
// search where the forge offers it and filter client-side where it does
// not, so match quality differs between providers and no ordering is
// promised.
//
// A provider MUST NOT ignore this field and return everything. A caller
// checking the first results for a duplicate and finding none would
// conclude there are none — the false negative that files a duplicate into
// a public tracker.
//
// Judging whether a match is a CREDIBLE duplicate remains the caller's.
Text string
// State narrows to open or closed issues. IssueStateUnknown means both.
State IssueState
// Labels narrows to issues carrying ALL of these.
Labels []string
// PageSize hints at the provider's internal page size. Zero means the
// provider's default.
PageSize int
}
IssueQuery narrows what Issues.SearchIssues returns.
type IssueState ¶ added in v0.5.0
type IssueState string
IssueState is an issue's open/closed status.
const ( // IssueStateUnknown is the zero value: the provider could not determine the // state. It is deliberately not a usable state. // // A caller polling for closure that reads an unset field as "open" waits // forever; one that reads it as "closed" tells someone their question was // resolved when nothing happened. Neither failure is visible, which is why // the zero value has to be inert. IssueStateUnknown IssueState = "" // IssueStateOpen means the issue is open. IssueStateOpen IssueState = "open" // IssueStateClosed means the issue is closed. IssueStateClosed IssueState = "closed" )
type Issues ¶ added in v0.5.0
type Issues interface {
// SearchIssues yields issues matching q, calling yield once per issue.
// Returning false from yield stops the search early and is not an error.
//
// The returned error is the single, complete answer to "did I see
// everything?". A nil error means the search completed; a NON-NIL error
// means the caller holds a PARTIAL view.
//
// That distinction is sharper here than anywhere else in this module. A
// duplicate search that fails and is read as "no duplicates found" causes a
// caller to file a duplicate into a public tracker — so an empty result set
// and a failed search must never be the same answer.
SearchIssues(ctx context.Context, owner, repo string, q IssueQuery, yield func(Issue) bool) error
// GetIssue returns one issue by its per-project number.
//
// Returns ErrNotFound when no such issue exists. A permission refusal is
// NOT ErrNotFound: a caller must never be told an issue is absent because
// its token was too weak.
GetIssue(ctx context.Context, owner, repo string, number int) (Issue, error)
// ListComments yields an issue's comments, calling yield once per comment.
// Returning false from yield stops early and is not an error. The error
// carries the same meaning as SearchIssues'.
//
// System comments are included and flagged; filtering them is the caller's
// decision, because "what counts as noise" is a policy this contract has no
// business fixing.
ListComments(ctx context.Context, owner, repo string, number int, q CommentQuery, yield func(Comment) bool) error
}
Issues is an OPTIONAL capability implemented by providers that can read a project's issues.
It is separate from IssueFiler because the credential requirement differs: reading is satisfied by a read-only API scope, while filing needs a read-write one. A deployment trusted to watch and search but not to write can therefore decline the write capability structurally, rather than discovering the limit on its first attempt to file.
type KeyManager ¶ added in v0.2.0
type KeyManager interface {
// UploadKey registers publicKey — an OpenSSH-format public key ("ssh-ed25519
// AAAA… comment") — under the given human-readable name on the authenticated
// account. The provider resolves its own credentials (as for the release
// [Provider]); name is a label the user will see in the account's key list.
UploadKey(ctx context.Context, name string, publicKey []byte) error
}
KeyManager is an OPTIONAL capability implemented by providers that can register an SSH public key on the authenticated account through their API.
Returns ErrNotSupported when the provider exposes no key API or it is disabled; the caller then skips automated upload and instructs the user to add the key manually.
type MergeRequest ¶ added in v0.16.0
type MergeRequest = PullRequest
MergeRequest is an alias for PullRequest.
type MergeRequestCommenter ¶ added in v0.26.0
type MergeRequestCommenter = PullRequestCommenter
MergeRequestCommenter is PullRequestCommenter under GitLab's name for the same thing. A true alias, so a provider satisfies both by implementing one.
type MergeRequestDraft ¶ added in v0.16.0
type MergeRequestDraft = PullRequestDraft
MergeRequestDraft is an alias for PullRequestDraft.
type MergeRequestState ¶ added in v0.16.0
type MergeRequestState = PullRequestState
MergeRequestState is an alias for PullRequestState.
type MergeRequests ¶ added in v0.16.0
type MergeRequests = PullRequests
MergeRequests is an alias for PullRequests, for callers and adapters whose forge uses GitLab's vocabulary. It is the SAME TYPE, not a wrapper: a provider satisfies both by implementing either.
type Option ¶ added in v0.7.0
type Option func(*Options)
Option configures a provider built through Lookup and a ProviderFactory.
func WithHTTPClient ¶ added in v0.13.0
WithHTTPClient supplies a fully built client for a provider to use as-is.
This transfers the redirect policy, and the obligation with it ¶
A provider that would otherwise build its own client installs a redirect policy on it: bounded redirects, no HTTPS-to-HTTP downgrade, and configured sensitive headers stripped on any hop leaving the origin. A provider that attaches a credential by hand — GitLab's PRIVATE-TOKEN is the case in this estate — depends on that last part to stop the credential following an object-storage redirect, or an open redirect on a self-hosted instance, off the host it pinned.
Supplying a client replaces that policy with yours. Supply one that carries an equivalent policy, or accept that the guarantee is now yours to keep. Prefer WithHTTPTransport, which shares the connection pool without moving the obligation.
A nil client is ignored.
An adapter uses this for its own API requests and NOT to fetch a release asset; see the package documentation above for why.
func WithHTTPTransport ¶ added in v0.13.0
func WithHTTPTransport(rt http.RoundTripper) Option
func WithLogger ¶ added in v0.7.0
WithLogger supplies the logger a provider emits diagnostics to.
The handler is wrapped so every attribute value this module logs passes through redaction first (see RedactingHandler). That is defence in depth, not the primary control — no credential value is passed to a logger at all; see Options and the package documentation.
Passing nil discards output, which is also the default. This module never writes to slog.Default: choosing a consumer's output stream is a decision that belongs to the consumer.
type Options ¶ added in v0.7.0
type Options struct {
// Logger receives this provider's diagnostics. Never nil after
// [NewOptions]: an unset logger becomes one that discards.
Logger *slog.Logger
// HTTPTransport is the transport a provider should build its clients on, so
// several providers share one connection pool. Nil means the provider builds
// its own, which is the default and always valid.
//
// A provider using it still builds its own client, and so keeps its own
// redirect and sensitive-header policy. See [WithHTTPTransport].
HTTPTransport http.RoundTripper
// HTTPClient is a fully built client a provider should use as-is, replacing
// the one it would have built — redirect policy included. Nil means the
// provider builds its own.
//
// A provider that honours both prefers this one, because a caller supplying
// a whole client has asked for exactly that. See [WithHTTPClient] for what it
// transfers.
HTTPClient *http.Client
}
Options carries the cross-cutting settings a provider constructor accepts through the registry, where there is no settings struct to put them in.
Construct one with NewOptions; the zero value is not meant to be used directly, because Options.Logger must be normalised (see NormaliseLogger).
func NewOptions ¶ added in v0.7.0
NewOptions applies opts and normalises the result, so a provider can use the returned value without nil-checking anything.
type Prompter ¶ added in v0.2.0
type Prompter interface {
// ShowDeviceCode presents a device-authorization prompt to the user — typically
// "open dc.VerificationURI and enter dc.UserCode". It returns once the prompt has
// been shown; the [Authenticator] then polls the provider for completion. A
// non-nil error aborts the login.
ShowDeviceCode(ctx context.Context, dc DeviceCode) error
}
Prompter lets a provider surface an interactive authentication prompt without depending on any UI toolkit — the caller (typically a CLI) renders it. This is how forge offers a device flow while staying framework-free.
type Provider ¶
type Provider interface {
GetLatestRelease(ctx context.Context, owner, repo string) (Release, error)
GetReleaseByTag(ctx context.Context, owner, repo, tag string) (Release, error)
// ListReleases returns up to limit releases, newest first.
//
// # Pagination contract
//
// limit is a total, not a page size. A provider MUST paginate across the
// platform's pages until it has gathered limit releases or the history is
// exhausted, whichever comes first — the caller receives "everything up to
// limit", never a silently truncated first page. This matters because
// callers request a fixed window (GTB's changelog walk asks for 100) that
// commonly exceeds a platform's per-page maximum: GitHub and GitLab cap
// per_page at 100, and Gitea instances commonly cap page size at 50, so a
// single request would drop older releases and yield a truncated changelog.
//
// A limit <= 0 means "no explicit bound"; a provider returns its natural
// first page rather than walking all history.
//
// A provider whose platform has no concept of enumerable releases returns an
// error wrapping [ErrNotSupported] (Bitbucket Downloads does this).
//
// # Rate limiting
//
// forge does not retry internally. A provider surfaces the platform's
// rate-limit response (HTTP 429, or GitHub's 403 + X-RateLimit-Remaining: 0)
// as an error, wrapped verbatim so a caller can inspect it for a Retry-After
// and decide whether to back off — pagination loops here do not sleep and
// retry on the caller's behalf.
ListReleases(ctx context.Context, owner, repo string, limit int) ([]Release, error)
// DownloadReleaseAsset opens a stream of the asset's bytes.
//
// The second return value is a REDIRECT URL, and it is a security
// boundary rather than a convenience. Some APIs answer an asset request
// with a redirect to storage the client is expected to follow itself
// (this is go-github's behaviour, which surfaces the location instead of
// following it). A provider that receives such a redirect and does NOT
// follow it returns the location here, with a nil or empty reader.
//
// Callers are expected to REFUSE a non-empty redirect rather than follow
// it: following one would fetch bytes from a host the caller never
// vetted, defeating the point of pinning the API host. Return "" whenever
// the body is served directly, which is the common case — three of the
// four first-party providers always do.
//
// A provider MAY instead resolve the redirect itself, provided it follows
// it with a CREDENTIAL-FREE HTTP client and returns the resulting body with
// "" for the redirect URL. This is the GitHub adapter's stance: go-github
// follows the storage redirect using a token-free client, so no credential
// reaches the redirect target and the caller's refuse-on-redirect boundary
// is satisfied by construction (the URL is always ""). A provider that
// takes this route MUST NOT attach its API credential to the followed
// request — the pin exists precisely because the redirect target is
// author-influenced and unvetted.
DownloadReleaseAsset(ctx context.Context, owner, repo string, asset ReleaseAsset) (io.ReadCloser, string, error)
}
Provider defines the operations a release backend must support.
Implement every method. Where a platform has no equivalent concept, return an error wrapping ErrNotSupported rather than a nil result — see ChecksumProvider for how callers treat that sentinel.
type ProviderFactory ¶
type ProviderFactory func( ctx context.Context, ep Endpoint, cfg Config, opts ...Option, ) (Provider, error)
ProviderFactory is a function that constructs a forge.Provider for an Endpoint, from an optional configuration reader.
The context bounds construction, which can reach the network or a credential store: a CredentialSource the consumer supplied may call Vault, SSM or an OS keychain, and resolving it against a background context would leave the caller's deadline unhonoured.
opts carries cross-cutting settings that have no natural home in a config subtree — WithLogger today. A factory that wants none can ignore them; one that wants them calls NewOptions.
func Lookup ¶
func Lookup(sourceType string) (ProviderFactory, error)
Lookup returns the ProviderFactory registered for the given source type. Returns ErrProviderNotFound if no factory has been registered for that type.
type ProviderUnwrapper ¶ added in v0.3.0
type ProviderUnwrapper interface {
Unwrap() Provider
}
ProviderUnwrapper is implemented by a Provider decorator that wraps another Provider. As uses it to walk the decorator chain when discovering an optional capability interface (ChecksumProvider, SignatureProvider, Authenticator, KeyManager).
Why this exists ¶
Every capability is an OPTIONAL interface a provider MAY implement, and callers discover them with a type assertion. A decorator that wraps a Provider and forwards only the five required methods silently strips those optional interfaces — a direct assertion on the wrapper then reports the capability absent, and because callers treat "not implemented" as a graceful fallback, verification quietly downgrades instead of failing.
A decorator that carries this method lets As see past it. Implement it on any wrapper you write; the first-party providers are not decorators and do not implement it.
type PullRequest ¶ added in v0.16.0
type PullRequest struct {
// Number is the PER-PROJECT number — GitLab's iid, GitHub's number,
// Gitea's index. It is not the forge's global ID, because the global ID is
// not what a URL or a human uses, and it is the number the other methods
// here take.
Number int
Title string
Body string
State PullRequestState
SourceBranch string
TargetBranch string
// URL is the human-facing web URL.
URL string
Author string
CreatedAt time.Time
UpdatedAt time.Time
// MergedAt is zero unless State is PullRequestStateMerged.
//
// It is the ordering key for [PullRequests.FindLastMerged], and it is not
// interchangeable with UpdatedAt: a comment posted after a merge moves
// UpdatedAt and leaves this alone.
MergedAt time.Time
// ClosedAt is zero while the pull request is open.
ClosedAt time.Time
// Labels are the label NAMES the pull request carries, as the forge
// reported them.
//
// Empty means the pull request carries none, OR that the forge has no
// label concept — Bitbucket Cloud does not. The two are not distinguished
// here, because a caller that must know asks the provider matrix rather
// than inferring a platform's feature set from one empty slice.
Labels []string
}
PullRequest is a proposed change against a target branch, as the forge recorded it.
There is no head SHA, and that is deliberate ¶
A forge records a pull request's head commit and will hand it to you. It is wrong exactly when it matters most: GitLab 19.2 rebases automatically before a fast-forward merge and does NOT write the result back, so the recorded head stays at a commit the rebase orphaned. A release tool that trusts it tags a commit that is not on the target branch — measured at 9 of 231 tags in one group, silently dropping non-changelog commits.
The field is therefore absent rather than documented as unreliable. A field that exists gets read, a warning in a doc comment is not present at the call site, and the failure is a permanent tag. Use PullRequests.ResolveMergedCommit.
A caller that genuinely wants the raw record can reach the platform's own type through ProviderUnwrapper. That is deliberately the awkward path.
type PullRequestCommenter ¶ added in v0.26.0
type PullRequestCommenter interface {
// Comment posts a comment on the pull request numbered number.
//
// Returns an error wrapping [ErrNotFound] when no such pull request exists,
// and honours draft.IdempotencyKey as [IssueCommenter.CreateIssueComment]
// does — including the sanitise-then-append order, which is load-bearing.
//
// # This is a conversation comment, not a review comment
//
// A provider MUST post to the pull request's conversation rather than to a
// diff. On GitHub the two are different endpoints whose names invite the
// mistake: IssuesService.CreateComment is this one, because a pull request
// IS an issue there, while PullRequestsService.CreateComment posts a review
// comment against a line of a diff. Both compile, both succeed, and only one
// is what a caller asked for.
Comment(
ctx context.Context, owner, repo string, number int, draft CommentDraft,
) (Comment, error)
// ListRequestComments returns every comment on the pull request numbered
// number.
//
// # Why the name is neither ListComments nor ListPullRequestComments
//
// ListComments is TAKEN. [Issues.ListComments] already has it, with a yield
// signature, and every provider implements Issues and this on one concrete
// type — so reusing the name makes this interface unimplementable by
// everybody. Go refuses two methods of one name on one type, which is the
// loud version of the collision this file's opening comment describes.
//
// ListPullRequestComments would collide with nothing and would hand the
// GitLab-flavoured noun back through [MergeRequestCommenter], which is the
// thing [PullRequests]' bare Find, Create and Close exist to avoid.
//
// "Request" is the half both names share, so this reads correctly under
// either.
//
// # It exists so at-most-once can be CHECKED
//
// [Issues.ListComments] covers the issue side and nothing covered this one,
// which would have made draft.IdempotencyKey a guarantee the contract
// asserts, providers implement privately, and the conformance harness cannot
// test. A check that cannot fail is worse than no check.
//
// # It MUST return the complete set
//
// It paginates internally. A NON-NIL error means the slice is PARTIAL and
// must not be read as the full set — the rule [Issues.ListComments] states
// and which bites harder here, because a partial view read as "no duplicate"
// is what posts the duplicate.
ListRequestComments(ctx context.Context, owner, repo string, number int) ([]Comment, error)
}
PullRequestCommenter is an OPTIONAL capability implemented by providers that can post a comment on an existing pull request.
Why the methods carry no noun ¶
MergeRequests is a type ALIAS, and an alias can rename a type but never a method. CreatePullRequestComment would hand the GitLab-flavoured noun back at every call site of MergeRequestCommenter. The same reasoning gives PullRequests its bare Find, Create and Close, and it is why this reads differently from IssueCommenter — each is consistent with its own neighbour rather than with the other.
type PullRequestDraft ¶ added in v0.16.0
type PullRequestDraft struct {
Title string
Body string
// SourceBranch holds the proposed commits. It must already exist on the
// forge; this contract does not create branches.
SourceBranch string
// TargetBranch is what the change is proposed against. Empty means the
// repository's default branch.
TargetBranch string
// Labels are the label NAMES to attach, and EVERY ONE MUST ALREADY EXIST
// on the repository. A name that matches no label is not sent and is
// reported back — see [PullRequests.Create].
//
// Names, not identifiers: it is what a human types into the forge's filter
// box, it is what [IssueDraft.Labels] already takes, and it is the only
// identifier that means the same thing on every forge. Matching is EXACT,
// so "Release::Pending" does not resolve to a "release::pending" label.
//
// Setting a label NEVER CREATES ONE, even on the forges that would. GitLab
// and GitHub both create an unknown name on the spot; Gitea cannot, because
// its API takes label IDs and an unresolvable name has no ID to send. The
// contract declines rather than guaranteeing what one adapter cannot
// honour, and because creating a label as a side effect of tagging a pull
// request turns a caller's typo into a permanent artefact on someone's
// project.
//
// Empty or nil attaches nothing. There is no way to change a pull request's
// labels afterwards; see the file comment.
//
// Specified by spec 0014 D2 and D7:
// https://gitlab.com/phpboyscout/go/forge/-/wikis/specs/0014-labels-on-pull-requests
Labels []string
}
PullRequestDraft is what a caller supplies to open a pull request.
type PullRequestState ¶ added in v0.16.0
type PullRequestState string
PullRequestState is where a pull request has got to.
The vocabulary is deliberately narrow. Only one first-party provider implements this capability today, so a wider normalisation would be a guess dressed as a contract — GitLab alone distinguishes a locked merge request, and there is no second implementation to test that mapping against.
const ( // PullRequestStateUnknown is the zero value: the provider did not say. It // is not a synonym for any other value, and in particular it must never be // read as "not merged". PullRequestStateUnknown PullRequestState = "" // PullRequestStateOpen is proposed and not yet resolved. PullRequestStateOpen PullRequestState = "open" // PullRequestStateClosed was closed without merging. PullRequestStateClosed PullRequestState = "closed" // PullRequestStateMerged landed on the target branch. // // It says the forge considers the request merged. It does NOT say which // commit resulted, and a caller that needs one asks // [PullRequests.ResolveMergedCommit] rather than inferring it from here. PullRequestStateMerged PullRequestState = "merged" )
type PullRequests ¶ added in v0.16.0
type PullRequests interface {
// Find returns the OPEN pull request opened from sourceBranch.
//
// At most one can exist: a forge refuses a second open request for the same
// source and target pair. Verified across 50 open merge requests sampled
// group-wide, no source/target pair carried two.
//
// Returns an error wrapping ErrNotFound when there is none. A permission
// refusal is NOT ErrNotFound: a caller must never be told nothing is open
// because its token was too weak, since the response to that is to open a
// duplicate.
Find(ctx context.Context, owner, repo, sourceBranch string) (PullRequest, error)
// FindLastMerged returns the most recently MERGED pull request from
// sourceBranch, ordered by merge time.
//
// It is separate from Find because the two differ in CARDINALITY, not
// merely in which state they look at. A release branch is reused: one such
// branch carried 0 open and 53 merged merge requests. At most one can be
// open; many have merged, so this one needs an ordering and Find does not.
// A single method taking a state would return "the one" in one mode and an
// arbitrary one of fifty-three in the other, with a signature unable to say
// which.
//
// Ordering is by MERGE time. A provider must not order by last-updated,
// which drifts after the merge and is a claim about recency rather than
// evidence of it.
//
// Returns an error wrapping ErrNotFound when nothing from that branch has
// merged.
FindLastMerged(ctx context.Context, owner, repo, sourceBranch string) (PullRequest, error)
// Create opens a pull request and returns it as the forge recorded it,
// including the Number and URL.
//
// It is NOT idempotent and makes no at-most-once claim. A caller that must
// not duplicate calls Find first, and accepts that the gap between the two
// is a race it owns.
//
// # Read this before writing the usual error check
//
// When draft.Labels is set and some label could not be applied, Create
// returns the CREATED PULL REQUEST together with an error wrapping
// [ErrNotHonoured]. So the reflexive idiom
//
// pr, err := p.Create(ctx, owner, repo, draft)
// if err != nil {
// return err
// }
//
// treats a successful create as a failure. Because Create makes no
// at-most-once claim, the retry that usually follows opens a SECOND pull
// request. The idiom that is correct:
//
// pr, err := p.Create(ctx, owner, repo, draft)
// if err != nil && !errors.Is(err, forge.ErrNotHonoured) {
// return err // a real failure; nothing was created
// }
// // pr exists either way. Log err if non-nil: a label was dropped.
//
// A caller that sets no labels can never see this, and its error handling
// does not change.
//
// # What "could not be applied" covers
//
// A provider establishes what was applied by READING THE RESULT BACK and
// comparing it against draft.Labels, never by assuming the request worked.
// Two things are caught only that way:
//
// - A name that resolved and did not stick. GitLab's scoped labels are
// mutually exclusive, so a draft carrying both "release::pending" and
// "release::tagged" keeps one and the forge says nothing.
// - Anything else a forge drops silently.
//
// A name that matches no existing label is caught earlier, by resolving
// draft.Labels against the repository's labels before sending. That listing
// MUST be paginated: GitLab's default page is 20, and resolving against a
// truncated set reports a label absent that the caller can see in the UI.
//
// Either way the hint on the returned error names the labels that did not
// land, reachable with errors.Hints.
//
// A provider whose forge has no labels at all creates the pull request and
// reports every requested label as not honoured. It does not refuse: a
// caller must not lose a pull request over a marker.
Create(ctx context.Context, owner, repo string, draft PullRequestDraft) (PullRequest, error)
// Update replaces the title and body. Both are sent as given.
//
// There is no partial update and no way to clear a field, because every
// call supplies the whole value. The alternative had a trap in it: a
// forge distinguishes "field omitted, leave it" from "field sent empty,
// clear it", and a plain string cannot express both — read as "leave" and
// a caller can never clear, read as "clear" and a caller changing only the
// title silently wipes the body.
//
// Supplying both costs nothing, since Find already returned them.
//
// # The lost-update race is real and is not solved here
//
// A human editing the body between a caller's read and this write loses
// that edit. No first-party forge offers a compare-and-swap on this
// operation, so the race exists under any signature. Which regions of a
// body are machine-owned is the CALLER's policy, stated once in its own
// composition — this contract does not merge, diff or protect regions.
Update(ctx context.Context, owner, repo string, number int, title, body string) error
// Close closes a pull request without merging it.
//
// Returns an error wrapping ErrNotFound when no such pull request exists,
// so a caller cleaning up can treat "already gone" as success. Closing an
// already-closed pull request is not an error.
Close(ctx context.Context, owner, repo string, number int) error
// ResolveMergedCommit returns the commit ON THE TARGET BRANCH that this
// pull request produced.
//
// This is the method the whole capability exists for.
//
// # The returned SHA has been confirmed present on the target branch
//
// That confirmation is the contract. HOW a provider finds its candidate is
// its own business — the forge's recorded merge commit, its squash commit,
// a reverse lookup from the branch's recent history — but it MUST NOT
// return one it has not checked.
//
// # Confirmation means containment, not existence
//
// An orphaned commit still EXISTS. Fetching it succeeds and returns its
// message intact, so a provider that confirms by fetching passes its own
// check and is still wrong. Only asking which branches contain the commit
// separates the two. On the reproduced case that query returned no branches
// for the orphan and the target branch for the commit that landed.
//
// # Returns ErrNotFound when nothing confirms
//
// Never a best guess. "I could not establish what landed" and "nothing
// landed" call for the same action from a caller — do not tag — and a
// plausible wrong SHA is worse than an honest refusal, because a tag cannot
// be quietly withdrawn.
ResolveMergedCommit(ctx context.Context, owner, repo string, number int) (string, error)
}
PullRequests is an OPTIONAL capability implemented by providers that can manage a proposed change against a target branch.
Providers for forges without the feature do not implement it and remain conformant, and so do providers whose forge has it but whose adapter has not been asked for it yet. Both are indistinguishable from a caller's side, which is the capability pattern rather than a gap.
type Release ¶
type Release interface {
GetName() string
GetTagName() string
GetBody() string
GetDraft() bool
GetAssets() []ReleaseAsset
}
Release defines the common abstraction for a software forge.
type ReleaseAsset ¶
ReleaseAsset defines the common abstraction for a release asset.
type ReleaseAssetPublisher ¶ added in v0.22.0
type ReleaseAssetPublisher interface {
// CreateReleaseWithAssets publishes a release carrying assets, such that NO
// OBSERVER WITHOUT WRITE ACCESS EVER SEES IT WITHOUT THEM.
//
// That is the guarantee, and it is deliberately a statement about what is
// observable rather than about how many requests are sent. Only one of the
// three platforms can do it in a single request, and a contract promising
// one would be honourable there and nowhere else:
//
// - GitLab publishes the bytes to its package registry, which needs no
// release, then creates the release with every link in the same
// request. One visible state, and it is the complete one.
// - GitHub and Gitea create the release as a DRAFT, attach, then publish.
// A draft is not visible without write access on either platform, so
// from an observer's side the effect is the same.
//
// # Every rule CreateRelease states still applies
//
// The tag must already exist and is never created; draft.Commit is resolved
// and compared rather than sent; a tag that already carries a release
// returns [ErrAlreadyExists]. Read [ReleasePublisher.CreateRelease] for the
// reasoning, all of which is unchanged.
//
// The tag check happens BEFORE any byte is uploaded. Uploading first and
// finding the tag wrong afterwards would leave bytes in a package registry
// for a release that was never created.
//
// # Read this before writing the usual error check
//
// Like CreateRelease, this returns the CREATED RELEASE together with an
// error wrapping [ErrNotHonoured] when part of what was asked could not be
// applied. The idiom that is correct:
//
// rel, err := p.CreateReleaseWithAssets(ctx, owner, repo, draft, assets)
// if err != nil && !errors.Is(err, forge.ErrNotHonoured) {
// return err // a real failure; nothing was created
// }
// // rel exists either way. Log err if non-nil: something was dropped.
//
// What it never does is succeed silently on a partial. The guarantee is the
// whole point of the capability, so a release that went out incomplete says
// so.
//
// # A Location the platform cannot hold becomes a footer, and is reported
//
// On GitHub and Gitea a [ReleaseAssetSource] carrying Location is rendered
// as a link in a footer appended to the release body, and reported with
// [ErrNotHonoured].
//
// Both halves matter. The footer keeps the location reachable by a human
// reading the release; the error is owed to a machine, because
// [Release.GetAssets] will NOT contain it and a caller enumerating assets
// would otherwise be told nothing is missing when something is.
//
// The footer is written ONCE, here. [ReleasePublisher.UpdateRelease]
// replaces the body wholesale, so an update that does not carry the footer
// forward removes it — which regions of a body are machine-owned is the
// caller's policy, as it is everywhere else in this contract.
//
// # A draft the CALLER asked for is honoured
//
// draft.Draft is intent, not merely the mechanism above. Where a platform
// models it, a caller asking for a draft gets one carrying its assets: the
// same route runs and the final publish step is not taken. GitLab models
// neither draft nor prerelease and continues to report [ErrNotHonoured] for
// them.
//
// # If an asset fails to attach, nothing is left behind
//
// On the platforms that take the draft route, a provider deletes the
// unpublished draft it created rather than stranding the caller with a
// half-built release that [ErrAlreadyExists] would block a retry against.
// That is not a delete capability: nothing here lets a caller ask for a
// deletion, and the only thing a provider may remove is a draft it created
// moments earlier and never published. The tag is untouched.
CreateReleaseWithAssets(
ctx context.Context, owner, repo string, draft ReleaseDraft, assets []ReleaseAssetSource,
) (Release, error)
// AddReleaseAssetLocation attaches an ALREADY-HOSTED asset to the release on
// tagName, which [ReleasePublisher.AddReleaseAsset] cannot express because
// it takes an io.Reader.
//
// It is the after-the-fact sibling of the Location half above, and it
// carries a limit that CreateReleaseWithAssets does not.
//
// # A source carrying Content is a CALLER ERROR here
//
// This method attaches a location. Handed a [ReleaseAssetSource] carrying
// Content it refuses before any request is sent, and does NOT quietly
// upload the bytes — doing that would silently become
// [ReleasePublisher.AddReleaseAsset] and hide which of the two paths ran.
//
// The refusal is a plain error with no sentinel, exactly as
// [ReleaseAssetSource.Validate] reports a malformed source, because a
// caller cannot usefully branch on its own programming mistake. In
// particular it is NOT [ErrNotFound]: on this method that sentinel means no
// release exists for tagName, and reporting a wrong-shaped argument with it
// sends a caller looking for a release that is right there.
//
// # There is no footer fallback here
//
// On a platform that cannot hold a location as an asset, this returns an
// error wrapping [ErrNotHonoured] and writes NOTHING. It does not append a
// footer, because doing so would mean reading the release body, modifying
// it and writing it back — and that read-modify-write carries the
// lost-update race [ReleasePublisher.UpdateRelease] already records as
// unsolved, on every call.
//
// A caller that needs the location recorded on such a platform uses
// CreateReleaseWithAssets, where the body is composed once and no existing
// content can be lost.
//
// Returns an error wrapping [ErrNotFound] when no release exists for
// tagName.
AddReleaseAssetLocation(ctx context.Context, owner, repo, tagName string, asset ReleaseAssetSource) error
}
ReleaseAssetPublisher is an OPTIONAL capability implemented by providers that can publish a release COMPLETE, and can attach an asset the caller has already hosted elsewhere.
Providers whose forge has no release concept do not implement it and remain conformant, and so do providers whose forge has one but whose adapter has not been asked for this yet. Both are indistinguishable from a caller's side.
It is separate from ReleasePublisher rather than an extension of it because a ReleaseAssetSource carrying Content is single-use, and putting readers on ReleaseDraft would make a value that reads as plain data silently non-reusable.
An attached asset is reachable at the platform's CONVENTIONAL address ¶
Not only at the address it was hosted at. Every asset attached through this capability, by either method and in either shape, is fetchable at the address that platform conventionally serves a release asset from, derived from the asset's Name:
GitLab <host>/<owner>/<repo>/-/releases/<tag>/downloads/<name> GitHub, Gitea the browser download URL the platform issues on upload
That is the address a human, a script and a Dockerfile reach for, and it is the one a consumer writes down. A provider whose platform issues it on upload honours this by doing nothing; one whose platform makes it a property of the asset must set that property.
Why it is stated rather than left to each adapter ¶
Because its absence is invisible from every angle except a fetch. A release missing it EXISTS, reports the right asset count, and hands back every Location it was given, so Release.GetAssets says nothing is wrong. A consumer following the conventional address gets a 404 that reads as a missing file rather than as a missing property of the release.
It is the same shape as the window this capability was built to close, one layer along: the release looks complete and one of its addresses does not work. Counting assets does not catch it. Fetching does, and a provider's own tests are where that fetch belongs — see go/forge#19, where it reached a consumer's build first.
Nothing is added to ReleaseAssetSource for it. A field would make the address opt-in a second time, one layer up, and every caller who did not set it would keep exactly this failure.
Where a platform cannot offer one ¶
A provider that attaches an asset and cannot give it the platform's conventional address MUST report that with ErrNotHonoured beside the created release, for the same reason a dropped Location is reported: Release.GetAssets tells a caller the asset is there, and only the error can say that one of its addresses is not.
This is not hypothetical, and the case is narrower than it sounds. GitLab accepts only word characters, hyphens, dots and slashes in the path it derives the address from, and REJECTS THE WHOLE ASSET for anything else — measured, so a name carrying a space or a "+", which semver build metadata produces on its own, is attached without an address rather than failing to attach at all. The asset is there; one of its addresses is not, and the hint names it.
The asset is still attached, always. A name this contract accepts must never cost a caller the asset over an address.
Specified by spec 0018: https://gitlab.com/phpboyscout/go/forge/-/wikis/specs/0018-the-address-a-release-asset-is-fetched-from
type ReleaseAssetSource ¶ added in v0.22.0
type ReleaseAssetSource struct {
// Name is the asset's filename as it should appear on the release, and is
// REQUIRED for both shapes.
//
// For a Location it is also the link text, so it is what a human sees.
//
// It is also what the platform's conventional asset address is derived
// from — see [ReleaseAssetPublisher] — so it is the name a consumer will
// write into a fetch URL, not merely a label.
Name string
// Size is the byte count, REQUIRED when Content is set and ignored
// otherwise.
//
// Two of the platforms need it before the body and neither can obtain it
// from a bare reader, which is why it is not optional and not derived.
Size int64
// Content is the asset's bytes. It is read EXACTLY ONCE and not seeked, so
// a caller may stream a build artefact without landing it on disk first.
//
// Reading once is also why a [ReleaseAssetSource] carrying Content cannot be
// reused across calls. A retry loop that re-sends the same value publishes
// an empty asset from a drained reader, which is the failure this whole
// capability exists to prevent, arriving one layer up.
Content io.Reader
// Location is an absolute http or https URL where the bytes already live.
//
// # This is not honoured everywhere, and the fallback is visible
//
// GitLab records it as a release asset link, which is that platform's
// native asset. GitHub and Gitea have no concept of an asset that is a
// link, so a provider there renders it into the release notes as a footer
// and reports it with [ErrNotHonoured] — see [ReleaseAssetPublisher].
//
// # Nothing is fetched, and reachability is NEVER checked
//
// A provider does not download this URL to convert it into bytes, and it
// does not HEAD it, resolve it, or otherwise confirm it serves anything.
// That is a decision rather than an omission: the address is supplied by
// whoever is publishing, and a request this module makes on their behalf to
// a host it does not control is a capability nobody asked for — the same
// reasoning that keeps a caller's HTTP client away from an asset URL.
//
// **So a forge will happily record a location that 404s.** The guarantee
// this capability makes is that no observer sees the release before its
// assets are ATTACHED; that the bytes are actually there is the caller's
// guarantee, and it is the one worth checking before publishing rather than
// after.
//
// What IS checked is shape: absolute, http or https, with a host. That
// catches the mistake which would otherwise link to nothing at all, since a
// relative path resolves against the forge's own host, and it costs no
// request.
//
// Specified by spec 0016:
// https://gitlab.com/phpboyscout/go/forge/-/wikis/specs/0016-assets-rules-the-contract-does-not-state
Location string
}
ReleaseAssetSource is one asset to attach, supplied EITHER as content the caller holds OR as a location it has already put the bytes.
Exactly one of Content and Location is set. Both or neither is a caller error, caught by ReleaseAssetSource.Validate before any request is sent, because the two shapes are not interchangeable and a provider must know which it got.
func (ReleaseAssetSource) IsLocation ¶ added in v0.22.0
func (a ReleaseAssetSource) IsLocation() bool
IsLocation reports whether this source names a location rather than carrying bytes. Only meaningful once ReleaseAssetSource.Validate has passed.
func (ReleaseAssetSource) Validate ¶ added in v0.22.0
func (a ReleaseAssetSource) Validate() error
Validate reports whether this source is one the contract can carry, and is called by a provider BEFORE any request is sent.
It exists on the contract rather than in each adapter so that a malformed asset is refused identically everywhere, rather than reaching three different platform errors.
type ReleaseDraft ¶ added in v0.19.0
type ReleaseDraft struct {
// TagName is the tag the release is attached to. It MUST already exist;
// see [ReleasePublisher.CreateRelease].
TagName string
// Commit is the full SHA that TagName is expected to point at, and it is
// checked rather than sent.
//
// This field looks like it should tell the forge where to put the release,
// and no forge will let it. On GitHub the equivalent parameter is
// documented as "unused if the Git tag already exists"; GitLab behaves the
// same way and does not say so, which was confirmed by creating a tag at
// one commit and a release naming another — the request succeeded, returned
// no warning, and the release sat on the tag's commit.
//
// So a provider MUST resolve TagName, compare what it points at against
// this value, and return an error wrapping [ErrNotFound] on a mismatch.
// That turns an instruction the forge may discard into a check it cannot
// fake, which is the same reasoning as [PullRequests.ResolveMergedCommit]
// one step later in the same workflow.
//
// A full SHA. A provider must reject a branch name or an abbreviation
// rather than resolving it, because resolving is what reopens the race.
Commit string
// Name is the release's title. REQUIRED.
//
// GitHub and GitLab both accept an empty name; Gitea rejects one in its
// SDK before the request is sent. Requiring it here means the same call
// behaves the same way everywhere, rather than succeeding on two forges and
// failing locally on the third.
Name string
// Body is the release notes, in the forge's markdown flavour.
Body string
// Draft asks for a release that is not yet published, and Prerelease for
// one marked as not production-ready.
//
// GitHub and Gitea model both. GitLab models NEITHER — its create API has
// no such field, which is a gap in the platform rather than in its client.
//
// A provider that cannot honour these MUST still create the release, and
// MUST report what it could not apply by returning [ErrNotHonoured]
// alongside the created release. It must not refuse, which would deny a
// caller a release over a field the forge does not model, and it must not
// stay silent, which would hand back a PUBLISHED release to a caller who
// asked for a private one — on the one operation where that is expensive
// and where [ReleasePublisher] offers no delete to undo it.
Draft bool
Prerelease bool
}
ReleaseDraft is what a caller asks for when creating a release.
type ReleasePublisher ¶ added in v0.19.0
type ReleasePublisher interface {
// CreateRelease publishes a release for draft.TagName.
//
// # The tag must already exist
//
// CreateRelease returns an error wrapping [ErrNotFound] when the tag is
// absent. It never creates one, and the reason is not only that tagging
// belongs elsewhere.
//
// Given a tag that does not exist, a forge will happily create it from
// whatever ref it was passed — so without this refusal, CreateRelease
// silently becomes a tagging operation performed with the caller's credential, which
// is precisely the credential whose writes may not fire the pipeline that
// was meant to follow.
//
// Worse, creating is not atomic. A create that FAILS can still leave the
// tag behind: a rejected asset link returned 400 with no release created,
// and the tag it had been asked to create remained. A caller retrying then
// finds a tag this contract wrote, and draft.Commit is checked against it —
// a check against your own output proves nothing. Refusing an absent tag
// removes the whole class instead of handling it.
//
// # Returns
//
// The created release, and one of:
//
// - nil, when everything asked for was applied.
// - an error wrapping [ErrNotHonoured] TOGETHER WITH the created release,
// when the release exists but some field could not be applied. Read
// [ErrNotHonoured] before writing the usual error check.
// - an error wrapping [ErrAlreadyExists] when the tag already carries a
// release, [ErrNotFound] when the tag is absent or points somewhere
// other than draft.Commit, or a refusal sentinel. In every one of these
// the returned Release is nil and nothing was created.
CreateRelease(ctx context.Context, owner, repo string, draft ReleaseDraft) (Release, error)
// UpdateRelease amends an existing release's name and notes.
//
// It exists so notes can be corrected after the fact. It cannot move a
// release to another commit, cannot change its tag, and there is no delete
// — see the file comment for where that line sits and why.
//
// Returns an error wrapping [ErrNotFound] when no release exists for
// tagName.
UpdateRelease(ctx context.Context, owner, repo, tagName, name, body string) error
// AddReleaseAsset attaches a file to the release on tagName.
//
// content is read exactly once and not seeked, so a caller may stream a
// build artefact without landing it on disk first. size is the byte count
// and is REQUIRED: two of the four platforms need it before the body, and
// neither can obtain it from a bare reader.
//
// name is the asset's filename as it should appear on the release. A
// provider derives the content type from its extension, falling back to
// application/octet-stream.
//
// # This is not the same operation on every forge
//
// GitHub and Gitea accept a binary into the release. GitLab has no such
// endpoint at all — its release assets are LINKS to files hosted elsewhere
// — so a provider there uploads the bytes somewhere the instance will serve
// them and links the result. That is a visible side effect rather than a
// hidden one, and the provider's own documentation states where the file
// lands.
//
// # The asset is reachable at the platform's conventional address
//
// Derived from name, on this method exactly as on
// [ReleaseAssetPublisher], where the rule is stated in full along with
// what a provider does when its platform cannot offer one.
//
// Returns an error wrapping [ErrNotFound] when no release exists for
// tagName.
AddReleaseAsset(ctx context.Context, owner, repo, tagName, name string, size int64, content io.Reader) error
}
ReleasePublisher is an OPTIONAL capability implemented by providers that can create a release and attach files to it.
Why every method carries the noun ¶
PullRequests deliberately strips it — Find, Create, Close — and that is not a house style to copy. It exists for one reason: MergeRequests is a type ALIAS, an alias can rename a type but never a method, and CreatePullRequest would hand the GitLab-flavoured noun back at every call site.
Releases have no such split. All four forges call them releases, there is no alias, and so there is no reason to drop the noun — and dropping it collides head-on with the capability that needed it. A provider implementing both would need two methods named Create, which Go does not allow, and moving one to another type would break discovery because As asserts against the provider itself.
v0.19.0 shipped these as Create, Update and AddAsset and was therefore unimplementable by any provider that also serves pull requests, which is all four adapters.
Discover it with As, and treat a failed assertion and ErrNotSupported identically. A provider whose platform has no release object does not implement this and does not stub it.
type Repositories ¶ added in v0.4.0
type Repositories interface {
// ListRepositories enumerates the repositories in a namespace, calling yield
// once per repository. It paginates internally, so neither side holds the
// whole namespace in memory. Returning false from yield stops the
// enumeration early and is not an error; the provider must not call yield
// again afterwards.
//
// Every yielded Repository.Path is WITHIN namespace: equal to it, or
// prefixed by namespace + "/". This is a guarantee rather than an
// observation, because at least one forge's default breaks it — GitLab's
// project listing includes projects merely SHARED into a group unless
// with_shared=false is passed, and those carry foreign paths. A caller
// asking "is this in my namespace?" would otherwise get a different question
// answered, in the permissive direction.
//
// namespace is opaque, and the provider RESOLVES it rather than guessing.
// Where a forge splits organisations from users, the provider must determine
// which it has before enumerating: on GitHub, an organisation the token
// cannot see answers 404, and the user endpoint then succeeds for that same
// name returning PUBLIC repositories only — a short list indistinguishable
// from a complete one.
//
// The returned error is the single, complete answer to "did I see
// everything?". It is ErrNotSupported when the capability is configured off,
// and otherwise reports the first failure encountered. A nil error means the
// namespace was enumerated in full; a NON-NIL error means the caller holds a
// PARTIAL view and must not treat it as the namespace.
//
// That the error is an ordinary return value is deliberate. An iterator
// yielding (Repository, error) can be ranged with a single variable, which
// compiles cleanly, passes vet, discards every error and hands the caller a
// zero-value Repository as though it were data — turning a failed
// enumeration into a silently truncated one.
ListRepositories(
ctx context.Context,
namespace string,
opts RepositoryListOptions,
yield func(Repository) bool,
) error
}
Repositories is an OPTIONAL capability implemented by providers that can enumerate the repositories in a namespace.
type Repository ¶ added in v0.4.0
type Repository struct {
// Path is the canonical namespace path the API reported, and it is the
// identity a caller keys on.
//
// It is the path that ANSWERED, which may not be the path that was asked
// for: forges redirect renamed and transferred projects, so two paths can
// resolve to one container. A caller that keys on anything else — a
// directory name, a configured string, a remote URL — will eventually index
// the same repository twice under two names.
Path string
// Name is the repository's short name, without the namespace.
Name string
// Description is the forge's one-line description, empty when unset.
Description string
// URL is the human-facing web URL, suitable for citations. Like
// [GitRemote.CloneURL] it is API-reported data; pin it before attaching a
// credential.
URL string
// Visibility is the forge's view of who can see this repository. Callers
// making a security decision on it MUST allowlist — `v == VisibilityPublic`
// — never denylist, since `v != VisibilityPrivate` admits both
// [VisibilityUnknown] and [VisibilityInternal].
Visibility Visibility
// Archived reports the forge's own archived flag.
//
// It is false on a forge with no archiving concept — Bitbucket Cloud has
// none — so it is a weak negative rather than a statement that the
// repository is maintained.
Archived bool
// LastActivityAt is the most recent activity the provider reports, zero when
// it reports none.
//
// It is BEST-EFFORT and NOT comparable across forges: GitLab reports
// last_activity_at, GitHub pushed_at, Gitea updated_at and Bitbucket
// updated_on — related, but not equivalent. Use it to spot abandonment,
// never to order repositories from different forges against each other.
LastActivityAt time.Time
// Git carries the git remote. Its zero value means there is no git
// repository behind this container, or the provider could not report one —
// check Git.CloneURL before cloning rather than assuming.
Git GitRemote
// Site reports whether a documentation site is configured, from the
// enumeration payload alone. See [SiteStatus] for why an empty value is not
// "no site".
Site SiteStatus
}
Repository is a forge's CONTAINER for a codebase — not the git repository itself, which is reached through Repository.Git.
Two things, one word, and this contract means the container ¶
A forge container has a description, a visibility, an activity timestamp and possibly a published site. The git repository it wraps has a clone URL and a default branch. They are separable in fact, not merely in modelling: on GitLab and Bitbucket the container can exist with nothing behind it, which is why GitRemote is its own type and why its zero value is meaningful rather than an error.
Everywhere else in this estate "repo" means the git one — gitlab.com/phpboyscout/go/repo owns cloning, branching and committing, and exports no Repository type of its own, so there is no symbol collision and the package prefix carries the distinction. Inside THIS package, unqualified "repository" means the container.
Why not Project ¶
GitLab models exactly this split and names the container a Project, which is the better word in isolation. It is not used here because it collides on two of the four forges rather than clarifying: GitHub Projects are planning boards (`ProjectV2` in the SDK this module imports), and a Bitbucket project is a grouping of repositories inside a workspace. Neither is modelled here, and naming this type after them would mean a consumer's own forge UI disagrees with the contract.
So the name follows the three forges that use it — GitHub, Gitea and Bitbucket — and the mapping to GitLab's Project is the provider's to make.
func CollectRepositories ¶ added in v0.4.0
func CollectRepositories( ctx context.Context, r Repositories, namespace string, opts RepositoryListOptions, ) ([]Repository, error)
CollectRepositories enumerates a whole namespace into a slice, for the caller that wants the complete set rather than to stop early.
The slice is returned even when err is non-nil, so a caller can see how far it got — but a NON-NIL error means the slice is PARTIAL and must not be treated as the namespace. For a corpus defined by a predicate over that namespace, a short list that looks complete is worse than no list at all.
type RepositoryCreator ¶ added in v0.26.0
type RepositoryCreator interface {
// CreateRepository creates a repository named draft.Name in owner.
//
// # owner is REQUIRED, and is a path
//
// It is the namespace path, exactly as every other method on this contract
// takes one. Empty is an error rather than "my own namespace": Bitbucket has
// no personal namespace for such a value to name, so a meaning that cannot
// be honoured everywhere is not given one here.
//
// What each provider does with it is the provider's problem. GitLab resolves
// the path to a numeric namespace id; GitHub and Gitea choose between a user
// and an organisation endpoint by asking what the namespace IS; Bitbucket
// uses it verbatim as the workspace slug.
//
// # Returns
//
// The created repository, and one of:
//
// - nil, when everything asked for was applied.
// - an error wrapping [ErrNotHonoured] TOGETHER WITH the created
// repository, when it exists but some field could not be applied. Read
// [ErrNotHonoured] before writing the usual error check — the reflexive
// `if err != nil { return err }` treats a SUCCESSFUL creation as a
// failure, and a caller that then retries gets [ErrAlreadyExists] for a
// repository that was already correct.
// - an error wrapping [ErrAlreadyExists] when the name is taken, or a
// refusal sentinel. In both the returned Repository is the zero value and
// nothing was created.
//
// # The collision is FOUR forges and THREE status codes
//
// A provider maps its platform's answer rather than leaving a caller to
// recognise four shapes, and it cannot do that from the status alone.
// Measured by creating a repository and creating it again: GitLab answers
// 400, GitHub 422, Gitea 409, Bitbucket 400. Only one of the four is the
// status this looks like, and the two answering 400 share it with every
// malformed request — so the body has to be read.
//
// # A created repository may not be readable immediately
//
// go-github documents that its create "will return the response without
// actually waiting for GitHub to finish creating the repository". This
// contract never retries, so a provider does not loop and the returned value
// is what the create answered with. An immediate read-back on GitHub may not
// find it. Three immediate reads did not reproduce it, and three immediate
// reads on Gitea and Bitbucket did not either, so the caveat is GitHub's
// alone — but it is the vendor's own warning and a caller should not build
// a read-after-create assumption on one non-reproduction.
//
// # Initialise: false leaves no git repository
//
// The returned [Repository.Git] is then its zero value, which is what that
// field's documented meaning already covers. Check Git.CloneURL before
// cloning rather than assuming.
CreateRepository(
ctx context.Context, owner string, draft RepositoryDraft,
) (Repository, error)
}
RepositoryCreator is an OPTIONAL capability implemented by providers that can create a repository.
Discover it with As, and treat a failed assertion and ErrNotSupported identically. A provider with no forge to create anything on — the direct download source — does not implement it and does not stub it.
type RepositoryDraft ¶ added in v0.26.0
type RepositoryDraft struct {
// Name is the repository's short name, without the namespace. REQUIRED.
//
// It is also the PATH SEGMENT, and that is a guarantee rather than an
// accident of most forges agreeing. GitHub, Gitea and Bitbucket have one
// field that is both; GitLab has two, a display `name` and a URL `path`,
// and derives the second from the first by slugifying it. A provider there
// MUST send this value as BOTH, because otherwise "My Tool" creates a
// repository at my-tool and the returned Path is a value the caller never
// wrote and cannot predict without knowing GitLab's slug rules.
//
// So the returned [Repository.Path] is always owner + "/" + Name. A caller
// that wants a display name unlike its path is asking for post-creation
// configuration, which this capability does not do.
//
// A provider rejects an empty Name before making any request, because a
// forge's own answer to one is a validation error indistinguishable from a
// dozen others.
Name string
// Description is the forge's one-line description. Empty sets none.
Description string
// Visibility is who should be able to see the repository.
//
// [VisibilityInternal] exists on GitLab and GitHub Enterprise only. A
// provider that cannot honour it creates the repository PRIVATE — never
// public — and reports with [ErrNotHonoured].
//
// Falling back to private rather than public is the whole of the reasoning:
// the failure has to be safe, because there is no delete here to undo it.
//
// The zero value is [VisibilityUnknown], which a provider treats as private
// for the same reason.
Visibility Visibility
// Initialise asks for a repository with a first commit, so it can be cloned
// immediately.
//
// GitLab, GitHub and Gitea all offer this. BITBUCKET CANNOT: no such field
// exists in its API, and a repository it creates has zero branches and zero
// commits. A provider there creates the repository and reports the drop with
// [ErrNotHonoured].
Initialise bool
// DefaultBranch is the branch a clone should land on. Empty takes the
// platform's default.
//
// Honoured on GitLab and Gitea. GITHUB AND BITBUCKET BOTH ACCEPT IT AND
// DISCARD IT — GitHub returns 201 with main, Bitbucket 200 with master,
// neither with an error — so a provider there reports it with
// [ErrNotHonoured]. That is the same trap [ReleaseDraft.Commit] exists to
// close: a field a forge takes, ignores, and says nothing about.
//
// On Bitbucket it is unreachable rather than merely unimplemented. It
// validates the name against branches that ACTUALLY EXIST — setting it after
// creation answers 400 "not a valid branch" — and since it cannot
// initialise, there is no branch for it to name. No ordering of calls this
// contract can make will set it there.
DefaultBranch string
}
RepositoryDraft is what a caller asks for when creating a repository.
Name, Description and Visibility are accepted by every forge. Initialise and DefaultBranch are not, and a provider that cannot apply one MUST say so with ErrNotHonoured rather than refusing or staying silent — see RepositoryCreator.CreateRepository.
func (RepositoryDraft) Validate ¶ added in v0.26.0
func (d RepositoryDraft) Validate() error
Validate reports whether the draft can be sent.
A provider calls this BEFORE its first request, so an empty name costs no round trip and produces one error rather than four forge-shaped ones.
type RepositoryListOptions ¶ added in v0.4.0
type RepositoryListOptions struct {
// IncludeSubgroups descends into nested namespaces. GitLab has them and
// defaults to excluding them; GitHub, Gitea and Bitbucket have no equivalent
// and ignore this.
IncludeSubgroups bool
// IncludeArchived includes archived repositories, which are excluded by
// default.
//
// The default is a policy choice, so make it deliberately: a caller whose
// own rules have no archived clause will silently lose qualifying
// repositories by inheriting it.
IncludeArchived bool
// PageSize hints at the provider's internal page size. Zero means the
// provider's own default. It tunes how many requests the enumeration makes,
// never which repositories it returns.
PageSize int
}
RepositoryListOptions narrows what Repositories.ListRepositories enumerates.
type SignatureProvider ¶
type SignatureProvider interface {
// DownloadSignature returns the raw bytes of the detached
// signature over the checksums manifest for the given release.
//
// As with [ChecksumProvider.DownloadChecksumManifest], the
// implementation MUST enforce maxBytes. The two are deliberately
// symmetric: an implementer should never have to remember which of the
// two capped for them. [DefaultMaxSignatureSize] is a reasonable bound,
// chosen by the caller.
//
// Returns [ErrNotSupported] when the provider is configured in a
// way that disables signature retrieval (e.g. the Direct
// provider's `signature_url_template` param is empty, or no
// signature file was uploaded to Bitbucket). The caller treats
// [ErrNotSupported] exactly like "provider does not implement this
// interface" — fall back to asset-by-name lookup if one is
// available, otherwise respect the require_signature policy.
DownloadSignature(ctx context.Context, rel Release, maxBytes int64) ([]byte, error)
}
SignatureProvider is an OPTIONAL interface implemented by release providers that can fetch a detached signature over the checksums manifest by means other than a standard release-asset download. It mirrors ChecksumProvider exactly: the Direct provider composes a URL from its `signature_url_template` param, and Bitbucket locates an uploaded `checksums.txt.sig` by exact filename in the downloads list.
The update flow type-asserts at runtime — providers that do not implement this interface fall back to locating the signature asset by filename within the release's asset list (the GitHub/GitLab path). This keeps third-party Provider implementations source- compatible: they gain signature support by opting in, not by implementing a new required method.
type Site ¶ added in v0.4.0
type Site struct {
// URL is where the site is published, empty when the provider cannot say.
//
// It is the address to CITE: where the forge reports a configured domain,
// this is that domain rather than the generated one. See [Site.URLSource]
// for which of those it turned out to be.
URL string
// URLSource reports where URL came from, which is what tells a caller how
// much a failed probe of it proves.
//
// The zero value is [SiteURLUnknown], so a Site nobody filled in claims
// nothing about provenance.
URLSource SiteURLSource
// TLS reports whether the forge enforces HTTPS for this site.
//
// It is REPORTED, not applied: where the forge does not enforce, URL is
// returned with the scheme the forge recorded rather than upgraded, because
// upgrading would guess TLS availability on precisely the sites where the
// forge declines to promise it. A caller that refuses to cite http:// has
// what it needs to decide for itself.
TLS SiteTLS
// State describes how the most recent build ended, when the forge reports it.
State SiteState
// Visibility is who can see the site, which some forges control separately
// from the container itself.
Visibility Visibility
}
Site describes a repository's published documentation site.
Every field is INDEPENDENTLY optional, because forges differ on which they expose: GitLab and GitHub gate the URL behind a privileged endpoint but can report build state, while Codeberg's URL is a deterministic template and its build state is not observable at all. Each zero value means only that THAT field is unknown — including Site.URLSource and Site.TLS, which is why both are string enumerations with an explicit unknown rather than bools. A bool cannot express "the provider did not say", so it would make the zero Site assert a fact about a site it knows nothing about.
type SiteState ¶ added in v0.4.0
type SiteState string
SiteState describes how a published site's most recent build ended.
It is BEST-EFFORT and deliberately wider than any single forge can populate: GitHub reports the full set, GitLab can infer only that a deployment exists, and Codeberg cannot report state at all. A provider maps what its forge actually reports and uses SiteStateUnknown for the rest — it never infers a state from the absence of one.
const ( // SiteStateUnknown is the zero value: the provider cannot report build state. SiteStateUnknown SiteState = "" // SiteStateBuilding means a build is in progress. SiteStateBuilding SiteState = "building" // SiteStateBuilt means the most recent build succeeded. SiteStateBuilt SiteState = "built" // SiteStateFailed means the most recent build failed. SiteStateFailed SiteState = "failed" )
type SiteStatus ¶ added in v0.4.0
type SiteStatus string
SiteStatus is the cheap, ungated signal for whether a repository publishes a documentation site — read from the same payload the enumeration already fetched, so it costs no extra request and needs no elevated token.
It answers "is a site configured?", never "is a site reachable?". See Sites for the richer answer, and note that neither tier can promise reachability: that is an HTTP request only the caller can make.
const ( // SiteUnknown is the zero value: the provider cannot say from the // enumeration alone. // // It does NOT mean "no site". Codeberg reports it and has sites; GitLab // reports it for any project whose Pages access level is merely non-disabled, // because that field is an access-control setting rather than evidence a site // was ever deployed. Ask [Sites], or treat it as unknown — never as absent. SiteUnknown SiteStatus = "" // SiteNone means the forge reports site publishing as switched off. This is // authoritative and is the signal worth acting on: it eliminates a candidate // without a second request. SiteNone SiteStatus = "none" // SitePublished means the forge reports a site as configured. Only providers // whose listing payload carries a genuine existence bit report this. SitePublished SiteStatus = "published" )
type SiteTLS ¶ added in v0.10.0
type SiteTLS string
SiteTLS reports whether a forge enforces HTTPS for a published site.
It is tri-state because two of the first-party providers genuinely cannot say: a forge that exposes no site API exposes no TLS setting either, and reporting "not enforced" for it would be a claim the provider cannot support.
This is REPORTED rather than applied. Where a forge does not enforce HTTPS, Site.URL keeps the scheme the forge recorded — upgrading it would guess TLS availability on exactly the sites where the forge declines to promise it, and a wrong guess yields an address that does not resolve. A caller that refuses to cite http:// can act on this field instead.
const ( // SiteTLSUnknown is the zero value: the provider cannot report enforcement. SiteTLSUnknown SiteTLS = "" // SiteTLSEnforced means the forge serves the site over HTTPS and redirects // plain HTTP to it, so an https:// address is safe to cite even where the // forge recorded the domain as http://. SiteTLSEnforced SiteTLS = "enforced" // SiteTLSNotEnforced means the forge does NOT guarantee HTTPS. The site may // still serve it — GitHub reports this state for sites holding a valid // certificate — but the forge will not promise it, so neither does [Site.URL]. SiteTLSNotEnforced SiteTLS = "not-enforced" )
type SiteURLSource ¶ added in v0.10.0
type SiteURLSource string
SiteURLSource describes where a Site.URL came from.
It exists because "the forge told us this address" and "this is the address the project publishes at" are DIFFERENT facts, and a caller needs both. A forge can report a generated address while the project serves from a custom domain the same response also carries; a forge with no site API at all forces the provider to compose an address from a template that may not serve.
The distinction is load-bearing for a caller that probes: a failed probe of a COMPOSED address means "unknown", while a failed probe of one the forge reported is much closer to a real negative. Collapsing them silently drops a repository that publishes at an address the forge never disclosed.
const ( // SiteURLUnknown is the zero value: the provider did not say where the URL // came from. It is NOT a synonym for any of the others — a Site nobody // filled in asserts nothing about provenance. SiteURLUnknown SiteURLSource = "" // SiteURLGenerated is an address the forge reported but generated itself, // such as a *.gitlab.io or *.github.io host. // // It is real and it resolves, but it is not necessarily where the project // publishes: a custom domain may be configured that this response does not // carry, and the forge typically redirects the generated host to it. SiteURLGenerated SiteURLSource = "generated" // SiteURLCanonical is the address the forge reports as the project's own // configured domain. This is what a citation should use. SiteURLCanonical SiteURLSource = "canonical" // SiteURLDerived is an address COMPOSED by the provider from a template // rather than reported by the forge at all. // // A custom domain is authorised in DNS and is invisible to the API, so a // derived address may simply not serve. A caller whose probe of one fails // has learned "unknown", not "no site". SiteURLDerived SiteURLSource = "derived" )
type Sites ¶ added in v0.4.0
type Sites interface {
// GetSite returns the published-site details for a repository.
//
// Returns ErrNotSupported when the provider's forge has no site feature or
// it is disabled for this configuration, and ErrNotFound when the repository
// has no site. A permission failure is NEITHER — it is an error, so a caller
// is never told "no site" because its token was too weak. On both forges
// with a Pages API that distinction is routine rather than theoretical:
// reading Pages settings requires elevated access.
//
// A non-error result means the forge reports a site as CONFIGURED. It never
// means the site is reachable, and on a forge that infers configuration from
// a convention rather than an API it may not even mean the site was ever
// deployed — check State and URLSource before relying on it.
GetSite(ctx context.Context, owner, repo string) (Site, error)
}
Sites is an OPTIONAL capability implemented by providers whose forge can publish a static site from a repository — GitLab Pages, GitHub Pages, Codeberg Pages.
It is a capability, not a binding to any one API: a provider answers by whatever means its forge offers, which is a REST endpoint on some and a naming convention on others. Providers for forges without the feature do not implement it and remain conformant.
type Snippet ¶ added in v0.11.0
type Snippet struct {
// ID identifies the snippet for later operations and is OPAQUE: GitLab uses
// an integer, GitHub a hex string.
//
// A caller must not parse it, compare it across providers, or build a URL
// from it — [Snippet.URL] is the address to cite.
ID string
// URL is where the snippet is published. This is the durable address a
// consumer cites.
URL string
Title string
Description string
// Visibility is the EFFECTIVE reachability, which is not always what the
// forge's own field says.
//
// On GitLab a project snippet is subject to the project's snippets access
// level as well as its own visibility, and the two are NOT reconciled by the
// forge: a snippet capped to project members by that setting still reports
// itself "public". A provider therefore computes this from both rather than
// reading either, because reporting an artefact as world-readable when it is
// not is the contract asserting something it has not checked.
//
// Neither setting can RAISE exposure, so the computed value is the more
// restrictive of the two.
Visibility SnippetVisibility
// Files are the snippet's files. A provider MAY return them without content
// from a listing, where fetching every body would cost a request per
// snippet — see [Snippets.ListSnippets].
Files []SnippetFile
}
Snippet is a forge's snippet or gist.
type SnippetCreateOptions ¶ added in v0.11.0
type SnippetCreateOptions struct {
// Title is required by both forges.
Title string
// Description is optional.
Description string
// Visibility is REQUIRED and has no default.
//
// [SnippetVisibilityUnknown] is rejected rather than resolved to whatever
// the provider considers safest. The two forges disagree on what that would
// even be, so a caller that omitted it would be publishing under a rule it
// never read — and this is a write path, where that is not recoverable.
Visibility SnippetVisibility
// Files is the snippet's content, and must contain at least one file.
Files []SnippetFile
}
SnippetCreateOptions describes a snippet to create.
type SnippetFile ¶ added in v0.11.0
type SnippetFile struct {
// Path is the file's name as the forge will display it.
Path string
// Content is the file's bytes.
//
// Both forges' APIs are text-oriented; a provider given content they cannot
// represent returns an error rather than writing something lossy.
Content []byte
}
SnippetFile is one file within a snippet.
type SnippetScope ¶ added in v0.11.0
type SnippetScope struct {
// Owner is the namespace — a user or group on GitLab, a user or
// organisation on GitHub. Required for both scopes.
Owner string
// Repo selects the project scope. Empty means the account scope.
Repo string
}
SnippetScope selects where a snippet lives.
An empty Repo means the ACCOUNT scope — GitLab's personal snippets, GitHub's gists — which is the only scope both forges share. A non-empty Repo means the snippet belongs to that repository, which GitLab supports and GitHub does not: a gist has no project to belong to, so GitHub returns ErrNotSupported.
The scopes are not interchangeable and the difference is DURABILITY, not preference. An account-scoped snippet is owned by whoever created it and goes when that account does; a project-scoped one outlives its author. A caller parking something that will be cited later wants the latter.
func (SnippetScope) Account ¶ added in v0.11.0
func (s SnippetScope) Account() bool
Account reports whether this scope is the account scope rather than a project's.
type SnippetVisibility ¶ added in v0.11.0
type SnippetVisibility string
SnippetVisibility is who can reach a snippet.
It is deliberately NOT Visibility. That type has no value for "readable by anyone holding the URL, but not listed", and adding one would put a value into it whose safe handling differs from every other — quietly breaking the allowlist rule Repository.Visibility documents for callers who never read this comment.
The vocabulary exists to make ONE mistake unreachable: believing an artefact is access-controlled when it is merely hard to guess.
const ( // SnippetVisibilityUnknown is the zero value: the provider did not say. It // is not a synonym for any other value, and a caller must never treat it as // a permission to publish. SnippetVisibilityUnknown SnippetVisibility = "" // SnippetVisibilityPublic is listed and world-readable. SnippetVisibilityPublic SnippetVisibility = "public" // SnippetVisibilityUnlisted is world-readable to anyone holding the URL, // but not listed and not discoverable. // // This is what a GitHub "secret" gist actually is. It is NOT access // control, and the name says so, because "secret" reads as protection to // anyone who has not gone looking for the documentation. A caller that // needs the content restricted wants [SnippetVisibilityPrivate]. SnippetVisibilityUnlisted SnippetVisibility = "unlisted" // SnippetVisibilityInternal is readable by authenticated users of the // instance. // // GitLab only, and an INSTANCE MAY REFUSE IT — gitlab.com does, answering // "Visibility level internal has been restricted by your GitLab // administrator". A provider surfaces that refusal as an error rather than // substituting a different visibility, because a silent substitution is the // disclosure this type exists to prevent. SnippetVisibilityInternal SnippetVisibility = "internal" // SnippetVisibilityPrivate is restricted to those granted access. // // A provider that cannot deliver this MUST return [ErrNotSupported] rather // than anything weaker. GitHub cannot deliver it: a gist is listed or // unlisted, and both are readable by anyone with the URL. SnippetVisibilityPrivate SnippetVisibility = "private" )
type Snippets ¶ added in v0.11.0
type Snippets interface {
// CreateSnippet creates a snippet and returns it, including the URL to cite
// and the effective visibility achieved.
//
// Returns ErrNotSupported when the scope or the requested visibility is one
// this forge cannot express — GitHub refuses both a project scope and
// SnippetVisibilityPrivate. A provider MUST refuse rather than substitute
// something weaker: a caller that asked for private and silently received a
// world-readable URL does not find out until the artefact is published.
//
// The returned Visibility is the EFFECTIVE one and may be more restrictive
// than requested — a project's own settings can cap it. It is never less
// restrictive.
CreateSnippet(ctx context.Context, scope SnippetScope, opts SnippetCreateOptions) (Snippet, error)
// GetSnippet returns one snippet by its opaque ID, including file contents.
//
// Returns ErrNotFound when no such snippet exists. A permission refusal is
// NOT ErrNotFound: a caller must never be told a snippet is absent because
// its token was too weak.
GetSnippet(ctx context.Context, scope SnippetScope, id string) (Snippet, error)
// ListSnippets returns the snippets in scope.
//
// It paginates internally and returns the complete set. A NON-NIL error
// means the slice is PARTIAL and must not be treated as the full set — the
// same rule the enumeration capabilities carry, since a short list read as
// complete is how something gets silently missed.
//
// File CONTENT may be absent from the returned snippets: fetching every body
// costs a request per snippet, and a caller that wants one calls
// GetSnippet. File paths are always populated.
ListSnippets(ctx context.Context, scope SnippetScope) ([]Snippet, error)
// DeleteSnippet removes a snippet by its opaque ID.
//
// Returns ErrNotFound when no such snippet exists, so a caller cleaning up
// can treat "already gone" as success rather than a failure to investigate.
DeleteSnippet(ctx context.Context, scope SnippetScope, id string) error
}
Snippets is an OPTIONAL capability implemented by providers whose forge can store a standalone file outside a repository — GitLab snippets, GitHub gists.
Providers for forges without the feature do not implement it and remain conformant. Bitbucket Cloud and Gitea are both in that position permanently: Bitbucket withdrew the API, and Gitea never had one.
type TrustOption ¶
type TrustOption func(*trustPolicy)
TrustOption relaxes HostTrusted. Every option widens what is trusted with a credential attached, so each one is a deliberate, greppable decision rather than a default.
func WithAdditionalHosts ¶
func WithAdditionalHosts(hosts ...string) TrustOption
WithAdditionalHosts trusts hosts besides the API host with the credential.
The case this exists for is real: some deployments serve release assets from a separate storage domain that still requires the API credential — a self-hosted instance fronting an object store, or an enterprise install with a dedicated assets host. Without this they would have to choose between a failing download and no pinning at all.
Each host is matched exactly, case-insensitively, including port. It is never a suffix match: a suffix test is how host pinning is usually defeated, since "git.example.com" would then trust "git.example.com.attacker.net".
List only hosts you control. Every entry is somewhere your credential may be sent, and the point of pinning is that this list is short and deliberate.
func WithInsecureSchemeDowngrade ¶
func WithInsecureSchemeDowngrade() TrustOption
WithInsecureSchemeDowngrade permits attaching the credential when the target uses a different scheme from the base — in practice, plaintext HTTP against an HTTPS API.
It is named to be uncomfortable because it should be. A downgrade puts the credential on the wire in cleartext, and it is exactly what an attacker able to rewrite release metadata would choose. There is one defensible use: an air-gapped or lab deployment that genuinely has no TLS, where the operator accepts that the credential is not confidential in transit.
If you are reaching for this against an internet-facing forge, the answer is TLS, not this option.
type Visibility ¶ added in v0.4.0
type Visibility string
Visibility is a forge's view of who can see a repository.
It is a named type rather than a bare string because callers make security decisions on it, and a typo in a string comparison there is a disclosure rather than a bug.
const ( // VisibilityUnknown is the zero value: the provider could not determine // visibility. It is NOT a synonym for private, and it is never a synonym for // public. // // It is reachable more easily than it looks. GitHub's listing payload // reports privacy through a *bool, so an absent field decodes to nil; Gitea // uses a plain bool, where absent and false are indistinguishable unless the // provider decodes defensively. A provider MUST map absent or unrecognised // values here rather than to the nearest-looking constant. VisibilityUnknown Visibility = "" // VisibilityPublic means anyone can see the repository. VisibilityPublic Visibility = "public" // VisibilityInternal means visible to authenticated users of the instance // but not to the world. GitLab has this; most forges do not. It is kept // distinct because flattening it into public is a disclosure, and flattening // it into private loses a real distinction. VisibilityInternal Visibility = "internal" // VisibilityPrivate means restricted to members. VisibilityPrivate Visibility = "private" )
type WikiPage ¶ added in v0.24.0
type WikiPage struct {
// Path identifies the page, and is the ONLY identity this contract has —
// "specs/0079-pipeline-churn", with no extension and no leading slash.
//
// It is what a caller writes and what it reads back. How each platform
// stores and displays it varies and is the provider's problem:
//
// GitLab a native nested slug
// Gitea one escaped filename; written by title, READ BY sub_url
// GitHub a file at <path>.md, whose rendered page name drops the
// directory — the file keeps it, the wiki UI does not
//
// The Gitea case is the one that bites. Its write and read paths are not
// inverses: a title containing a hyphen gains a ".-" marker on write that a
// read by the same string does not reproduce, so reading back what you just
// wrote returns 404 for every "NNNN-slug" name. The adapter keeps the
// sub_url the API handed it and keys on that. Measured on Gitea 1.24.7.
Path string
// Content is the page body, in the forge's markdown flavour.
//
// It is EMPTY in the pages returned by [Wikis.ListWikiPages]: fetching every
// body costs a request per page, and a caller that wants one calls
// [Wikis.GetWikiPage]. The same rule [Snippets.ListSnippets] follows.
Content string
}
WikiPage is one page of a project's wiki.
func (WikiPage) Validate ¶ added in v0.24.0
Validate reports whether this page is one the contract can carry, and is called by a provider BEFORE any request is sent.
It lives here rather than in each adapter so a malformed page is refused identically everywhere, rather than reaching four different platform errors — or, worse, four different silent normalisations of the same bad path.
type Wikis ¶ added in v0.24.0
type Wikis interface {
// ListWikiPages returns every page in the project's wiki.
//
// It paginates internally and returns the complete set. A NON-NIL error
// means the slice is PARTIAL and must not be read as the full set, which is
// the rule every enumeration in this contract carries.
//
// WikiPage.Content is empty in the result. See [WikiPage.Content].
ListWikiPages(ctx context.Context, owner, repo string) ([]WikiPage, error)
// GetWikiPage returns one page and its content.
//
// maxBytes is REQUIRED and the provider MUST enforce it, for the same
// reason [Contents.GetFile] does: every remote read in this module is
// bounded by a caller-supplied ceiling, enforced at the trust boundary by
// the only code that sees the response before it is buffered.
// [DefaultMaxFileSize] is a reasonable value, and the CALLER chooses.
//
// Returns an error wrapping [ErrNotFound] when no page exists at path — an
// ordinary answer to "is this page there yet?", not a failure.
GetWikiPage(ctx context.Context, owner, repo, path string, maxBytes int64) (WikiPage, error)
// CreateWikiPage adds a page.
//
// message is the commit the write should carry. Not every forge models one:
// GitLab's wiki options are content, title and format and nothing else, so
// a provider there writes the page and reports the dropped message with
// [ErrNotHonoured]. Read that sentinel before writing the usual error check
// — the page EXISTS when it comes back.
//
// A provider reports it only when a message was actually supplied. Firing it
// on every write for a field the caller left empty teaches callers to ignore
// the sentinel.
//
// Returns an error wrapping [ErrAlreadyExists] when the path is taken, so a
// caller can fall through to [Wikis.UpdateWikiPage] rather than guessing.
CreateWikiPage(ctx context.Context, owner, repo string, page WikiPage, message string) error
// UpdateWikiPage replaces a page's content.
//
// Returns an error wrapping [ErrNotFound] when no page exists at
// page.Path, one wrapping [ErrConflict] when the wiki changed between the
// provider's read and its write, and one wrapping [ErrNotHonoured] when the
// page was written and message could not be — see [Wikis.CreateWikiPage].
//
// # The conflict is real on the git-backed forges
//
// GitHub and Bitbucket are served by clone, edit, push — a read-modify-write
// across a network. A second writer in that window makes the push a
// non-fast-forward, and it MUST surface as [ErrConflict] rather than be
// retried or, worse, forced. Last-write-wins on a wiki carrying a project's
// decision records is the failure this capability exists to prevent,
// arriving by another route.
//
// A provider narrows the window by re-reading immediately before it writes.
// It cannot close it, and does not pretend to.
UpdateWikiPage(ctx context.Context, owner, repo string, page WikiPage, message string) error
}
Wikis is an OPTIONAL capability implemented by providers that can read and write a project's wiki pages.
The wiki must already exist ¶
Every method returns an error wrapping ErrNotSupported when the project has no wiki, and NOT ErrNotFound. Two reasons, and both matter.
On GitHub and Bitbucket a wiki cannot be created by any call this contract offers — measured: pushing to a wiki remote that does not exist is refused on a public repository with the wiki enabled, using the owner's credential, and a human must create the first page in a browser. ErrNotSupported is already this contract's answer to "this provider cannot do that here", and a caller reading it falls back rather than retrying something that can never succeed.
And ErrNotFound on these methods means the PAGE is absent, which a caller fixes by calling Wikis.CreateWikiPage. Collapsing the two would tell a caller to retry in the case where it should give up.
GitLab and Gitea create the wiki on the first page write, so the refusal never fires there.
A permission refusal is never reported as absence ¶
GitHub answers "Repository not found" for a wiki that does not exist AND for a credential that cannot see one that does — deliberately, so a private repository does not leak its existence. A provider MUST disambiguate before answering, and where it cannot, ErrUnauthorized and ErrForbidden are the honest values. A caller must never be told a wiki is absent because its token was too weak.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package direct provides a forge.Provider implementation for tools distributed via arbitrary HTTP servers.
|
Package direct provides a forge.Provider implementation for tools distributed via arbitrary HTTP servers. |
|
Package pool reuses one provider per endpoint, so several components asking for the same forge get one connection between them rather than one each.
|
Package pool reuses one provider per endpoint, so several components asking for the same forge get one connection between them rather than one each. |
|
Package releasetest provides an in-memory forge.Provider test double and asset builders for exercising the self-update pipeline without network or disk access.
|
Package releasetest provides an in-memory forge.Provider test double and asset builders for exercising the self-update pipeline without network or disk access. |