forgeapi

package module
v1.2.0 Latest Latest
Warning

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

Go to latest
Published: Oct 11, 2026 License: Apache-2.0 Imports: 7 Imported by: 0

README

forgeapi

Go Reference Go version Mutation

forgeapi lets your Go code read and write pull requests, issues, merges, checks, releases and labels on GitHub, GitLab, Gitea and Forgejo through one set of types.

It replaces four API clients and the differences in routes, field names and value shapes you would otherwise handle. It has no operations for comments, reviews or file contents. It needs Go 1.27.2 or later, is at v1 and is licensed under Apache-2.0. It depends only on atomicfile, httpx, jsoncap, runesafe, ssrf and urlform, six modules by the same author.

Why use it

forgeapi is built for Go tools that span several forges.

  • Each operation is one method with the same result types on all four products. A field a product does not supply keeps its zero value, the unknown member for an enumeration.
  • Each operation's godoc lists its route, request cost and differing fields per product, and SUPPORT.md maps operations to products.
  • It serves github.com, gitlab.com, and Gitea and Forgejo instances. GitHub Enterprise Server and self-managed GitLab run the same code but were not measured.
  • A client paces itself to the rate limit and reports a repository that moved.
  • creds runs the OAuth device flow on GitHub and GitLab and refreshes tokens. gitcred hands them to git.

Consider go-github if you target GitHub alone and want services that mirror its API documentation. Consider go-scm if you need Bitbucket, Gitee or Gogs, or comment and file endpoints. Consider git-pkgs/forge if you need reviews, branches, Bitbucket Cloud or Gerrit, or a command-line tool.

Install

go get github.com/cplieger/forgeapi@latest

Usage

client, err := github.New(
    forgeapi.Connection{WebBaseURL: "https://github.com"},
    forgeapi.WithCredentialSource(cred),
)
if err != nil {
    return err
}
defer client.Close()

repos, err := client.ListRepos(ctx, forgeapi.WithPageBound(1))
if err != nil {
    return err
}

prs, err := client.ListPRs(ctx, repos.Items[0].Ref, forgeapi.WithState(forgeapi.ListStateOpen))

example_test.go holds the compiling form, with a credential source and error handling.

The github, gitlab and gitea packages each hold one client, and gitea serves Forgejo too. A client asks its CredentialSource for a token on every request, so a rotating token and a static one use the same code. Take each RepoRef from a call such as ListRepos rather than building one, because it carries its family's own spelling of the repository path. Call Close when you are done, to release the client's connection pool.

  • families.Open detects which forge a URL points at and returns a forgeapi.Core. Connecting to a forge covers what detection costs.
  • An instance on a private network or on plain http is refused until you pass forgeapi.WithPrivateAddresses(true) or forgeapi.WithPlaintextHTTP(true). Plain HTTP sends the token and every request in cleartext.
  • forgeapi.WithMutations(false) makes a client read-only. In tests, forgeapi.WithWireTransport takes an http.RoundTripper that answers requests without a network.
  • The creds package Example signs a user in and builds a client on the stored credential. Let only one process refresh tokens from a store, because two can lose a rotated token and force the user to sign in again. Credentials covers the store and the git credential helper.

API

  • Core is what every client satisfies. It joins eight interfaces, Identity, Repos, PullRequests, Merges, Checks, Issues, Capabilities and Governor, plus Close. Declare the narrowest one your code uses.
  • Releases and Labels sit outside Core, and all three clients implement both. Reach them from a Core with a type assertion such as rel, ok := client.(forgeapi.Releases).
  • The writes are CreatePR, ClosePR, ReopenPR, MergePR, RerunFailedChecks, CreateIssue, CloseIssue and CreateRelease. Every other operation reads.
  • Checks.CommitStatus returns a commit's combined check verdict. Checks.ListRuns lists GitHub Actions runs, GitLab pipelines, and Gitea and Forgejo Actions runs.
  • Connection describes an instance, RepoRef and PRRef address a repository and a pull request, and Option and ListOption configure a client and a list call.
  • Every list answers a Page with a Next cursor, and Pages and continuations covers short pages and resuming.
  • creds holds the device flow, the refreshing source and the file store. gitcred holds the git credential helper.

Per-symbol detail is on pkg.go.dev.

Every call answers nil, an Error or a context error

A caller can branch on these three outcomes completely. Every operation of a client, and families.Open, returns a forge or library failure as the *forgeapi.Error pointer, so recover it with errors.As and branch on its code:

var ferr *forgeapi.Error
if errors.As(err, &ferr) && ferr.Code == forgeapi.CodeRepoRefStale { ... }

Error carries an ErrorKind and a Code, because one HTTP status means different things per product and per operation. Error.Message is the forge's own text, so never branch on it. Error wraps no other error, and the library declares no sentinel errors of its own.

A cancelled or expired context returns Go's own context.Canceled or context.DeadlineExceeded unchanged, so errors.Is works on it. Each operation also runs under its own deadline, Budget.OperationTimeout, 20 seconds by default, which expires as context.DeadlineExceeded too. To tell whether your own context ended, check ctx.Err(). The creds file store and the gitcred helper return ordinary Go errors when a read or write fails.

Unsupported by design

forgeapi leaves these out on purpose. Non-goals gives the reason for each.

  • A generic request method, or routes you add to a client
  • A vendor SDK behind any of the clients
  • Receiving webhooks
  • Git operations such as clone, fetch and push
  • A "partly supported" mark in SUPPORT.md. A difference between products is stated on the field it affects
  • Reading a proxy from the environment
  • A polling loop in the API
  • Code scanning
  • The OAuth authorization-code grant. To use it, implement CredentialSource yourself.

Documentation

Contributing

See CONTRIBUTING.md.

Disclaimer

This project is built with care and follows security best practices, but it is intended for personal / self-hosted use. No guarantees of fitness for production environments. Use at your own risk.

This project was built with AI-assisted tooling using Claude, GPT, and Kiro. The human maintainer defines architecture, supervises implementation, and makes all final decisions.

License

Apache-2.0. See LICENSE.

Documentation

Overview

Package forgeapi holds the types, options and role interfaces shared by the github, gitlab and gitea clients for GitHub, GitLab, Gitea and Forgejo. The guides are at https://github.com/cplieger/forgeapi#documentation.

Every operation in this package and in every family package answers nil, a *Error, or an unchanged context.Canceled or context.DeadlineExceeded. Recover the pointer form with errors.As and branch on Error.Code and Error.Kind, never on Error.Message. Error wraps nothing. Every enumeration's zero value is its unknown member, except MergeIntent's.

A role method's doc ends with a "Per forge:" block generated from one table: per product, the route, its request price and the capability that can refuse it, then each field the product departs on or that no measurement has read.

Example

Example lists the open pull requests of the first repository a credential reaches. It is the whole shape of a call against this library: construct one family's client and close it when done, take a repository identifier from what the forge answered rather than minting one, then call the operation and branch on the error's code.

The example carries no Output comment, so the toolchain compiles it and does not run it. What it is here to prove is that the published surface holds together at the call site, which no prose can state and a build can.

package main

import (
	"context"
	"errors"
	"fmt"

	"github.com/cplieger/forgeapi"
	"github.com/cplieger/forgeapi/github"
)

// staticPAT is the smallest credential source there is: one personal access token,
// held for the life of the process. The client asks a source for its token on every
// request it sends, so a rotating source, such as the one the creds package
// builds, answers a fresh token from the same method. Kind and State report what
// the source knows about itself, for the consumer to read.
type staticPAT string

func (p staticPAT) Token(context.Context) (string, error) { return string(p), nil }
func (staticPAT) Kind() forgeapi.CredKind                 { return forgeapi.CredKindStaticPAT }
func (staticPAT) State() forgeapi.CredState               { return forgeapi.CredValid }

// Example lists the open pull requests of the first repository a credential reaches.
// It is the whole shape of a call against this library: construct one family's
// client and close it when done, take a repository identifier from what the forge
// answered rather than minting one, then call the operation and branch on the
// error's code.
//
// The example carries no Output comment, so the toolchain compiles it and does not
// run it. What it is here to prove is that the published surface holds together at
// the call site, which no prose can state and a build can.
func main() {
	ctx := context.Background()

	client, err := github.New(
		forgeapi.Connection{WebBaseURL: "https://github.com"},
		forgeapi.WithCredentialSource(staticPAT("a-personal-access-token")),
	)
	if err != nil {
		fmt.Println(err)
		return
	}
	defer client.Close()

	repos, err := client.ListRepos(ctx, forgeapi.WithPageBound(1))
	if err != nil {
		fmt.Println(err)
		return
	}
	if len(repos.Items) == 0 {
		fmt.Println("this credential reaches no repository")
		return
	}

	prs, err := client.ListPRs(ctx, repos.Items[0].Ref, forgeapi.WithState(forgeapi.ListStateOpen))
	var ferr *forgeapi.Error
	switch {
	case errors.As(err, &ferr):
		// Branch on the code, never on the message, which is upstream's own text.
		fmt.Printf("%s answered %s\n", ferr.Op, ferr.Code)
		return
	case err != nil:
		fmt.Println(err)
		return
	}

	for _, pr := range prs.Items {
		fmt.Printf("#%d %s\n", pr.Ref.Number, pr.Title)
	}
}

Index

Examples

Constants

View Source
const (
	// CodeRepoRefInvalid is a decoded repository selector that failed
	// validation. It is answered before any request.
	CodeRepoRefInvalid = "repo_ref_invalid"
	// CodeRepoRefStale is a repository that moved. It is detected from the
	// redirect rather than from a 404, because a followed redirect would
	// otherwise succeed and a stale identifier would keep working silently
	// until the day the old path stopped redirecting. [Error.Successor]
	// carries the repository it moved to, and is nil where the move was
	// detected but the successor could not be resolved, which is the case a
	// consumer answers with reconnect-and-relist rather than a one-click
	// re-point.
	CodeRepoRefStale = "repo_ref_stale"
	// CodeRepoOrPRNotVisible is the union answer: a stale identifier and a
	// repository the credential cannot see are indistinguishable from the
	// status alone, and on one family a single REST 404 cannot separate a
	// missing pull request from an invisible repository either.
	CodeRepoOrPRNotVisible = "repo_or_pr_not_visible"
	// CodePRNotFound is a pull request that does not exist in a repository
	// that does. It is issued only where the evidence separates the two, which
	// on two families is the document's own envelope.
	CodePRNotFound = "pr_not_found"
	// CodeOwnerUnresolved is a list scoped by [WithOwner] to an owner the
	// instance does not resolve to a namespace it lists by owner for this
	// credential: absent, invisible, or a namespace of a kind that product lists
	// by no owner route. It is a not-found answer carrying the status the
	// instance sent. A product whose answer cannot separate no such owner from
	// nothing open answers an empty page instead, and that product's own
	// per-forge block on the operation says so.
	CodeOwnerUnresolved = "owner_unresolved"
	// CodeNotMergeable is a refusal whose cause upstream did not name.
	CodeNotMergeable = "not_mergeable"
	// CodeMissingSHA is a merge whose head SHA is empty: refused by this
	// library before the request on every family, and refused upstream by an
	// instance of the one product an operator can configure to require it. The
	// local refusal carries this code rather than one of its own because it is
	// the same cause, and it is every family's rather than that product's
	// because [MergeRequest.HeadSHA] is the precondition that makes a merge
	// safe wherever it runs.
	CodeMissingSHA = "missing_sha"
	// CodeNoMergePermission is a merge the credential may not perform.
	CodeNoMergePermission = "no_merge_permission"
	// CodeStrategyNotAllowed is a merge strategy the repository does not offer.
	CodeStrategyNotAllowed = "strategy_not_allowed"
	// CodeStaleHead is optimistic concurrency the forge names apart: the head
	// moved between the read and a re-run, refused before the re-run on every
	// family, or between the read and a merge on the product whose merge route
	// answers that cause in a status of its own (GitLab). A merge route that
	// answers a moved head in human text alone answers [CodeNotMergeable], and
	// the remedy for both is reading the pull request again.
	CodeStaleHead = "stale_head"
	// CodeValidation is a request upstream rejected as invalid, a RESPONSE this
	// library will not accept, which is a body it could not decode, and a label
	// name a creation could not resolve to an identifier on a read that answered
	// the repository's WHOLE label list. One code covers them because what a
	// consumer does about each is the same, and the Kind and the Status say which
	// end the shape came from.
	CodeValidation = "validation"
	// CodeLabelPageTruncated is that same unresolved label name where the page
	// read came back FULL, so the repository's label list continues past what the
	// resolution saw.
	//
	// It is its own code because the two answers have different remedies. A name
	// missing from a page that was the whole list does not exist, and the caller
	// creates it; a name missing from a TRUNCATED page may exist past the bound,
	// and the caller retries with a larger page bound or names a repository with
	// fewer labels. One code over both would send a caller back to the message,
	// which is what a code exists to avoid. The message names the page bound
	// either way.
	CodeLabelPageTruncated = "label_page_truncated"
	// CodeBranchCannotMerge is one product's refusal naming the branch rather
	// than the request.
	CodeBranchCannotMerge = "branch_cannot_merge"
	// CodeRepoArchived is a mutation against an archived repository: a
	// permanent refusal whose remedy is an operator unarchiving it, never
	// retried and never reported as a conflict, because no rebase and no
	// re-read reaches that state.
	CodeRepoArchived = "repo_archived"
	// CodeScopeInsufficient is a credential the instance authenticated and
	// refused for a permission or scope the operation needs: a fine-grained
	// token without the resource permission, a legacy token on a group that
	// requires fine-grained ones, or a legacy token without the scope. Its
	// remedy is the credential's permission set, never a project role and never
	// a reconnect, which is why it is neither [KindUnauthorized] nor the plain
	// [KindForbidden] a role refusal answers. It is issued on the one product
	// whose refusal names the cause in a machine-readable error member, and
	// decided by that member alone; [Error.Message] carries the instance's own
	// text naming what is missing, which is never branched on.
	CodeScopeInsufficient = "scope_insufficient"
	// CodeAlreadyEnqueued is a merge in flight with no verdict yet: an
	// equivalent merge already running under a request this caller did not
	// issue, or a merge accepted and running in the background. It rides a
	// successful outcome rather than an error, because the remedy is a read,
	// [Merges.MergeStatus] on the same pull request, and it is ONE code for both
	// of those states because what a consumer branches on is that it must read
	// the state back, not which answer put the merge in flight.
	CodeAlreadyEnqueued = "already_enqueued"
	// CodeAPIVersionRetired is a pinned REST API version the instance does not
	// serve: retired past its window on a product that has moved on, or not yet
	// present on a release that predates it. Both reach a consumer as the same
	// remedy, which is a release of this library carrying a version the instance
	// answers under, so they are one code rather than two.
	CodeAPIVersionRetired = "api_version_retired"
	// CodeMutationsDisabled is an operation refused because the client was
	// built with mutations off. The refusal is enforced at the operation
	// boundary rather than advisory, so the read-only posture is worth taking.
	CodeMutationsDisabled = "mutations_disabled"
	// CodeRedirectHopCap is a redirect chain longer than the hop cap. It is
	// its own code so a redirect loop is distinguishable from an operation
	// that merely ran out of time.
	CodeRedirectHopCap = "redirect_hop_cap"
	// CodeRedirectPortRefused is a redirect hop to a port outside the
	// connection's allowlist. It is refused in this library's own redirect
	// policy so it carries a code and a counter, rather than arriving as a
	// transport error the retry layer would treat as transient.
	CodeRedirectPortRefused = "redirect_port_refused"

	// CodeConnectionInvalid is a [Connection] a client cannot serve honestly:
	// no web base URL, a half-present client-certificate pair, a
	// [Connection.Proxy] net/url refuses or carrying no scheme this library
	// proxies over, or a [Header] entry with an empty name, or with a name or a
	// value outside net/http's field grammar, which the transport would refuse
	// on every request. It is the catch-all for a malformed connection and never
	// the answer where a refusal below names the cause.
	CodeConnectionInvalid = "connection_invalid"
	// CodeAnonymousRefused is a client built with no [CredentialSource], whose
	// requests would be anonymous. It is its own code because its remedy is a
	// credential rather than a corrected connection: a client that silently
	// read only public data would look like a permission problem on the
	// instance.
	CodeAnonymousRefused = "anonymous_refused"
	// CodePlaintextRefused is a plaintext http connection built without
	// [WithPlaintextHTTP]. It is its own code because the consumer names the
	// actual consequence to the user, which is that the token and every
	// request travel in cleartext on that network.
	CodePlaintextRefused = "plaintext_refused"
	// CodePrivateAddressRefused is a private-range or single-label host built
	// without [WithPrivateAddresses]. It is its own code because the consumer's
	// remedy is one deliberate statement per instance, which it cannot offer
	// without knowing that this was the refusal.
	CodePrivateAddressRefused = "private_address_refused"
	// CodeBudgetInvalid is a NEGATIVE value passed to one of the budget
	// options, [WithMutationReserve] and [WithRefreshLead] included, the second
	// refused by the creds package's source rather than by a family's
	// constructor. It is refused rather than defaulted, because a negative
	// deadline would expire every operation on the client and a negative bound,
	// reserve or lead would read as a limit nobody set. A ZERO is never this
	// refusal: with one configuration door a zero is the literal zero, and each
	// option's godoc says what its own zero does.
	CodeBudgetInvalid = "budget_invalid"
	// CodePageBoundInvalid is a page bound of zero or less, refused by
	// [ResolveList] rather than sent: one upstream accepts a zero with HTTP 200
	// and answers an item-less page beside "there is more", which would render
	// as an empty list marked [PartialPaginationCap] on every such call with no
	// upstream error to classify.
	CodePageBoundInvalid = "page_bound_invalid"
	// CodeListStateInvalid is a state filter that is [ListStateUnknown], one
	// the list it was given to has no state to filter on, or
	// [ListStateMerged] on [Issues.ListIssues], which has a state and no
	// merged one. The first is [ResolveList]'s to refuse, since it is invalid
	// whichever list is being resolved; the other two are the family method's,
	// which is what knows the list.
	CodeListStateInvalid = "list_state_invalid"
	// CodeListOwnerInvalid is a [WithOwner] scope that cannot be sent: an empty
	// owner or one outside the shared form, which [ResolveList] refuses whichever
	// list it resolves for, and an owner named on a list with no owner scope, or
	// a path where the family's owner is a single name, which the family method
	// refuses because it knows the list. The form is checked rather than escaped
	// because the owner can reach a search string, whose grammar would read a
	// space, a colon or a quote as a second qualifier.
	CodeListOwnerInvalid = "list_owner_invalid"
	// CodeListSinceInvalid is a [WithSince] bound that cannot be sent: the zero
	// time, which [ResolveList] refuses whichever list it resolves for, and a
	// bound named on a list with no creation time to bound, which the family
	// method refuses.
	CodeListSinceInvalid = "list_since_invalid"
	// CodeRunUndated is a run row with no creation time on a listing held to a
	// [WithSince] bound, which such a row cannot be held to. It is upstream's
	// shape, not the caller's: the same listing read with no bound answers the
	// row with a zero CreatedAt. A malformed run row of any other kind stays
	// [CodeValidation].
	CodeRunUndated = "run_undated"
	// CodeCursorInvalid is a [Cursor] that is not well formed, refused by
	// [ValidateCursor] and by [ResolveList] before it can be interpolated into
	// a request, and one well formed that names no position in the call it was
	// handed to, refused by the family before any request: another encoding, a
	// continuation minted by another call or on another connection, or a
	// page-numbered one minted at another page bound, as [WithAfter] states.
	CodeCursorInvalid = "cursor_invalid"
	// CodeRefInvalid is a commit ref that is not safe to interpolate into an
	// API path, refused by [ValidateRef] before any request. It is traversal
	// and form only, never git's grammar: the forge stays the authority on
	// which names a repository holds, so a name git would reject reaches the
	// instance and comes back as upstream's own refusal.
	CodeRefInvalid = "ref_invalid"
	// CodeHeaderReserved is an extra request header on a [Connection] whose
	// name this library or the HTTP stack beneath it writes itself, the set
	// [ReservedHeaders] returns. It is refused at connect time naming the header
	// rather than dropped silently, because an operator whose setting was
	// ignored believes something false about their traffic.
	CodeHeaderReserved = "header_reserved"

	// CodeCapabilityUnsupported is an operation refused because this instance
	// lacks a capability the operation requires: detection said so, so no request
	// is issued at all, which is the property that makes the refusal free. The
	// standing case is [PullRequests.RerunFailedChecks] against an instance whose
	// API has no re-run verb, which is [CapRerunChecks] read from
	// [Capabilities.ConnectionCaps].
	//
	// It carries the operation in [Error.Op], the capability the operation
	// required in [Error.Capability], and the [Evidence] detection reached the
	// verdict on, its Source and Detail, in [Error.Evidence], so the message
	// states a fact about the instance rather than a bare refusal. Those two
	// fields are this code's alone: every other code leaves them at their zero
	// values, and no other code populates either.
	//
	// It sits in the local group by remedy and apart from it by shape, which is
	// what [CodeFamilyUndetected] does in the opposite direction: this refusal
	// names both an operation and a family, so Op and [Error.Family] are
	// populated and [Error.Status] is zero because nothing was sent, and it
	// carries a [Error.DiagID] because there IS an operation whose log lines it
	// correlates with.
	//
	// It is written for a DEVELOPER, not for a user, and that is what shapes it.
	// A consumer following this library's own contract reads
	// [Capabilities.ConnectionCaps] and [Capabilities.GrantCaps] first and
	// renders a disabled control with the reason, so it never reaches this code;
	// reaching it means an operation was called that the caller could have known
	// was unsupported. So the message is written for a log rather than for a
	// dialog, and the code carries no user-facing text obligation.
	CodeCapabilityUnsupported = "capability_unsupported"

	// CodeFamilyUndetected is a connection whose forge family could not be
	// established: detection ran and nothing answered. It belongs to neither
	// group above (a request was made, and its answer identified no family),
	// so it carries the real status where one arrived and [FamilyUnknown]
	// always. It is its own code because its remedy is a corrected instance URL
	// rather than a credential or a permission, and because it is the one
	// failure on this surface that belongs to no family at all.
	CodeFamilyUndetected = "family_undetected"

	// CodeReconnectRequired is a credential the machinery has no way left to
	// renew: no record under the connection's key, a record of the unknown
	// kind, a static token past its expiry, a rotating one past its expiry
	// with no refresh token, a refresh token past its own expiry, or a refresh
	// the product answered terminally. Token fails with it until a human
	// connects again, and [CredReconnectRequired] is the state it surfaces as.
	CodeReconnectRequired = "reconnect_required"
	// CodeGrantDenied is a device grant the user declined at the verification
	// address. It is terminal for that grant.
	CodeGrantDenied = "grant_denied"
	// CodeGrantExpired is a device grant whose codes expired before the user
	// approved it, whether the product said so or a poll came after the
	// codes' own expiry. It is terminal for that grant.
	CodeGrantExpired = "grant_expired"
	// CodeGrantUnsupported is a device grant asked of a family this library
	// runs no device grant for. It is refused before any request.
	CodeGrantUnsupported = "grant_unsupported"
)

The machine-readable codes. A code is issued where something PROVES it and never where it would be inferred: two conditions a status cannot separate get the wider code, not a guess.

Codes are strings rather than a defined type because they cross a consumer's wire as strings, and because this block is where every one of them is declared, so a defined type would add a name without adding a fact.

The namespace is governed by a RULE rather than pinned by a total, and the rule is three clauses. Every refusal a consumer must branch on carries a code. The code is declared HERE, in the root package, beside the refusal that issues it, so a family package mints none of its own: a code a consumer branches on lives in this block, or it reaches that branch as a bare literal nothing on this surface documents. And a code's wire spelling is the constant's documented string, never a second vocabulary.

What holds the rule is api/forgeapi.txt, which lists every declared Code constant and fails the build on any movement. That is also what holds the one growth rule a 1.0 owes: a spelling can be ADDED and never changed or removed. No count of this block appears anywhere, deliberately: a total is a number every new refusal renegotiates, and it was never the gate. A reader who wants the list reads the generated surface file or this block.

View Source
const (
	// DefaultPageBound is the page size a list asks for when no
	// [WithPageBound] is given. A family whose instance or document serves
	// fewer rows a page asks for at most that many, and its walk still reaches
	// every row.
	DefaultPageBound = 100
	// DefaultListPages is the number of pages one list operation traverses
	// before reporting [PartialPaginationCap].
	DefaultListPages = 3
	// DefaultStatusPages is the number of pages one folded commit status
	// traverses. It is a bound of its own rather than the list bound, because
	// the folded-status endpoint is paginated at the instance's own page size
	// and neither its folded state nor its total reveals a truncation.
	DefaultStatusPages = 3
	// DefaultStatusReadsPerInterval is how many folded statuses one client reads
	// per rolling interval of its own keeping, on the family that needs one
	// request per pull request. The remainder is reported partial and reached in
	// the next interval, which resumes from the [RotationCursor] the consumer
	// stored, so no row starves.
	DefaultStatusReadsPerInterval = 20
	// DefaultMutationReserve is the budget the governor holds back from reads,
	// in the instance's own units, when no [WithMutationReserve] is given. It is
	// sized for a burst of mutations with their follow-up reads inside one
	// window, and it is a small share of every in-scope instance's window: one
	// per cent of GitHub's hourly REST window and two and a half of gitlab.com's
	// per-minute one, so a client that never mutates loses no more than that.
	DefaultMutationReserve = 50
	// DefaultRetries is the per-request retry cap. It is an upper bound rather
	// than a behaviour: the per-phase transport bounds sum to more than
	// [DefaultOperationTimeout], so a stalled instance exhausts the operation
	// deadline inside one attempt and the retries are reachable only on
	// failures that fail fast.
	DefaultRetries = 2
	// DefaultReadConcurrency is the number of concurrent reads per instance.
	// Two, following the upstream guidance to prefer serial over concurrent.
	DefaultReadConcurrency = 2
	// DefaultMutationConcurrency is the number of concurrent mutations per
	// instance. One.
	DefaultMutationConcurrency = 1
)

The budget defaults. A standalone consumer must not have to invent page caps and concurrency limits for forges it has not measured, so the library ships them and a consumer overrides what its own poller needs.

View Source
const (
	// DefaultOperationTimeout bounds one operation. It is applied as a context
	// deadline, which is the only bound that can interrupt an attempt already
	// in flight.
	DefaultOperationTimeout = 20 * time.Second
	// DefaultStatusTimePerInterval is the wall clock one client spends on
	// folded-status reads per rolling interval of its own keeping.
	DefaultStatusTimePerInterval = 60 * time.Second
	// DefaultMutationInterval is the minimum gap between consecutive mutations
	// on one instance, following the upstream instruction to pause between
	// mutative requests. It is a library default rather than something a
	// standalone consumer has to discover from a secondary rate limit.
	DefaultMutationInterval = time.Second
)

The budget's time defaults.

View Source
const BudgetRemainingUnknown = -1

BudgetRemainingUnknown is BudgetState.Remaining where the instance sends no budget signal, which of the four products is Gitea alone: Forgejo sends a structured `ratelimit` header the Gitea family parses like the other two families' signals. It is negative rather than zero on purpose: a zero would read as a budget spent to its last unit, and a poller that believed it would stop.

View Source
const DefaultRefreshLead = 5 * time.Minute

DefaultRefreshLead is how long before its expiry a rotating credential is due for refresh when no WithRefreshLead is given.

View Source
const DiagIDLength = 13

DiagIDLength is the rendered length of Error.DiagID: 13 base32 characters, because base32 carries 5 bits per character and 64 random bits need 13.

View Source
const MaxRedirectHops = 5

MaxRedirectHops is how many redirect hops one request may take. It counts the redirects followed, not including the original request.

It is this library's own number and it is not configurable. A non-nil redirect policy REPLACES net/http's default policy, whose only content is a ten-hop bound, so supplying our own removes that bound rather than adding to it: the cap is counted in this library's policy, beside the scheme, port, form and address checks. Exceeding it is refused with CodeRedirectHopCap and counted, never left to become an operation-deadline expiry reported as transient with nothing naming the cause.

View Source
const QueuePositionUnknown = -1

QueuePositionUnknown is ActionState.QueuePosition or MergeOutcome.QueuePosition where no position was read.

It is negative rather than zero on purpose: no merge response of any product carries a queue position, the only one in the surface is a field a list document reaches, and a zero would read as "next to merge".

View Source
const RepoIDPrefix = "v1."

RepoIDPrefix is the version prefix every derived repository identifier carries, so the derivation can change later without a consumer's stored identifier becoming ambiguous.

Variables

This section is empty.

Functions

func ReservedHeaders

func ReservedHeaders() []string

ReservedHeaders returns the request header names a Connection may not set through Connection.Headers: the ones something beneath the consumer already writes, which is this library for its credential injection, its API-version pin and the three it sends on every request, and Go's HTTP stack for the names it controls itself; plus the two request validators, which this library never writes, because every read it issues is unconditional, and which it refuses for the reason the next paragraphs give.

The set is DERIVED rather than curated, and the instrument is one bounded observation: a request driven through this library's own transport to a test server, whose ARRIVING header set is what this list is held to. It reads an OUTPUT because the alternative, that no code anywhere in this module writes a header, is an unbounded claim over the language and cannot be gated.

Three of the names are not observable, for two different reasons. The API-version pin belongs to one family's requests and is sent only to an instance known to carry that version, so it becomes observable when that family lands. The two request validators never become observable: conditional requests are a transport LAYER this library does not build, so no read of its own carries one. They are reserved anyway, because a validator a CONSUMER set would be answered with a not-modified status, which this library maps as the unexpected status it is rather than as an answer, so the header could only ever break the read it was set on. Refusing it by name at connect time says so where an operator reads it, and the refusal stays correct on the day the caching layer above exists, since that layer would write the same two names itself.

The stack's half is three plus two. Accept-Encoding, Content-Length and User-Agent arrive as header-map entries, and the first of those is the one a consumer could otherwise pass through and break every response with: the transport writes it to negotiate compression and decodes the body transparently, so a caller-set value turns that decoding off and hands the decoder gzip bytes. Host and Transfer-Encoding never arrive as headers at all, reaching a server through fields of its own request, so they are here for the stated reason that a caller setting either would be silently IGNORED rather than honoured, which is worse than a refusal. Connection is here on the same ground and arrives on neither shape of request.

Nothing on the set is a judgement about what a gateway may legitimately need. Everything not on it passes through, which is the point of a denylist over an allowlist here: a public library cannot foresee its users' gateways, and a release per unforeseen header is the wrong trade.

The names are in canonical MIME header case where that case is the conventional one, and a connection's entry is compared against them case-insensitively. Each call returns a fresh slice, so a caller may keep or sort it.

func ValidateCursor

func ValidateCursor(c Cursor) error

ValidateCursor refuses a Cursor that is not a well-formed continuation: a byte cap and the allowed character class of this library's own encoding, checked WITHOUT interpreting it. A failure is CodeCursorInvalid and never a request.

It is exported for the same reason ValidateSelector is, and it is the half of Cursor's contract a consumer cannot write for itself: a cursor comes back from a client as untrusted input on its way into an upstream request, the library owns the encoding, and a consumer that validated by parsing would be interpreting a value this type says it must not. ResolveList performs the same check on WithAfter.

The empty Cursor is valid and means the first page.

func ValidateOwner

func ValidateOwner(family Family, owner string) error

ValidateOwner refuses, without a request, an owner the family's lists would refuse under WithOwner, with CodeListOwnerInvalid, the code the list answers, so a stored owner can be refused when it is stored. The shared form is one or more segments of ASCII letters, digits, '-', '_' and '.', joined by '/', none empty, "." or "..", at most 255 bytes. Only GitLab takes a path, a group's full path, and FamilyUnknown takes the single-name rule, as ValidateSelector gives it the single-separator one.

func ValidateRef

func ValidateRef(ref string) error

ValidateRef refuses a commit ref, a branch name or a SHA, that is not safe to interpolate into an API path: the selector rule minus the separator ban. A byte cap, an allowed character class, no empty, "." or ".." segment, no leading or trailing separator; and no bound on separators, because a ref legitimately contains "/".

A failure is CodeRefInvalid, answered before any request with an empty Error.Op and FamilyUnknown, as every local refusal reads. The gap it closes is measured rather than supposed: the HTTP stack forwards ".." segments in a request URI verbatim, so a ref of "../../../user" would reach the instance as a different endpoint.

It is traversal and form only, never git's grammar. The forms git additionally rejects, a ".lock" suffix, a leading "-", "@{", are rare, each would be a published refusal a 1.0 cannot loosen, and the forge is the authority on which names a repository holds: such a name reaches the instance and comes back as upstream's own refusal, mapped to KindNotFound with the ref in the message so the caller sees the name it sent.

func ValidateSelector

func ValidateSelector(family Family, selector string) error

ValidateSelector refuses a decoded selector that is not safe to interpolate into that family's API path: a byte cap, an allowed character class, no empty, "." or ".." segment, no leading or trailing separator, and no separator beyond the family's own. A GitHub or Gitea selector is an owner and a name, so it carries exactly one separator.

A failure is CodeRepoRefInvalid, the same code DecodeRepoRef answers, because it is the same refusal reached without the decode.

The segment rules are spelled over slash-delimited path fragments here rather than borrowed from a filesystem-path library: a cleaned-path identity check rewrites "owner/repo" on any platform whose separator is not "/", so every valid selector would be refused there.

Types

type Account

type Account struct {
	// Login is the account name, which is also the username a git credential
	// helper hands over on the family that expects one.
	Login string
	// Name is the display name where the instance exposes one.
	Name string
	// Email is the account email where the instance exposes one.
	Email string
	// WebURL is the account's page on the instance.
	WebURL string
	// Scopes are the granted scopes where the family reports them, which is
	// what makes a grant capability evidence rather than inference. It is nil
	// where the family does not.
	Scopes []string
}

Account is the authenticated identity on one connection.

type ActionState

type ActionState struct {
	// Mergeable is not a bool, for the same reason CanPush is not: one
	// product's list omits the field entirely and another can serve a stale
	// one.
	Mergeable Support
	// Checks is the folded verdict over the counts below.
	//
	// Whether that fold is COMPLETE depends on the operation that filled it,
	// which is why the same field can be a verdict on one call and
	// [CheckUnknown] on another. A single-pull-request read follows the checks
	// collection to its end and answers a real verdict; a LIST takes one page of
	// each row's collection, rather than paying a fetch per row for a confident
	// green.
	//
	// What that bound COSTS the field is per PRODUCT, and this sentence claimed
	// [CheckUnknown] on all four until the measurement took two of them. The Gitea
	// family pays it as written: a row whose folded status had more answers
	// [CheckUnknown] with [PartialPaginationCap] on [PullRequest.Partial].
	// GitHub's first page carries per-state counts over the WHOLE collection, so
	// its verdict stays exact and the marker means the check NAMES truncated.
	// GitLab serves one scalar head-pipeline status with no collection under it,
	// so there is nothing to truncate and nothing to mark. Each operation's own
	// per-product table states which of the three a call answers.
	Checks CheckState
	// ChecksPassing is the number of checks in the CheckPassing state.
	ChecksPassing int
	// ChecksFailing is the number in CheckFailing.
	ChecksFailing int
	// ChecksPending is the number in CheckPending.
	ChecksPending int
	// ChecksNeutral is the number in CheckNeutral.
	ChecksNeutral int
	// ChecksUnknown is the number whose upstream value did not map.
	ChecksUnknown int
	// ChecksTotal is the number of checks the fold read. It is not the
	// upstream's own total where that total is page-scoped.
	//
	// One count per CheckState member plus this total, rather than passing,
	// failing and total: a remainder computed from three ints conflates
	// pending, neutral and unknown, and the two production fold policies this
	// vocabulary has to serve both turn on exactly those. A count added after
	// the tag would mean the published set was wrong at 1.0.
	ChecksTotal int
	// AutoMergeArmed reports that the forge will merge this pull request
	// itself once its requirements are met. Not a bool: a degraded read leaves
	// it unknown, and a consumer must not read that as disarmed.
	AutoMergeArmed Support
	// QueueState is the merge queue's verdict, or QueueNone where the
	// repository has no queue.
	QueueState QueueState
	// QueuePosition is the position in that queue, or [QueuePositionUnknown]
	// when none was read. It is not fetched inside a mutation: a merge reports
	// the enqueue without one, and the position arrives with the next list read,
	// on that row's action state, which is why [MergeOutcome.QueuePosition] and
	// this field carry the same sentinel.
	QueuePosition int
	// MergeBlocked is the cause a merge is refused, or MergeBlockNone.
	MergeBlocked MergeBlockReason
}

ActionState is what one pull request's own controls need: whether it can be merged, what its checks say, and what is in the way.

It is the fourth capability scope and it rides each item rather than the response, because mergeability, folded checks and armed auto-merge are properties of one pull request. A response-level slot could only describe one of the rows it was returned with.

type Budget

type Budget struct {
	// ListPages is the page cap per list operation. [WithListPages] sets it.
	ListPages int
	// StatusPages is the page cap per folded commit status.
	// [WithStatusPages] sets it. It is a bound of its own rather than the list
	// bound, because the folded-status endpoint is paginated at the instance's
	// own page size and neither its folded state nor its total reveals a
	// truncation; it is also what bounds the COMPLETE fold on
	// [PullRequests.ReadPR], and it prices no list path at all, since a list
	// takes one page of the nested collection and marks the row.
	StatusPages int
	// StatusReadsPerInterval is the folded-status read cap per rolling interval,
	// on the family whose folded status costs one request per pull request.
	// [WithStatusReadsPerInterval] sets it. It is enforced: the read that would
	// exceed it is not issued and its row is marked [PartialBudget], and the
	// next interval starts each list at the row after the last one served there,
	// so no row starves under a poller presenting the same lists in the same
	// order. Each list rotates on its own; the [RotationCursor] the consumer
	// stored carries the rotation across a restart.
	StatusReadsPerInterval int
	// Retries is the per-request retry cap, an upper bound rather than a
	// behaviour for the reason [DefaultRetries] states. [WithRetries] sets it,
	// and a zero there is no retries.
	Retries int
	// ReadConcurrency is the concurrent-read limit per instance.
	// [WithReadConcurrency] sets it.
	ReadConcurrency int
	// MutationConcurrency is the concurrent-mutation limit per instance.
	// [WithMutationConcurrency] sets it.
	MutationConcurrency int
	// OperationTimeout bounds one operation, applied as a context deadline.
	// [WithOperationTimeout] sets it.
	OperationTimeout time.Duration
	// StatusTimePerInterval is the wall clock one rolling interval of
	// folded-status work is allowed. [WithStatusTimePerInterval] sets it. It is
	// enforced like StatusReadsPerInterval: a read that cannot be issued inside
	// the interval's remaining clock is not sent and its row is marked
	// [PartialBudget]. OperationTimeout bounds each read inside it, so the work
	// stops at whichever bound it reaches first. A consumer that wants its whole
	// polling cycle bounded brings its own context.
	StatusTimePerInterval time.Duration
	// MutationInterval is the minimum gap between mutations.
	// [WithMutationInterval] sets it.
	MutationInterval time.Duration
}

Budget is what one connection is allowed to spend. It is read and never filled: DefaultBudget publishes the shipped numbers, Settings.Budget reports what one client resolved, and each field names the one option that moves it, so a zero passed to that option is the literal zero.

The per-interval fields bound a rolling interval the client keeps itself; nothing on this surface marks a polling cycle. The page size is the per-call WithPageBound, and the reserve held back from reads is WithMutationReserve.

func DefaultBudget

func DefaultBudget() Budget

DefaultBudget returns the budget an unconfigured client resolves: every field at its own Default* constant, the value each option's Default: clause names. It is a record to read, in the shape ReservedHeaders uses, never an input: a consumer moves a number one option at a time. Each call returns a fresh value.

type BudgetState

type BudgetState struct {
	// Remaining is the budget left in the current window, in the instance's own units:
	// requests on a REST endpoint, points on a document endpoint, and on a product with
	// one pool per surface the pool the last response drew on. It is
	// [BudgetRemainingUnknown] where the instance reports none. On GitHub and GitLab it
	// is the user's own quota, which every application acting for that user draws on,
	// so it falls while this client sends nothing: render it as the account's remaining
	// budget. The mutation reserve is held against it until Reset, after which the next
	// read is sent and its response reports the renewed window.
	Remaining int
	// Reset is when the window renews, or the zero time where the instance
	// reports none.
	Reset time.Time
	// LastCost is what the last call spent, in the same units as Remaining.
	LastCost int
	// RotationCursor is where the rotation stands now: the value to store and
	// hand to the next client's [WithRotationCursor]. It is empty on a family
	// with nothing to rotate, which is the neutral value every family answers
	// rather than a special case.
	//
	// It is the one field here a consumer STORES rather than displays. The
	// three above are facts about the connection a user interface renders; this
	// one is an opaque token whose only reader is this library.
	RotationCursor RotationCursor
}

BudgetState is the governor's state for one connection: what the instance last reported its remaining budget to be, when that budget renews, what the last call cost, and where the rotation stands.

It is read through one accessor, Governor.BudgetState, which every family client implements and Core carries, and never written from outside. The governor paces the connection's reads from it: it holds back the reserve WithMutationReserve sets, so a cycle of reads never spends what the merge the user is about to click will need, and DEFERS a read that would cross the reserve rather than issuing it. A deferred read is marked partial with PartialRateLimited, the same word an upstream throttle carries; the two are told apart here, where a deferral leaves Remaining above zero, and counted apart by Counters.ReadDeferred. The governor sits under the per-interval caps Budget publishes rather than replacing them, so the work is bounded by whichever it reaches first.

The signal is per PRODUCT rather than per family. GitHub, GitLab and Forgejo each report remaining and reset on every response, two of them from a rate-limit header and GitLab's documents priced by their own complexity; Gitea alone sends nothing, so there Remaining is BudgetRemainingUnknown, Reset is the zero time, and LastCost is this library's own per-call price, one per request. That is the neutral value in the field rather than a field omitted, so a consumer renders one shape for every family.

What the budget belongs to is the USER rather than this library, and that is the reading of the whole type. On GitHub and GitLab the quota a response reports is the account's primary one, drawn on by every application acting for that user, so Remaining moves when something else spends and this library's own consumption is not recoverable from it. The reserve WithMutationReserve sets is therefore measured against that SHARED pool, which is the useful direction: the client backs off when anyone has drained the account rather than only when it has drained it itself, and it costs nothing, because the number is already in hand on every response.

type Capabilities

type Capabilities interface {
	// ConnectionCaps reports what this instance can do, whoever is asking.
	//
	// Per forge:
	//
	//	GitHub   the X-GitHub-Request-Id header that names the product and the x-github-enterprise-version header that names an appliance's version, both riding traffic the connection already sent, then GET /meta where no header answered, beside GET /versions, which settles whether this instance serves the version this family pins, all cached with the connection, 0 to 2 requests
	//	GitLab   the X-Gitlab-Meta header riding traffic the connection already sent, then GET /api/v4/metadata, beside the one bounded schema question that settles whether this instance accepts the documents this family ships, both cached with the connection, 0 to 2 requests
	//	Gitea    neither X-Gitea-Version nor X-Forgejo-Version, which neither product sends, so GET /api/v1/version, whose body separates the two, then GET /swagger.v1.json where no cheaper source answered, both cached with the connection, and GET /api/v1/settings/api, whose max_response_items every page-numbered list asks for at most, read once beside the version, 0 to 3 requests
	//	Forgejo  neither X-Gitea-Version nor X-Forgejo-Version, which neither product sends, so GET /api/v1/version, whose body separates the two, then GET /swagger.v1.json where no cheaper source answered, both cached with the connection, and GET /api/v1/settings/api, whose max_response_items every page-numbered list asks for at most, read once beside the version, 0 to 3 requests
	//
	// GitHub departs from the normalized contract:
	//
	//	Caps[rerun_checks]  cannot supply  no wire field: per repository
	//
	// Forgejo departs from the normalized contract:
	//
	//	Caps[rerun_checks]  cannot supply  swagger has no rerun verb: no
	ConnectionCaps(ctx context.Context) (ConnectionCaps, error)

	// GrantCaps reports what this credential can do on it.
	//
	// Per forge:
	//
	//	GitHub   GET /user, whose X-OAuth-Scopes header carries the scope pair, 0 to 1 request
	//	GitLab   the PRRead document's user permissions, 0 to 1 request
	//	Gitea    GET /api/v1/repos/{owner}/{repo}/pulls/{number}, whose body carries the mergeable flag, 0 to 1 request
	//	Forgejo  GET /api/v1/repos/{owner}/{repo}/pulls/{number}, whose body carries the mergeable flag, 0 to 1 request
	//
	// GitLab departs from the normalized contract:
	//
	//	Caps[read_merge_state], after a scope refusal  cannot supply  no token-permission source a request reads: the token route refused the fine-grained token for lacking [Personal Access Token: Read] at the user boundary, so once a refusal names what the credential lacks, the merge-state grant answers unknown with that refusal as its evidence
	//
	// Gitea and Forgejo depart from the normalized contract:
	//
	//	Ev  cannot supply  no evidence object; body mergeable
	GrantCaps(ctx context.Context) (GrantCaps, error)

	// RepoAffordances reports what one repository allows.
	//
	// It is NOT the way to fill a list: every row a repository listing returns
	// already carries its own [Repository.Affordances], so calling this per row
	// is the per-item fan-out inside a list that this library's budget exists
	// to make visible. Call it for one repository a consumer holds an
	// identifier for.
	//
	// Per forge:
	//
	//	GitHub   GET /repos/{owner}/{repo}, then one read against the id-addressed location to resolve the successor where it answered from another address, 1 to 3 requests
	//	GitLab   GET /api/v4/projects/{id}, 1 request
	//	Gitea    GET /api/v1/repos/{owner}/{repo}, and where the repository moved, the one 301 its first read answers, whose Location names the successor the stale refusal carries, 1 to 2 requests
	//	Forgejo  GET /api/v1/repos/{owner}/{repo}, and where the repository moved, the one 301 its first read answers, whose Location names the successor the stale refusal carries, 1 to 2 requests
	//
	// GitHub departs from the normalized contract:
	//
	//	MergeTrain  cannot supply  not on the record: a rulesets read
	//
	// GitLab departs from the normalized contract:
	//
	//	HasIssues                  differs in shape  varies by permission
	//	CanPush                    differs in shape  from a role integer
	//	Ev                         cannot supply     no source on the row for its three keys, anonymous
	//	error, scope insufficient  not measured      no refusal on a public project outside the token's group, which a fine-grained token reads without a permission and which answered 200 with its permission object; a private project outside it is unmeasured, needs a private project outside the token's group that the account can read
	//
	// Gitea and Forgejo depart from the normalized contract:
	//
	//	MergeTrain  cannot supply  SupportNo: no queue
	RepoAffordances(ctx context.Context, repo RepoRef) (RepoAffordances, error)
}

Capabilities reads the three response-level capability scopes, so a consumer can gate a control on what this instance, this credential and this repository actually allow.

All three are accessors over state the client detected, but resolving one can cost a request: the evidence order includes a bounded probe and, for a capability nothing cheaper answered, a swagger fetch. They take a context for that reason.

type Capability

type Capability string

Capability names one thing a connection, a credential grant or a repository may be able to do. It is the key of all three capability maps.

The set is deliberately left open: nothing licenses a family to add a member, and nothing closes the set either, so a new member is additive.

const (
	// CapRerunChecks is connection-scoped and sits behind
	// [PullRequests.RerunFailedChecks]: a product with no re-run verb, or an
	// instance too old to serve one, reports it no or unknown with its
	// evidence.
	CapRerunChecks Capability = "rerun_checks"
	// CapReadMergeState is grant-scoped: the merge state a Merge control reads
	// needs push permission on one product, so a token without it makes the
	// field unknown rather than the feature absent. Reporting this as
	// unsupported would tell a user their instance lacks a feature their token
	// merely cannot see.
	CapReadMergeState Capability = "read_merge_state"
	// CapHasIssues keys [RepoAffordances.HasIssues] in
	// [RepoAffordances.Ev].
	CapHasIssues Capability = "has_issues"
	// CapCanPush keys [RepoAffordances.CanPush] in [RepoAffordances.Ev].
	CapCanPush Capability = "can_push"
	// CapMergeTrain keys [RepoAffordances.MergeTrain] in
	// [RepoAffordances.Ev].
	CapMergeTrain Capability = "merge_train"
)

The capabilities named so far.

type CheckContext

type CheckContext struct {
	// Name is the context name upstream reported.
	Name string
	// Description is upstream's own description, sanitized and bounded.
	Description string
	// TargetURL is where a human reads the check, empty where none was given.
	TargetURL string
	// State is this context's own mapped state. An upstream value outside the
	// mapping table yields CheckUnknown, a counter increment and a log line
	// naming the value, rather than a guess.
	State CheckState
}

CheckContext is one reporting check on a commit: one context, one verdict.

type CheckState

type CheckState int

CheckState is the folded CI verdict for one commit.

const (
	// CheckUnknown is the zero value: no check state was read, which includes
	// the case where a paginated status endpoint was truncated.
	CheckUnknown CheckState = iota
	// CheckPassing means every check that reported succeeded.
	CheckPassing
	// CheckFailing means at least one check finished with a conclusion that
	// needs attention: a failure, a timeout, a startup failure, or a check's
	// request that a person act on its result. Whether it blocks a merge is the
	// repository's rule.
	CheckFailing
	// CheckPending means at least one check is still running or has not started,
	// a run waiting for a person to approve it included, and none failed.
	CheckPending
	// CheckNeutral means the checks that reported were neutral or skipped. It
	// is unreachable through one product's rollup field and only visible per
	// check run, which is why a document selects the runs.
	CheckNeutral
)

The CheckState members. Each has a count of its own in ActionState, because the fold is policy and a consumer may want to parameterize it.

func (CheckState) String

func (c CheckState) String() string

String returns the member's wire spelling: "unknown", "passing", "failing", "pending" or "neutral".

type Checks

type Checks interface {
	// CommitStatus returns the folded verdict for a commit ref, a branch or a
	// SHA, with the contexts it was folded from.
	//
	// ref is refused with [CodeRefInvalid] before any request where
	// [ValidateRef] refuses it, because the value reaches an API path.
	//
	// On the family whose folded status costs one request per pull request this
	// is the read [Budget.StatusReadsPerInterval] counts over the client's own
	// rolling window, and the one the [RotationCursor] rotates.
	//
	// Its fold is COMPLETE: this addresses one commit, so the library follows
	// the status endpoint's pages until the fold is total, bounded by
	// [Budget.StatusPages], and marks [CommitChecks.Partial] with
	// [PartialPaginationCap] and [CommitChecks.State] at [CheckUnknown] past
	// that bound. The endpoint's own folded state is not usable for this:
	// it is computed over the returned page, so a page of successes reports
	// success for a failing commit.
	//
	// Per forge:
	//
	//	GitHub   the CommitRollup document, the one source on this product that tells a commit with no CI from a commit whose checks all passed, or GET /repos/{owner}/{repo}/commits/{ref}/status beside GET /repos/{owner}/{repo}/commits/{ref}/check-runs where this connection has already discovered the documents refused, plus one read against the id-addressed location to resolve the successor where those reads answered from another address, which both of them meet and one read answers, 1 to 5 requests
	//	GitLab   GET /api/v4/projects/{id}/repository/commits/{sha}/statuses, 1 to 3 requests
	//	Gitea    GET /api/v1/repos/{owner}/{repo}/commits/{ref}/status, and where the repository moved, the one 301 its old path answers, whose Location names the successor, every later request of the call addressing that successor, 1 to 4 requests
	//	Forgejo  GET /api/v1/repos/{owner}/{repo}/commits/{ref}/status, and where the repository moved, the one 301 its old path answers, whose Location names the successor, every later request of the call addressing that successor, 1 to 4 requests
	//
	// GitHub departs from the normalized contract:
	//
	//	State, under a fine-grained token  cannot supply  CheckUnknown with no count, beside forbidden on Partial: a fine-grained token holds no Checks permission, and the document refuses the rollup's contexts nodes on a private repository with FORBIDDEN, measured on the PRMine search, which selects the same rollup
	//
	// GitLab departs from the normalized contract:
	//
	//	Successor  cannot supply  nil: no successor key
	CommitStatus(ctx context.Context, repo RepoRef, ref string) (CommitChecks, error)

	// ListRuns lists every run of repo, not one commit's, in the order the product
	// answers them, on the route [PullRequests.RerunFailedChecks] resolves its run on;
	// no sort parameter is sent, because one family's route declares none. Under
	// [WithSince] it answers only the runs created at or after the bound, and a walk
	// ends once no later page can hold one. [WithState] and [WithOwner] are refused
	// before any request, with [CodeListStateInvalid] and [CodeListOwnerInvalid]: a
	// run has no open state to select on, and one repository has no owner.
	//
	// Per forge:
	//
	//	GitHub   GET /repos/{owner}/{repo}/actions/runs, the route the re-run resolves its run on, with the page bound as its page size and no head filter, then one read against the id-addressed location to resolve the successor where it answered from another address; a run whose conclusion is action_required, a fork's run waiting for a maintainer's approval with no job run, reads pending, the verdict the Gitea family gives the same wait; under a since bound, a page that reaches the 1,000-run window the route serves a created-filtered listing through, or ends short of its total with no next relation, holds back the runs of the oldest second it served, and the next page asks created=<bound>..<that second> from page 1, the inclusive range the route's created filter takes from the search syntax (https://docs.github.com/en/search-github/getting-started-with-searching-on-github/understanding-the-search-syntax#query-for-dates); where an earlier page of the range served that second, the range already ends at that second, or a page's runs of its oldest second are more than a continuation names, the next page asks created=<that second>..<that second> instead (measured: it answers that second's runs alone), drops by id the runs of it the walk answered, and the walk then goes on with created=<bound>..<the second before>, so each range a walk crosses into reads the runs of one second twice and pays once more for the pages they fill, 1 to 3 requests per page
	//	GitLab   GET /api/v4/projects/{id}/pipelines, the route the re-run resolves its pipeline on, with the page bound as its page size and no head filter, 1 request per page
	//	Gitea    GET /api/v1/repos/{owner}/{repo}/actions/runs, the route the re-run resolves its run on, with page from 1, the smaller of the page bound and the instance's stated maximum as its page size and no head filter, continued from the body's total_count, and where the repository moved, the one 301 its old path answers, whose Location names the successor, every later request of the call addressing that successor; under a since bound no time filter, the route declaring none, so each page's rows created before the bound's second are dropped; the route answers runs by id descending, or from v28 by repository index descending, co-monotonic with id (models/actions/run_list.go, FindRunOptions.ToOrders), and stamps a run's creation once, at insert (models/actions/run.go, its Created field), so a page whose every run was created more than a minute before the bound, the step a host clock can take back between two inserts, ends the walk with no continuation, 1 to 2 requests per page
	//	Forgejo  GET /api/v1/repos/{owner}/{repo}/actions/runs, with page from 1, the smaller of the page bound and the instance's stated maximum as its page size and no head filter, continued from the body's total_count, since the route sends no paging header and an instance whose maximum was lowered after the connection read it still serves a page of its own maximum, and where the repository moved, the one 301 its old path answers, whose Location names the successor, every later request of the call addressing that successor; under a since bound no time filter, the route declaring none, so each page's rows created before the bound's second are dropped; the route answers runs by id descending (models/actions/run_list.go, FindRunOptions.ToOrders) and stamps a run's creation once, at insert (models/actions/run.go, its Created field), so a page whose every run was created more than a minute before the bound, the step a host clock can take back between two inserts, ends the walk with no continuation, 1 to 2 requests per page
	//
	// GitHub departs from the normalized contract:
	//
	//	Items[].Ref.Number         differs in shape  run_number, numbered per workflow rather than per repository, so two runs of one repository can share it
	//	Next, under a since bound  differs in shape  the route serves a filtered listing through the first whole page that reaches its 1,000th run (https://docs.github.com/en/rest/actions/workflow-runs#list-workflow-runs-for-a-repository) and answers the page after it with no rows and total_count 0 (measured: 1,000 of a repository's 1,045 runs in 72 hours), so a walk goes on below the oldest second that page served; where more runs than that window share one second, the walk goes on below that second too, and where runs of such seconds remain that neither the walk nor that second's own range served, its last page, which carries no continuation, carries result_window counting them; the call fails as a validation failure of kind upstream where no narrower range reaches the rest: a range that served no row while its total states more, or a second's own range that ends short of its total below the window
	//
	// GitLab departs from the normalized contract:
	//
	//	Items[].Branch     differs in spelling  ref string: a branch on 2 of 5 and a refs/ path on 3, which merge-request and workload pipelines run for
	//	Items[].StartedAt  cannot supply        zero: the listing row carries no started_at, which only the single-pipeline route answers, and the listing adds no request for it
	//	Successor          cannot supply        nil: no successor key
	//
	// Gitea departs from the normalized contract:
	//
	//	Items[].Name                   differs in spelling  path string, the workflow file and its ref, on 6 of 6; no name key among the 26 the route answers
	//	Items[].CreatedAt, before v28  cannot supply        zero: the route's run object carries no created_at before v28, so a page read under a since bound is refused as run_undated rather than answered as holding no run
	//	Items[].UpdatedAt, before v28  cannot supply        zero: the route's run object carries no updated_at before v28
	//
	// Forgejo departs from the normalized contract:
	//
	//	Items[].Name    differs in spelling  workflow_id string, the workflow file's name, on 16 of 16 rows kept; no name key among the 23 the route answers
	//	Items[].Branch  differs in spelling  prettyref string, pull_request run "#820"; pull_request run "#841"; pull_request run "#842"; push run "main": the branch on a push run and a pull request's number on a pull-request run, no branch key
	//
	// GitHub sends this, where a wrong default would change the request silently:
	//
	//	head_sha  query  absent: the listing answers every run of the repository, and the head filter the re-run sends on the same route would narrow it to one commit's runs
	//	created   query  under a since bound, >= the bound at whole seconds, the precision the route stamps created_at at, and the rows stamped before the bound's second are dropped; past the window, the bound to the oldest second the window served, that second alone, or the bound to the second before it, both ends included; absent without one
	//
	// GitLab sends this, where a wrong default would change the request silently:
	//
	//	sha            query  absent: the listing answers every run of the repository, and the head filter the re-run sends on the same route would narrow it to one commit's runs
	//	created_after  query  under a since bound, one second before the bound at whole seconds, because the filter is strictly after, and the rows created before the bound's second are dropped; absent without one
	//
	// Gitea and Forgejo send this, where a wrong default would change the request silently:
	//
	//	head_sha  query  absent: the listing answers every run of the repository, and the head filter the re-run sends on the same route would narrow it to one commit's runs
	//	page      query  the first page of a walk, sent with every limit: a Forgejo instance answers a request with no page with every run of the repository
	//	limit     query  the smaller of the page bound and the instance's stated maximum, here the maximum both public instances state, the same on every page of one walk, which is what lets the stated total decide the continuation where an instance serves fewer rows than asked
	ListRuns(ctx context.Context, repo RepoRef, opts ...ListOption) (Page[Run], error)
}

Checks reads the CI verdict for a commit.

type CommitChecks

type CommitChecks struct {
	// Ref is the commit the checks belong to: the SHA the read resolved the
	// requested ref to, so a branch asked for comes back as its head commit. It
	// is the ref as asked only where the answer named no commit, which includes
	// a page bound that admitted no read.
	Ref string
	// Contexts are the checks read, in the order upstream returned them.
	Contexts []CheckContext
	// Partial is non-nil when the fold stopped short of every context, on the
	// same per-product terms as [PullRequest.Partial]: where the response carried
	// counts over the whole collection, State and the counts stay exact and what
	// is short is the context NAMES, and where it did not, State is CheckUnknown
	// rather than a verdict over part of the evidence. [PartialForbidden] and
	// [PartialGraphQLPartial] carry State CheckUnknown with no count.
	Partial *Partial
	// Successor is non-nil when the repository this read addressed had moved and
	// the read followed the redirect to it, on the same terms as
	// [Page.Successor].
	Successor *RepoRef
	// State is the fold.
	State CheckState
	// Passing is the number of contexts in CheckPassing.
	Passing int
	// Failing is the number in CheckFailing.
	Failing int
	// Pending is the number in CheckPending.
	Pending int
	// Neutral is the number in CheckNeutral.
	Neutral int
	// Unknown is the number whose upstream value did not map.
	Unknown int
	// Total is the number of contexts the fold read.
	Total int
}

CommitChecks is the folded CI verdict for one commit, with the contexts it was folded from.

State is computed HERE, over the contexts actually held, rather than taken from an upstream folded field: on one family that endpoint is paginated and both its folded state and its total are computed over the returned PAGE, so a page of successes reports success for a commit that is failing and the truncation is not detectable from the body at all.

type Connection

type Connection struct {
	// WebBaseURL is required: the clone origin, matching the HTML URLs the
	// instance serves. A credential helper registers against this, because it
	// is the origin a git remote carries. It is an origin and a path: a URL
	// carrying userinfo, a query or a fragment is refused with
	// [CodeConnectionInvalid], because userinfo names a host other than the
	// one a request reaches.
	WebBaseURL string
	// APIBaseURL is optional and derived per family when empty. It takes the
	// same form as WebBaseURL.
	APIBaseURL string
	// CABytes is an optional PEM bundle. It is COPIED rather than referenced,
	// so a path cannot change under a live connection, and it becomes the SOLE
	// trust anchor rather than an addition to the system pool: a self-hosted
	// instance presenting a private CA is the case this serves, and accepting
	// either is how a mis-issued public certificate stays accepted.
	CABytes []byte
	// ClientCert is an optional client certificate for mutual TLS. A
	// half-present pair is refused at entry rather than ignored.
	ClientCert []byte
	// ClientKey is the matching private key, handled as key material.
	ClientKey []byte
	// Proxy is the proxy THIS connection's traffic leaves through, a URL, empty
	// for a direct dial. It is one more per-connection statement rather than a
	// process-wide one, so one connection's proxy can never silently apply to
	// another and the connection record is the whole truth about where this
	// connection's traffic goes.
	//
	// It is NEVER read from the environment: HTTPS_PROXY, HTTP_PROXY and
	// NO_PROXY are not consulted at any level of the stack, because a
	// process-global setting is invisible in the record above and would apply to
	// every connection at once.
	//
	// The posture on a proxied connection is DELEGATED, which is the one
	// containment promise this field weakens and it is stated rather than
	// claimed: the socket goes to the proxy and the proxy resolves the
	// destination, so the resolved destination address is not checked here and
	// the party guaranteeing which destinations are reachable is the proxy the
	// operator named. The address policy applies to the PROXY's own address
	// instead, under the same private-range statement as a forge on one. What is
	// unchanged is everything above the dial: https unless plaintext was opted
	// into on this connection, credentials and Headers dropped on a hop whose
	// scheme, host or port differs from the connection's, the hop cap, URL form
	// validation, and the token's confidentiality end to end, because https
	// through a proxy is a CONNECT tunnel the proxy cannot read.
	//
	// A URL net/url refuses, or one carrying no scheme this library proxies
	// over, is refused at connect time with [CodeConnectionInvalid] rather than
	// dropped to a direct dial, for the reason the reserved-header refusal
	// gives: an operator whose setting was ignored believes something false
	// about their traffic.
	Proxy string
	// Headers are extra request headers sent on every request to this
	// connection, for the gateway in front of a self-hosted instance whose
	// canonical case is an identity-aware proxy taking a client id and a client
	// secret on every request. It is a list of [Header] entries, a name and a
	// value each, rather than a string map, so the order a consumer configured
	// is the order it reads back and a duplicated name is visible rather than
	// silently collapsed.
	//
	// What it accepts is partitioned by WHO WRITES the header. A name this
	// library or the HTTP stack beneath it writes itself, the set
	// [ReservedHeaders] returns, is refused at connect time with
	// [CodeHeaderReserved] naming the header, never dropped and never sent,
	// because an operator whose setting was ignored believes something false
	// about their traffic. Every other name passes through. Containment is the
	// redirect policy's job and not this list's: every entry is dropped on a
	// redirect hop whose scheme, host or port differs from the connection's, so
	// a header only ever reaches the instance the user typed.
	//
	// TWO LIMITS, stated so this is not read as enterprise coverage. Authorization
	// is RESERVED for the forge credential, so a front door that authenticates
	// with a bearer token in that header is not served by this field at all; a
	// gateway with header names of its own, which is the canonical case above,
	// is. And the commoner enterprise blocker is not a front door but an egress
	// PROXY, which no header this field carries reaches: the explicitly
	// configured one is Proxy above, and the transparent network-level one is
	// undetectable from inside the process and unsupported.
	Headers []Header
}

Connection is one instance a client talks to: where it is, and what it is trusted with. A client is built per connection, because the port allowlist, the address policy, the proxy and the TLS material below are all per instance.

type ConnectionCaps

type ConnectionCaps struct {
	// Caps is the verdict per capability. A missing key reads as
	// SupportUnknown.
	Caps map[Capability]Support
	// Ev is the evidence behind each verdict, keyed the same way.
	Ev map[Capability]Evidence
	// Product is the product this instance runs, as the detection that
	// established the family read it, never inferred. It is [ProductUnknown]
	// only on a zero value.
	Product Product
}

ConnectionCaps is what this instance can do, whoever is asking.

It is a separate scope from GrantCaps and RepoAffordances because the three answer differently: a capability can be disabled per repository on three of the four products, unauthorized per credential, or unknown for want of a safe probe. One boolean map conflated all of them.

type Core

type Core interface {
	Identity
	Repos
	PullRequests
	Merges
	Checks
	Issues
	Capabilities
	Governor

	// Close releases the client's pool, the idle connections of the transport the
	// library built for its connection: a caller that disconnects or re-addresses a
	// connection calls it to release them at once, and otherwise they close after
	// that transport's idle timeout. It adds no closed state, so a call after it
	// dials afresh.
	Close()
}

Core is the union of the roles EVERY family satisfies, and the union is therefore the INTERSECTION of what they can do.

A union of everything would re-import the god-interface failure and could not be satisfied by a family whose product lacks releases or pull-request labels, so those two roles stay out of Core and are asserted separately. A consumer declares the narrowest role it actually uses; Core exists for the one place that has to hand out a single value a route might ask anything of.

Adding a method to any of these interfaces is a breaking change for every implementor, so what is additive is a NEW role, a new family, a new struct field and a new enum member, never a method here.

type Counters

type Counters struct {
	// UnknownEnumValue fires for every upstream enumeration value outside a
	// mapping table, beside the log line naming it, which is how a maintainer
	// learns an upstream enumeration grew. It carries the family and field and
	// never the value, which is upstream-controlled and unbounded and would hand
	// a labelled metric unbounded cardinality; the log line names the value,
	// bounded and rune-sanitized.
	UnknownEnumValue func(family Family, field string)
	// CapabilityContradicted fires where an inferred capability is later
	// contradicted by a probe.
	CapabilityContradicted func(family Family, capability Capability)
	// RateLimited fires on a throttle refusal.
	RateLimited func(family Family)
	// Retried fires per retried request.
	Retried func(family Family, op string)
	// PartialResult fires per partial result, by reason.
	PartialResult func(family Family, reason PartialReason)
	// RefreshOutcome fires per credential-refresh outcome, naming the terminal
	// state it reached.
	RefreshOutcome func(family Family, terminal CredState)
	// HelperDecline fires per git-credential-helper decline, by reason, one of
	// unowned_origin, expired, reconnect_required, spent, no_account and
	// store_unreadable. A decline is the whole diagnosis a user gets when git
	// exits without naming a cause, so it is counted as well as reported. It is
	// the one counter here that carries no family, deliberately: its commonest
	// decline is a URL no connection owns, where there is no family to name.
	HelperDecline func(reason string)
	// HelperErase fires per erase git sends the credential helper for an origin
	// a connection owns, by that connection's family. Git sends one after any
	// authentication failure, and the helper keeps the connection rather than
	// let one failed push take it down, so this count is where those failures
	// show; a token that is really revoked surfaces through the probe.
	HelperErase func(family Family)
	// EnvelopeError fires per document-envelope error, by upstream's own error
	// type.
	EnvelopeError func(family Family, errType string)
	// SunsetSeen fires when an instance announces that a pinned API version is
	// closing down. It is DORMANT at 1.0: the version this library pins has no
	// closing date scheduled, so no instance announces one for it, and it fires
	// the day a successor to that version ships and the pinned one enters its
	// closing window. The older version that is already in such a window is
	// one this library never sends.
	SunsetSeen func(family Family)
	// AddressRefusal fires when the address policy refuses a host, by family and
	// by the refusal's kind. The library counts these itself rather than relying
	// on the refusing library's own log line, which lands in a sink this one does
	// not own, and a refusal is the event most worth correlating with the
	// operation that failed, which is why the family is a parameter here rather
	// than an addition after the tag, when adding one would break every
	// implementer.
	AddressRefusal func(family Family, kind string)
	// RedirectHopCapExceeded fires when a redirect chain is refused for length.
	// It is what separates a redirect loop from an operation that merely ran
	// out of time.
	RedirectHopCapExceeded func(family Family)
	// RedirectPortRefused fires when a hop's port is outside the connection's
	// allowlist.
	RedirectPortRefused func(family Family)
	// NonDurableCredentialWrite fires when a credential write returned no error
	// but could not be proved durable, which this library treats as a failed
	// write.
	NonDurableCredentialWrite func(family Family)
	// ReadDeferred fires when the governor defers a read rather than issuing one
	// that would cross the mutation reserve, by family and operation. It is what
	// separates a [PartialRateLimited] the library chose from one upstream
	// imposed, since both carry that one partial reason. It is dormant on Gitea,
	// the one product that sends no budget signal. The operation is spelled as
	// [Error.Op] is, so its label set is closed with that field's.
	ReadDeferred func(family Family, op string)
}

Counters is how the library's own event counts reach a consumer: a struct of functions over this package's own types, so no metrics library appears in any signature and a consumer wires whatever it already has.

Every field is optional; a nil function means that event is not counted. The set is frozen at 1.0, so a new counter is a new field, which is additive.

type CredKind

type CredKind int

CredKind is what kind of credential a source holds, which decides whether it can be refreshed at all.

const (
	// CredKindUnknown is the zero value: the kind was not recorded. A
	// zero-valued record must not read as a valid static token that never
	// needs refreshing, which is the worst available default for a credential.
	CredKindUnknown CredKind = iota
	// CredKindStaticPAT is a personal access token: it does not rotate, so it
	// is never refreshed and it fails terminally when revoked or expired.
	CredKindStaticPAT
	// CredKindRotatingOAuth is an OAuth grant with an expiry, refreshed under
	// a single owner per connection. One in-scope product issues no refresh
	// token from its device flow at all, so a connection of this kind can
	// still reach [CredReconnectRequired] by construction.
	CredKindRotatingOAuth
)

The CredKind members.

func (CredKind) String

func (k CredKind) String() string

String returns the member's spelling, which is what a log line carries: "unknown", "static_pat" or "rotating_oauth".

type CredState

type CredState int

CredState is where one credential sits in the refresh state machine.

const (
	// CredUnknown is the zero value: the state was not read. It is not a
	// state of the machine, and it exists so that an unpopulated record does
	// not read as valid.
	CredUnknown CredState = iota
	// CredValid means the credential works and is not near expiry.
	CredValid
	// CredRefreshDue means expiry is inside the refresh lead time.
	CredRefreshDue
	// CredRefreshing means a refresh is in flight under this connection's
	// single owner. Concurrent callers wait bounded by their own contexts and
	// then fail, rather than queueing indefinitely.
	CredRefreshing
	// CredReconnectRequired is terminal: the grant is gone and no further
	// attempt is made. It surfaces on the connection, and the remedy is a
	// human reconnecting rather than anything this library can retry.
	CredReconnectRequired
)

The CredState members: the machine's four states plus the unknown zero.

func (CredState) String

func (s CredState) String() string

String returns the member's spelling, which is what a log line and a counter dimension carry: "unknown", "valid", "refresh_due", "refreshing" or "reconnect_required".

type CredentialSource

type CredentialSource interface {
	// Token returns the token to authenticate with, refreshing first where
	// that is this source's job. It is called per request rather than cached
	// by the caller, so a rotation reaches the next request.
	Token(ctx context.Context) (string, error)

	// Kind reports whether this credential rotates.
	Kind() CredKind

	// State reports where the credential sits in the refresh machine, so a
	// consumer can render a connection that needs reconnecting before a
	// request fails.
	State() CredState
}

CredentialSource is where a connection's token comes from.

It is a real interface rather than a struct of functions, and it is one of only two consumer-implementable shapes on this surface: a consumer supplying its own credential source is a genuine case, and this is deliberately sized at the minimum that serves it, because adding a method here is a breaking change for every implementer.

It lives in this package rather than beside the refresh machine because the root's option vocabulary has to name it, and the root imports no subpackage.

Implementations are called from every operation, so one must be safe for concurrent use.

type Cursor

type Cursor string

Cursor is an opaque forward continuation. It is the library's own encoding and a consumer never interprets it: it is handed back to the next call unchanged, and a consumer that puts it in a URL validates it as opaque rather than parsing it. The empty Cursor means the list is complete.

type Error

type Error struct {
	// Op is the role method that failed, spelled as the method that names it:
	// "ListPRs", "ListMyPRs", "ReadPR", "MergeStatus", "MergePR" and so on, one
	// of TWENTY-ONE. The spelling is fixed here rather than left to each family
	// package, because three families minting three spellings for one operation
	// is undetectable, and because this value is a counter dimension
	// ([Counters.Retried] and [Counters.ReadDeferred], the two counters that
	// take an operation) whose label set must be closed.
	//
	// Twenty-one rather than eighteen, and the three extra are why the label set
	// is closed on the METHOD rather than on the published operation list: every
	// error-returning role method can name itself here, so beside the eighteen
	// operations sit "ConnectionCaps", "GrantCaps" and "RepoAffordances", each
	// of which resolves evidence that can cost a request and each of which
	// therefore has an upstream failure of its own to report. [Governor] is the
	// fourth accessor and is not among them: [Governor.BudgetState] performs no
	// I/O and returns no error, so it can never name itself here.
	//
	// It is EMPTY where this library refused before any operation began, which
	// is the local-refusal group above and [CodeFamilyUndetected]: a validator and
	// a constructor are not operations, so an empty Op there is the honest value
	// rather than a field nobody set. [CodeCapabilityUnsupported] is the one
	// member of that group that DOES name an operation, because the operation
	// began and was refused on what detection already knew about the instance.
	Op string
	// Code is the machine-readable cause, one of the Code* constants. A
	// consumer branches on this.
	//
	// It is EMPTY on exactly one failure, the read the governor deferred to hold
	// the mutation reserve: that refusal is reported as the rate-limited KIND with
	// no status, which is the pair an upstream throttle carries minus the status,
	// and it deliberately mints no code of its own. Everywhere else this field
	// carries one of the constants.
	Code string
	// Family is the family that answered, or [FamilyUnknown] where nothing
	// answered: a local refusal, an undetected family, and every refusal reached
	// without a family in hand at all, [ValidateCursor]'s among them.
	// [CodeCapabilityUnsupported] is the one refusal that sends nothing and
	// carries a family anyway, because detection had already identified it and
	// the refusal is a fact about that family's instance.
	Family Family
	// Status is the real HTTP status, always, and ZERO exactly where no request
	// was made. A document error arriving at HTTP 200 keeps the 200 here and
	// records that the classification came from the body, rather than faking a
	// zero; a refusal from the local-refusal group above never had a status to
	// carry, and the zero there says so rather than standing in for one.
	Status int
	// Kind is the class of failure.
	Kind ErrorKind
	// Retryable reports whether this library considers another attempt useful.
	Retryable bool
	// Message is upstream's own message, with the request's credential and the
	// connection's own header values redacted out of it, sanitized and bounded.
	// It is never classified on: a caller branches on Code and Kind.
	Message string
	// RetryAfter is the wait upstream asked for, zero when it named none.
	RetryAfter time.Duration
	// DiagID is a per-failure random identifier, [DiagIDLength] characters,
	// carried in the user-visible error and in every log line for that
	// operation. It is not a session identifier, not stable across retries,
	// and never a lookup key for state.
	//
	// A local refusal carries none: it names no operation, so there are no log
	// lines for one to correlate. [CodeCapabilityUnsupported] carries one for
	// exactly that reason read the other way, since it does name an operation.
	DiagID string
	// Successor addresses the repository this one moved to, carried on
	// [CodeRepoRefStale] and nil everywhere else. It is nil on that code too where
	// the move was detected but the successor could not be named (the read resolving
	// it was refused or could not see it, or the redirect names no repository the
	// family addresses): reconnect and relist rather than re-point. It is spelled and
	// emptied as [Page.Successor], [CommitChecks.Successor] and [MergeStatus.Successor].
	Successor *RepoRef
	// Capability is the capability the refused operation required, carried on
	// [CodeCapabilityUnsupported] and empty everywhere else. It is a field
	// rather than prose inside Message because a consumer branches on values
	// here and never on text, which is the same rule Code states, and because
	// the refusal is this library's own: there is no upstream message for
	// Message to carry on this code.
	Capability Capability
	// Evidence is what detection reached that verdict on, its Source and its
	// Detail, carried on [CodeCapabilityUnsupported] and the zero [Evidence]
	// everywhere else. It is the same value [Capabilities.ConnectionCaps]
	// renders beside a disabled control, so a developer reading the log line
	// and a user reading the dialog are looking at one fact.
	Evidence Evidence
}

Error is one mapped failure.

Mapping is by family plus operation plus status plus selected headers, never by status alone: a 409 is optimistic concurrency on one family's merge, a merge already in flight on another's, and on a creation the pull request that already exists. That is why Code exists beside Kind.

The package's error contract is THREE-WAY: every operation in this package and in every family package answers nil, a *Error, or a context sentinel. This type is the second of those, and the pointer form is the one to recover:

var ferr *forgeapi.Error
if errors.As(err, &ferr) { switch ferr.Code { ... } }

The third arm is context.Canceled or context.DeadlineExceeded, returned UNCHANGED where the operation's context ended, so errors.Is against Go's own sentinel holds. It is not mapped into this type, and that is the one place the every-error-is-ours rule yields: a function taking a context is expected to return that context's error, every consumer already writes the check that only works if it does, and a *Error carrying a code of its own would keep the rule literally true while breaking the check nobody tests for. It follows that the library's own per-operation deadline and the caller's own cancellation are indistinguishable by type; a caller that needs the distinction reads its own context.

This type wraps nothing and declares no Unwrap, deliberately and decided here rather than after the tag: a transport cause reachable through this chain would put a dependency's error type in the published surface, which is the containment this library's own dependency rule refuses, and the mapping is a translation at the boundary rather than a pass-through. So the chain terminates here, and a consumer branches on Code and Kind and never on Message.

func (*Error) Error

func (e *Error) Error() string

Error implements the error interface.

The separator after the operation belongs to the CODE, so the one failure that carries no code, the governor's deferral, renders as the operation and its status rather than with a gap where a cause would be.

type ErrorKind

type ErrorKind int

ErrorKind is the class of a mapped failure: what a caller can do about it.

const (
	// KindUnknown is the zero value. It is not a mapping outcome: an
	// unmapped combination is KindUpstream, which says the failure reached us
	// unclassified rather than that nobody looked.
	KindUnknown ErrorKind = iota
	// KindUnauthorized is a dead credential. A probe separates it from
	// KindTransient, which is a dead network.
	KindUnauthorized
	// KindForbidden is a live credential without the permission, or an
	// instance refusing the operation outright.
	KindForbidden
	// KindNotFound is a resource this credential cannot see, which is what one
	// product answers instead of KindForbidden by design.
	KindNotFound
	// KindNotMergeable is a refusal to merge that no retry fixes.
	KindNotMergeable
	// KindConflict is optimistic concurrency on two families and a content
	// conflict on the third, which is why Code exists beside Kind.
	KindConflict
	// KindRateLimited is a throttle refusal. It fails fast for that instance
	// rather than consuming its retry budget, and RetryAfter carries the wait
	// where upstream stated one.
	KindRateLimited
	// KindTransient is a transport-level failure worth retrying.
	KindTransient
	// KindUpstream is a failure whose combination of family, operation, status
	// and headers is not in the mapping table. It is never guessed into
	// another kind.
	KindUpstream
)

The ErrorKind members.

func (ErrorKind) String

func (k ErrorKind) String() string

String returns the member's spelling, which is what a log line carries: "unknown", "unauthorized", "forbidden", "not_found", "not_mergeable", "conflict", "rate_limited", "transient" or "upstream".

type Evidence

type Evidence struct {
	// Source is the step of the resolution order that answered: one of the
	// seven Evidence* constants, one per step, or [EvidenceUnknown] where none
	// did.
	Source EvidenceSource
	// Detail is the version, the endpoint, or why the answer is unknown.
	Detail string
}

Evidence records WHY a capability reads the way it does, so an unknown can be rendered with a reason instead of as a silent absence.

type EvidenceSource

type EvidenceSource string

EvidenceSource is the step of the resolution order that answered a capability.

It is a defined type rather than a bare string because the membership is closed: there are seven steps, one per Evidence* constant below, and a family cannot add an eighth. Its zero value is EvidenceUnknown, no step at all, under the same rule every enumeration here follows: an Evidence nobody populated reads as unknown rather than as one of the steps. Its underlying type is string because a step's member IS the wire spelling, so no separate rendering is needed for the seven.

const (
	// EvidenceResponseHeader is a header on a response the client already
	// made.
	EvidenceResponseHeader EvidenceSource = "response-header"
	// EvidenceResponseBody is a field in the resource's own authenticated
	// response body, which is where a RepoAffordances value normally comes
	// from; the exception is [RepoAffordances.MergeTrain] on the Gitea family,
	// a fixed product property answered from the version body.
	EvidenceResponseBody EvidenceSource = "response-body"
	// EvidenceMetadata is an authenticated metadata endpoint.
	EvidenceMetadata EvidenceSource = "metadata"
	// EvidenceProbe is a bounded probe of the specific endpoint.
	EvidenceProbe EvidenceSource = "probe"
	// EvidenceVersion is inference from the instance's version. It is the
	// cheapest source in practice for one family, whose products send no
	// version header at all and separate themselves in the version body.
	EvidenceVersion EvidenceSource = "version"
	// EvidenceSwagger is the instance's swagger document, fetched under decode
	// bounds and cached with the connection, for a capability no cheaper
	// source answered. Its absence is SupportUnknown rather than an error: the
	// document is nearly a megabyte on two products and can be switched off on
	// a working instance.
	EvidenceSwagger EvidenceSource = "swagger"
	// EvidenceDefault is the answer when nothing else answered.
	EvidenceDefault EvidenceSource = "default"
)

The seven evidence sources, in resolution order: cheapest first.

EvidenceSwagger sits ahead of EvidenceDefault and not after it. Default is terminal by definition, being the answer when nothing else answered, so an order that reached it would stop there and no capability would ever justify fetching a swagger document approaching a megabyte.

const EvidenceUnknown EvidenceSource = ""

EvidenceUnknown is the zero EvidenceSource: no step of the resolution order answered, because the Evidence was never populated. It is the one member whose value is not its wire spelling, since the zero of a string is empty and an empty source on the wire would be indistinguishable from an omitted key, so the envelope renders it as "unknown".

func (EvidenceSource) String

func (s EvidenceSource) String() string

String returns the member's wire spelling: each of the seven steps as it is spelled, and "unknown" for EvidenceUnknown, whose value is empty. It exists for that one member: without it an Evidence nobody populated would format through %s or %v as the empty string, which on the wire is indistinguishable from an omitted key.

type Family

type Family int

Family is one implementation package. There are three, because Gitea and Forgejo share one; the four products an instance can be are Product, and a family serves one or two of them.

const (
	// FamilyUnknown is the zero value: no family was detected.
	FamilyUnknown Family = iota
	// FamilyGitHub serves github.com and Enterprise Server.
	FamilyGitHub
	// FamilyGitLab serves gitlab.com and self-managed instances.
	FamilyGitLab
	// FamilyGitea serves Gitea and Forgejo, until the first response-shape
	// divergence splits it.
	FamilyGitea
)

The Family members.

func (Family) String

func (f Family) String() string

String returns the member's spelling, which is what a log line and a wire field carry: "unknown", "github", "gitlab" or "gitea".

type Governor

type Governor interface {
	// BudgetState reports the remaining budget, when it renews, what the last call
	// cost, and where the rotation stands. It performs no I/O and is safe to call
	// concurrently with operations. On GitHub and GitLab the budget is the USER's
	// quota, which every application acting for that user draws on, so it moves
	// while this client sends nothing ([BudgetState.Remaining]). The [RotationCursor]
	// a consumer persists is read back here, empty on a client that rotates nothing.
	//
	// Per forge:
	//
	//	GitHub   the signal riding every response the other operations already received, no request
	//	GitLab   the signal riding every response the other operations already received, no request
	//	Gitea    nothing: this product sends no signal, so the neutral value is the answer, no request
	//	Forgejo  the signal riding every response the other operations already received, no request
	//
	// GitHub departs from the normalized contract:
	//
	//	Remaining       differs in shape  the pool the last response drew on, which its X-RateLimit-Resource header names: core after a REST call, graphql after a document
	//	RotationCursor  cannot supply     empty: every response carries its budget
	//
	// GitLab departs from the normalized contract:
	//
	//	LastCost        differs in shape  queryComplexity.score; REST 1 per request
	//	RotationCursor  cannot supply     empty: nothing rotates
	//
	// Gitea departs from the normalized contract:
	//
	//	Remaining  cannot supply  BudgetRemainingUnknown: no signal
	//	Reset      cannot supply  zero time: no signal
	//	LastCost   cannot supply  own per-call price
	//
	// Forgejo departs from the normalized contract:
	//
	//	LastCost  cannot supply  own per-call price
	BudgetState() BudgetState
}

Governor reads the per-connection budget the library paces itself against, and the rotation cursor a consumer persists on its behalf.

It is a role of its own, in Core, rather than a method on each family client alone, for the reason the three capability accessors are: the one cross-forge entry point, the families subpackage's Open, hands out a Core, and a consumer on that path has to render the budget beside the capabilities, and store the cursor, without asserting a concrete client. One method, because the governor exposes one value and is never written from outside.

type GrantCaps

type GrantCaps struct {
	// Caps is the verdict per capability. A missing key reads as
	// SupportUnknown.
	Caps map[Capability]Support
	// Ev is the evidence behind each verdict, keyed the same way.
	Ev map[Capability]Evidence
}

GrantCaps is what this credential can do on this instance. A capability the token cannot see is a grant fact, not a software fact.

type Header struct {
	// Name is the header's field name.
	Name string
	// Value is the value to send.
	Value string
}

Header is one consumer-set request header on a Connection: a name and a value, and nothing else.

The value is a LITERAL the consumer supplies from wherever it keeps such things. A gateway secret is the realistic case and this library does not resolve it: CredentialSource is consumer-implemented, so a consumer already holds every secret this library could obtain, and a key it chose for a store it owns would ask this library to resolve that key into a value the consumer had in its hand. What the library does owe the value is redaction wherever it logs, which it does whatever the value's provenance.

An entry with an empty Name, with a Name that is not a valid HTTP field name, or with a Value that is not a valid field value (net/http's own rules: no CR, LF or other control byte, no space in a name), is a malformed connection and is refused at connect time with CodeConnectionInvalid, because the transport would refuse every request carrying it and a connection that can never succeed is the catch-all's case; a Name in ReservedHeaders is refused with CodeHeaderReserved. Names are compared case-insensitively, as HTTP does.

type Identity

type Identity interface {
	// Whoami returns the authenticated account, and the granted scopes where
	// the family reports them.
	//
	// It is one request on every family, whoever calls it: a poller's first call
	// of a cycle and a connect dialog's confirmation are the same read, and
	// nothing about it is counted or rotated.
	//
	// Per forge:
	//
	//	GitHub   GET /user, whose X-OAuth-Scopes header carries the scopes, 1 request
	//	GitLab   GET /api/v4/user, 1 request
	//	Gitea    GET /api/v1/user, 1 request
	//	Forgejo  GET /api/v1/user, 1 request
	//
	// GitLab departs from the normalized contract:
	//
	//	Scopes                     cannot supply  nil: the user route carries no scope list among its 41 keys, and the token route's scopes describe one token rather than the grant
	//	error, scope insufficient  not measured   unmeasured, needs a GitLab token holding no user read: the fine-grained token measured holds User: Read and the user route answered it 200
	//
	// Gitea and Forgejo depart from the normalized contract:
	//
	//	Scopes  cannot supply  nil: the user route carries no scope list and its response no scope header
	Whoami(ctx context.Context) (Account, error)
}

Identity reads who the connection's credential belongs to.

type Issue

type Issue struct {
	// Ref addresses it.
	Ref IssueRef
	// Repo is the repository it belongs to.
	Repo RepoRef
	// Title is its title.
	Title string
	// Body is its description.
	Body string
	// Author is the account that filed it.
	Author string
	// WebURL is its page on the instance.
	WebURL string
	// Labels are the labels on it.
	Labels []Label
	// CreatedAt is when it was filed, zero where the instance reported none.
	CreatedAt time.Time
	// UpdatedAt is its last update, zero where the instance reported none.
	UpdatedAt time.Time
	// State is open or closed.
	State IssueState
}

Issue is one issue.

type IssueRef

type IssueRef struct {
	// Number is the issue number the family's own API takes.
	Number int
}

IssueRef addresses one issue within a repository.

type IssueState

type IssueState int

IssueState is the state of one issue.

const (
	// IssueStateUnknown is the zero value: the state did not map.
	IssueStateUnknown IssueState = iota
	// IssueStateOpen is open.
	IssueStateOpen
	// IssueStateClosed is closed.
	IssueStateClosed
)

The IssueState members.

func (IssueState) String

func (i IssueState) String() string

String returns the member's spelling: "unknown", "open" or "closed".

type Issues

type Issues interface {
	// ListIssues lists issues in repo.
	//
	// Per forge:
	//
	//	GitHub   GET /repos/{owner}/{repo}/issues, whose population is issues and pull requests both, so a row carrying a pull-request key is dropped, then one read against the id-addressed location to resolve the successor where it answered from another address, 1 to 3 requests per page
	//	GitLab   GET /api/v4/projects/{id}/issues, 1 request per page
	//	Gitea    GET /api/v1/repos/{owner}/{repo}/issues, filtered to issues, because this route's population is issues and pull requests both, and where the repository moved, the one 301 its old path answers, whose Location names the successor, every later request of the call addressing that successor, 1 to 2 requests per page
	//	Forgejo  GET /api/v1/repos/{owner}/{repo}/issues, filtered to issues, because this route's population is issues and pull requests both, and where the repository moved, the one 301 its old path answers, whose Location names the successor, every later request of the call addressing that successor, 1 to 2 requests per page
	//
	// GitLab departs from the normalized contract:
	//
	//	Items[].Ref             differs in spelling  iid int
	//	Items[].Labels[].Color  differs in spelling  color string, leading #
	//	Items[].State           differs in spelling  state string, opened
	//	Successor               cannot supply        nil: no successor key
	ListIssues(ctx context.Context, repo RepoRef, opts ...ListOption) (Page[Issue], error)

	// ListMyIssues lists OPEN issues across every repository the credential can
	// reach, scoped as [PullRequests.ListMyPRs] is: what the credential authored by
	// default, and every open issue under one owner under [WithOwner]. As there, each
	// row carries its own [Issue.Repo], [Page.Successor] is never set, a search's
	// result window ends a walk with [PartialResultWindow], [WithState] is refused
	// with [CodeListStateInvalid], and archived repositories' rows are listed unless
	// the block below says otherwise. No row adds a request to a page's price.
	//
	// Per forge:
	//
	//	GitHub   the IssueMine search document, whose query names type:issue and the author keyword, or user:{owner} in its place under an owner scope, 1 request per page
	//	GitLab   GET /api/v4/issues?scope=created_by_me, or GET /api/v4/groups/{owner}/issues under an owner scope, both with the open state and the label detail parameter; an owner named by digits alone, which every namespace route reads as a numeric id, is first resolved by name through the OwnerLookup document to the id the route takes, one request more on every page, 1 to 2 requests per page
	//	Gitea    GET /api/v1/repos/issues/search filtered to issues and to the rows the authenticated credential created, or by owner={owner} in place of created=true under an owner scope, 1 request per page
	//	Forgejo  GET /api/v1/repos/issues/search filtered to issues and to the rows the authenticated credential created, or by owner={owner} in place of created=true under an owner scope, 1 request per page
	//
	// GitHub departs from the normalized contract:
	//
	//	Next                     differs in shape  a continuation while hasNextPage holds, as on a viewer's first page of 20 rows over issueCount 188; past the search connection's 1,000th result, where it answers hasNextPage false while issueCount states 4617, and a page asked for after it answers no rows and a null end cursor, so a walk ends there with no continuation and carries result_window
	//	error, owner unresolved  cannot supply     empty: an owner the search index holds no account for answers issueCount 0 and no error, so no such owner reads as nothing open
	//
	// GitLab departs from the normalized contract:
	//
	//	Items[].Ref                           differs in spelling  iid int, on the group route's 2 of 2 rows
	//	Items[].Labels[].Color                differs in spelling  color string, leading #, on 16 of 16, upper-case hex on 1
	//	Items[].State                         differs in spelling  state string, opened on 2 of 2: the state parameter fixes it
	//	error, owner unresolved               cannot supply        404 Group Not Found on a user's own namespace, an account the users route answers, exactly as on an owner no namespace holds
	//	Items[], from an archived repository  cannot supply        none on either route: non_archived defaults to true
	ListMyIssues(ctx context.Context, opts ...ListOption) (Page[Issue], error)

	// CreateIssue files an issue and returns the created object.
	//
	// Per forge:
	//
	//	GitHub   POST /repos/{owner}/{repo}/issues, whose own body carries the labels the caller named, then one read against the id-addressed location to resolve the successor where it answered from another address, 1 to 2 requests
	//	GitLab   POST /api/v4/projects/{id}/issues, 1 request
	//	Gitea    GET /api/v1/repos/{owner}/{repo}/labels where the caller names labels, whose ids this product's option takes in place of their names, then POST /api/v1/repos/{owner}/{repo}/issues, 1 to 2 requests
	//	Forgejo  GET /api/v1/repos/{owner}/{repo}/labels where the caller names labels, whose ids this product's option takes in place of their names, then POST /api/v1/repos/{owner}/{repo}/issues, 1 to 2 requests
	//
	// GitLab departs from the normalized contract:
	//
	//	Ref                   differs in spelling  iid int, the project's own sequence
	//	Labels[].Color        cannot supply        no colour on the answer: its labels arrive as name strings
	//	Labels[].Description  cannot supply        no description on the answer: its labels arrive as name strings
	//	State                 differs in spelling  state string, opened
	//
	// GitHub sends this, where a wrong default would change the request silently:
	//
	//	labels  body  the labels the caller named, as the NAMES this product's issue creation takes, which is the difference from its pull-request creation
	//
	// GitLab sends this, where a wrong default would change the request silently:
	//
	//	labels  body  the labels the caller named, as the NAMES this product's creation takes, so nothing has to resolve them first
	//
	// Gitea and Forgejo send this, where a wrong default would change the request silently:
	//
	//	labels  body  the labels the caller named, as the numeric ids this product's creation declares in place of their names, which is what the label read this row prices resolves
	CreateIssue(ctx context.Context, repo RepoRef, req NewIssue) (Issue, error)

	// CloseIssue closes an issue and returns it as the forge now reports it.
	//
	// Per forge:
	//
	//	GitHub   PATCH /repos/{owner}/{repo}/issues/{number}, then one read against the id-addressed location to resolve the successor where it answered from another address, 1 to 2 requests
	//	GitLab   PUT /api/v4/projects/{id}/issues/{iid}, 1 request
	//	Gitea    PATCH /api/v1/repos/{owner}/{repo}/issues/{number}, 1 request
	//	Forgejo  PATCH /api/v1/repos/{owner}/{repo}/issues/{number}, 1 request
	//
	// GitLab departs from the normalized contract:
	//
	//	Ref                   differs in spelling  iid int, the project's own sequence
	//	Labels[].Color        cannot supply        no colour on the answer: its labels arrive as name strings
	//	Labels[].Description  cannot supply        no description on the answer: its labels arrive as name strings
	CloseIssue(ctx context.Context, repo RepoRef, issue IssueRef) (Issue, error)
}

Issues is the issue surface.

type Label

type Label struct {
	// Name is the label.
	Name string
	// Color is its colour as the instance spells it.
	Color string
	// Description is its description.
	Description string
}

Label is one label defined on a repository.

type Labels

type Labels interface {
	// ListLabels returns labels defined on repo.
	//
	// Per forge:
	//
	//	GitHub   GET /repos/{owner}/{repo}/labels, then one read against the id-addressed location to resolve the successor where it answered from another address, 1 to 3 requests per page
	//	GitLab   GET /api/v4/projects/{id}/labels, 1 request per page
	//	Gitea    GET /api/v1/repos/{owner}/{repo}/labels, and where the repository moved, the one 301 its old path answers, whose Location names the successor, every later request of the call addressing that successor, 1 to 2 requests per page
	//	Forgejo  GET /api/v1/repos/{owner}/{repo}/labels, and where the repository moved, the one 301 its old path answers, whose Location names the successor, every later request of the call addressing that successor, 1 to 2 requests per page
	//
	// GitLab departs from the normalized contract:
	//
	//	Items[].Color  differs in spelling  color string, leading #, on 23 of 23, upper-case hex on 3
	//	Successor      cannot supply        nil: no successor key
	ListLabels(ctx context.Context, repo RepoRef, opts ...ListOption) (Page[Label], error)
}

Labels reads the labels a repository defines. It is OPTIONAL for the same reason Releases is: a family whose product has no pull-request labels does not implement it.

type ListOption

type ListOption interface {
	// contains filtered or unexported methods
}

ListOption configures one list call. Like Option it cannot be implemented outside this package, and last one wins.

func WithAfter

func WithAfter(c Cursor) ListOption

WithAfter resumes a list from the opaque cursor a previous Page returned. ResolveList refuses a malformed one with CodeCursorInvalid, the check ValidateCursor makes without a request. A call differing from the minting one in family, API base, operation, repository, state filter, scope or since bound refuses it with that code before any request. A page-numbered one is also refused at another page bound; an upstream connection position resumes at any bound.

Default: the first page.

func WithOwner

func WithOwner(owner string) ListOption

WithOwner scopes PullRequests.ListMyPRs and Issues.ListMyIssues to every OPEN item under one owner, whoever authored it, and Repos.ListRepos to the repositories it owns: a login, or on GitLab a group's full path with its subgroups. An owner the instance does not resolve answers CodeOwnerUnresolved, or an empty page where the per-forge block says so. Other lists, and an owner outside the form ValidateOwner checks, are refused before any request with CodeListOwnerInvalid.

Default: the credential's own authorship, and every repository it reaches.

func WithPageBound

func WithPageBound(n int) ListOption

WithPageBound asks for n items per page. A page-numbered continuation resumes only at the bound it was minted at, as WithAfter states, so such a walk wanting another bound starts again from the first page.

Default: DefaultPageBound. A zero or negative n is refused by ResolveList with CodePageBoundInvalid rather than sent.

func WithSince added in v1.2.0

func WithSince(t time.Time) ListOption

WithSince bounds Checks.ListRuns to the runs created at or after t, read at whole seconds: GitHub, Gitea and Forgejo stamp a run's creation at whole seconds, so a run created in t's own second is answered. Every other list refuses it before any request with CodeListSinceInvalid, as ResolveList does the zero time. A continuation names the bound it was minted under, so a walk resumes only under it.

Default: no bound, every run of the repository.

func WithState

func WithState(s ListState) ListOption

WithState filters a listing by state. Only PullRequests.ListPRs and Issues.ListIssues take it: every other list refuses it, even at ListStateOpen, with CodeListStateInvalid, and so does a listing of issues asked for ListStateMerged. PullRequests.ListMyPRs and Issues.ListMyIssues list open items by construction, and Checks.ListRuns has no state to filter.

Default: ListStateOpen. ListStateUnknown is refused by ResolveList with CodeListStateInvalid rather than sent.

type ListSettings

type ListSettings struct {
	// After is the continuation to resume from, empty for a first page.
	After Cursor
	// Owner is the owner scope [WithOwner] named, empty where it named none.
	Owner string
	// PageBound is the page size to ask for.
	PageBound int
	// State is the state filter, [ListStateOpen] where the caller named none.
	State ListState
	// StateSet reports that the caller named the filter, whatever it named:
	// [WithState] sets it even for the default value. A family sees only this
	// struct, since [ListOption]'s mutator is unexported, and without this field
	// ResolveList() and ResolveList(WithState(ListStateOpen)) would resolve the
	// same. The stateful lists read State and ignore this; every other list
	// refuses whenever it is true.
	StateSet bool
	// OwnerSet reports that the caller named an owner scope, whatever it named,
	// for the reason StateSet exists: an empty owner is refused, and without
	// this field it resolves to the same value as no owner at all. The lists an
	// owner scopes read Owner where it is true; every other list refuses
	// whenever it is true.
	OwnerSet bool
	// Since is the creation bound [WithSince] named, in UTC at whole seconds with no
	// monotonic reading, the zero time where it named none.
	Since time.Time
	// SinceSet reports that the caller named a creation bound, for the reason
	// OwnerSet exists. The run listing reads Since where it is true; every other
	// list refuses whenever it is true.
	SinceSet bool
}

ListSettings is the resolved per-call option state for a list operation.

func ResolveList

func ResolveList(opts ...ListOption) (ListSettings, error)

ResolveList applies defaults and then every list option in order, and refuses rather than clamps, with a *Error: CodePageBoundInvalid for a page bound that is not positive, CodeListStateInvalid for ListStateUnknown, CodeListOwnerInvalid for an owner outside the form WithOwner states, CodeListSinceInvalid for the zero time, and CodeCursorInvalid for a malformed WithAfter cursor. It is not told which list it resolves for, so it records a named filter, owner or bound in the matching Set field, and the family method refuses one its list cannot take, under that option's code.

type ListState

type ListState int

ListState filters a pull-request or issue listing.

const (
	// ListStateUnknown is the zero value and is refused at the call site
	// rather than sent, so a filter nobody chose cannot become a listing
	// nobody expected.
	ListStateUnknown ListState = iota
	// ListStateOpen lists open items. It is what a list defaults to when no
	// state option is given.
	ListStateOpen
	// ListStateClosed lists closed items.
	ListStateClosed
	// ListStateMerged lists merged pull requests. It is not a valid issue
	// filter and a family package refuses it there, and it is meaningless on
	// the lists [WithState] does not reach at all.
	ListStateMerged
	// ListStateAll lists every state.
	ListStateAll
)

The ListState members.

func (ListState) String

func (l ListState) String() string

String returns the member's spelling: "unknown", "open", "closed", "merged" or "all".

type MergeBlockReason

type MergeBlockReason int

MergeBlockReason is why a forge refuses to merge one pull request.

It is not a Support: a three-valued type cannot tell "behind" from "conflicts" from "blocked", and that reason is what a Merge control renders.

const (
	// MergeBlockUnknown is the zero value: the cause could not be read. It is
	// what a degraded read reports rather than claiming the merge is clear.
	MergeBlockUnknown MergeBlockReason = iota
	// MergeBlockNone means nothing blocks this merge.
	MergeBlockNone
	// MergeBlockDraft means the pull request is still a draft.
	MergeBlockDraft
	// MergeBlockConflicts means the branches conflict.
	MergeBlockConflicts
	// MergeBlockChecksFailing means a required check failed.
	MergeBlockChecksFailing
	// MergeBlockChecksRunning means a required check has not finished.
	MergeBlockChecksRunning
	// MergeBlockBehind means the branch is behind its target and the
	// repository requires it to be current.
	MergeBlockBehind
	// MergeBlockBlocked means a rule other than the above refuses the merge, a
	// missing review among them.
	MergeBlockBlocked
)

The MergeBlockReason members: seven refusal causes plus MergeBlockNone.

func (MergeBlockReason) String

func (m MergeBlockReason) String() string

String returns the member's wire spelling: "unknown", "none", "draft", "conflicts", "checks_failing", "checks_running", "behind" or "blocked".

Seven of the eight are the spellings a consumer's current wire already carries; "none" replaces an empty string, which is a consumer-visible change because a consumer that fails closed on an unrecognised cause would otherwise disable Merge on every mergeable pull request.

type MergeIntent

type MergeIntent int

MergeIntent is what the caller wants the merge to do to the history.

It is the one enumeration here whose zero value is not an unknown member: IntentDefault at zero is genuinely the correct default rather than an absence, because every family has a project-level default and "do what this repository does" is a real request.

const (
	// IntentDefault takes the repository's own configured behaviour.
	IntentDefault MergeIntent = iota
	// IntentSquash asks for one commit.
	IntentSquash
	// IntentNoSquash asks for the branch's commits to survive.
	IntentNoSquash
)

The MergeIntent members. Default, squash and no-squash is what three forges commonly express; a family spelling beyond that travels in MergeRequest.Strategy.

func (MergeIntent) String

func (m MergeIntent) String() string

String returns the member's spelling: "default", "squash" or "no_squash".

type MergeOutcome

type MergeOutcome struct {
	// State is the outcome.
	State MergeOutcomeState
	// Code is a machine-readable discriminator from the same namespace as
	// [Error.Code]. It is carried on the two success states that give the
	// caller no verdict yet, [MergeOutcomeAccepted] and [MergeOutcomeInFlight],
	// both as [CodeAlreadyEnqueued], and it is empty on the three states whose
	// own name is the whole answer.
	Code string
	// QueueState is the queue's verdict where the merge entered one. It is
	// spelled as [ActionState.QueueState] is, because it is the same
	// enumeration answering the same question and one concept carries one name.
	QueueState QueueState
	// QueuePosition is the position in that queue, or [QueuePositionUnknown]
	// when none was read. A position is not fetched inside a mutation: it
	// arrives with the next list read.
	QueuePosition int
}

MergeOutcome is what a merge answers. A merge never reports a bare error: one product accepts a merge and runs it in the background, and another enqueues it on a queue-protected branch, so a merge has an outcome and not merely a failure.

Nothing a product mints to track a merge rides here. The one family whose merge is asynchronous receives a handle for it and keeps that handle to itself, per connection and in memory; the read that follows any outcome is Merges.MergeStatus, addressed by the same PRRef the merge was, so a caller written against one family holds nothing another family never mints.

Nor does the pull request itself. A merge answers what happened to the merge, and whether a consumer patches a row from that or reads the pull request back is the consumer's own cache policy, which this library does not own and has no opinion about. The read that answers what the pull request now looks like is Merges.MergeStatus or PullRequests.ReadPR.

type MergeOutcomeState

type MergeOutcomeState int

MergeOutcomeState is what actually happened to a merge request.

const (
	// MergeOutcomeUnknown is the zero value: no outcome was recorded.
	MergeOutcomeUnknown MergeOutcomeState = iota
	// MergeOutcomeMerged means the merge completed.
	MergeOutcomeMerged
	// MergeOutcomeEnqueued means the pull request entered a merge queue or
	// train, or was armed to merge itself once its requirements are met
	// ([MergeRequest.AutoMerge]). This is a SUCCESS: reporting it as a refusal
	// would label a perfectly mergeable pull request unmergeable, which is what
	// the bare status mapping does on a queue-protected branch.
	MergeOutcomeEnqueued
	// MergeOutcomeAccepted means the merge was accepted and is running in the
	// background. It carries [CodeAlreadyEnqueued] like MergeOutcomeInFlight
	// does: both leave the caller without a verdict, which is the one thing a
	// consumer branches on, and the read that follows either is
	// [Merges.MergeStatus] on the same pull request.
	MergeOutcomeAccepted
	// MergeOutcomeInFlight means an equivalent merge was already in flight
	// under a request this caller did not issue. Its requested options may
	// differ from ours, which is why it carries [CodeAlreadyEnqueued] rather
	// than being reported as a conflict.
	MergeOutcomeInFlight
	// MergeOutcomeRefused means the forge refused, with the reason in the
	// accompanying error's Kind and Code.
	MergeOutcomeRefused
)

The MergeOutcomeState members: five outcomes, because a merge on a queue-protected branch is neither a merge nor a refusal, and two answers give the caller no verdict yet.

func (MergeOutcomeState) String

func (m MergeOutcomeState) String() string

String returns the member's spelling: "unknown", "merged", "enqueued", "accepted", "in_flight" or "refused".

type MergeRequest

type MergeRequest struct {
	// Intent is the cross-forge intent.
	Intent MergeIntent
	// Strategy is an OPTIONAL family spelling, and it is refused with
	// [CodeStrategyNotAllowed] before any request unless it is in that family's
	// own closed set of spellings. The check is local and reads no repository,
	// because an affordances read inside a merge is a request the budget does
	// not price, so a strategy this repository has DISABLED costs a round trip
	// and comes back as the forge's own refusal, [CodeNotMergeable], because a
	// merge route answers that and a pull request not yet mergeable under one
	// status. It stays a string because the validation is the point
	// rather than the type: it keeps a fast-forward-only or a rebase reachable
	// instead of folded into an approximation. One product can silently ignore a
	// value it does not support, which the family package reports rather than
	// swallows.
	Strategy string
	// HeadSHA is required, and mandatory rather than prudent: one product has
	// a group and instance setting that makes it compulsory and answers 400
	// without it. It is also the precondition that makes a merge safe, since a
	// push landing between the read and the click must fail the merge rather
	// than land unreviewed code.
	//
	// An empty HeadSHA is therefore REFUSED with [CodeMissingSHA] before any
	// request, on every family and not only the one that answers 400 for it.
	// The requirement is stated as a refusal because the alternative is the
	// field's own reason failing open: on the two families that do not demand
	// it, a merge sent without it merges whatever the head is now, which is the
	// outcome the pin exists to prevent. It is not
	// [PullRequests.RerunFailedChecks]'s empty case, where an absent head is a
	// pin the forge could not supply rather than one the caller omitted.
	HeadSHA string
	// DeleteBranch asks the forge to delete the source branch after merging.
	DeleteBranch bool
	// AutoMerge asks the forge to complete the merge itself once its
	// requirements are met, using each product's current field for that:
	// auto_merge on GitLab (never the merge_when_pipeline_succeeds it
	// deprecated), merge_when_checks_succeed on Gitea and Forgejo, and the
	// auto-merge arm on GitHub, which takes only a pull request that cannot
	// merge now, so GitHub reads the pull request first and merges one whose
	// requirements are already met. An armed merge is [MergeOutcomeEnqueued].
	AutoMerge bool
}

MergeRequest is one merge attempt.

type MergeStatus

type MergeStatus struct {
	// Merged reports whether the pull request is merged. It is a [Support]
	// rather than a bool because an answer the credential could not read is
	// unknown, not a wrong "not merged".
	Merged Support
	// Queue is the merge queue's or merge train's verdict where the product
	// runs one, and [QueueNone] where the repository merges without one. It is
	// spelled as [ActionState.QueueState] is, so a consumer reads one
	// vocabulary from a list row and from this single read alike.
	Queue QueueState
	// WebURL is the pull request's own page on the instance, spelled as
	// [PullRequest.WebURL] is, and it is what this read hands its caller INSTEAD
	// of detail it deliberately does not fetch.
	//
	// This operation costs exactly one request and returns no checks, so the two
	// things a caller might want next are named rather than fetched: this field
	// for a person who wants to look at the pull request, and
	// [PullRequests.ReadPR] on the same [PRRef] for a caller that wants the
	// folded check list, which is the operation that pays for those pages. Every
	// product's pull-request object carries the field, so it costs no request of
	// its own.
	WebURL string
	// Successor is non-nil when the repository this read addressed had moved and
	// the read followed the redirect to it, on the same terms as
	// [Page.Successor] and [CommitChecks.Successor].
	Successor *RepoRef
}

MergeStatus is one pull request's merge state, as Merges.MergeStatus reads it back: the read that follows a MergeOutcomeAccepted or MergeOutcomeInFlight outcome, and the one answer to "is this merged" every family gives in the same two fields.

The two answer fields are this package's own types, so a caller written against one family runs unchanged against all of them: a product that cannot supply a field answers its unknown or none member in that field rather than omitting it, retyping it or moving it to a variant of its own. Beside them ride WebURL, which is where this read points a caller who wants more than it fetches, and the rename mark every response-level carrier has, because this read addresses a repository and its return rides no list item.

type Merges

type Merges interface {
	// MergePR merges, enqueues, or accepts a background merge, and reports
	// which in the outcome. It never answers a bare error: a refusal is an
	// error beside [MergeOutcomeRefused], and everything else is a success
	// state that may carry a queue verdict.
	//
	// It does not poll. A queued merge routinely outlives the per-operation
	// deadline, so a library that polled inside the mutation would have no
	// bound left to publish; what follows an outcome without a verdict is
	// MergeStatus on the same pull request, at whatever cadence the caller
	// chooses.
	//
	// Per forge:
	//
	//	GitHub   PUT /repos/{owner}/{repo}/pulls/{number}/merge-async, this product's asynchronous merge, whose accept carries a handle and whose outcome Merges.MergeStatus reads from the pull request, then one read against the id-addressed location to resolve the successor where it answered from another address; a merge asked to wait first reads GET /repos/{owner}/{repo}/pulls/{number}, and a pull request that cannot merge now is armed by the EnableAutoMerge document in place of the merge, 1 to 3 requests
	//	GitLab   PUT /api/v4/projects/{id}/merge_requests/{iid}/merge, with the head commit required, 1 request
	//	Gitea    POST /api/v1/repos/{owner}/{repo}/pulls/{number}/merge, and for a merge asked to wait whose form the route answers 405, the same form again carrying merge_when_checks_succeed, 1 to 2 requests
	//	Forgejo  POST /api/v1/repos/{owner}/{repo}/pulls/{number}/merge, and for a merge asked to wait whose form the route answers 405, the same form again carrying merge_when_checks_succeed, 1 to 2 requests
	//
	// GitHub departs from the normalized contract:
	//
	//	QueueState  cannot supply  no queue key on the 202; MergeStatus reads it
	//
	// GitLab departs from the normalized contract:
	//
	//	State  cannot supply  Merged, Enqueued or Refused
	//	Code   cannot supply  empty: no such state
	//
	// Gitea and Forgejo depart from the normalized contract:
	//
	//	State       cannot supply  Merged, Enqueued or Refused: a merge asked to wait sends the merge form first and, on its 405, the form with merge_when_checks_succeed, whose 201 is the scheduled merge
	//	Code        cannot supply  empty: no such state
	//	QueueState  cannot supply  QueueNone always
	//
	// GitHub sends this, where a wrong default would change the request silently:
	//
	//	merge_method            body  the strategy this product's own route takes, in its own spelling, and merge where the caller named none
	//	sha                     body  the head the caller pinned, under the key this product's asynchronous merge route reads, so a merge cannot land on a head that moved under the row the caller was looking at: a pin naming another commit is refused before anything merges, and a push between the request and the merge cancels it, as the route documents
	//	merge_action            body  absent: a merge this product accepts completes in the background on its own, so an action that deferred it would answer a merge nobody asked to wait for
	//	delete_branch_on_merge  body  absent: neither this product's merge route nor its auto-merge arm takes a branch deletion, so the repository's own delete_branch_on_merge setting decides whether the head branch goes, whatever the caller asked
	//
	// GitLab sends this, where a wrong default would change the request silently:
	//
	//	sha         body  the head the caller pinned, which a group or instance setting on this product can make compulsory and which is what makes the merge refuse a branch that moved
	//	squash      body  false unless the caller asked to squash: this product exposes no strategy selector, so a squash flag is what a merge intent reaches here
	//	auto_merge  body  absent unless the caller asked for it: sent by default it arms a merge that completes later and unattended on a pull request the user asked to merge now
	//
	// Gitea sends this, where a wrong default would change the request silently:
	//
	//	do                         body  the strategy this product takes, in its own spelling, and merge where the caller named none
	//	head_commit_id             body  the head the caller pinned
	//	merge_when_checks_succeed  body  absent unless the caller asked for it and the flagless form answered 405: sent, the instance schedules the merge whatever the checks, so sent by default it would answer a merge nobody asked to wait for
	//
	// Forgejo sends this, where a wrong default would change the request silently:
	//
	//	Do                         body  the strategy this product takes, in its own spelling, and merge where the caller named none
	//	head_commit_id             body  the head the caller pinned
	//	merge_when_checks_succeed  body  absent unless the caller asked for it and the flagless form answered 405: sent, the instance schedules the merge whatever the checks, so sent by default it would answer a merge nobody asked to wait for
	MergePR(ctx context.Context, repo RepoRef, pr PRRef, req MergeRequest) (MergeOutcome, error)

	// MergeStatus reads one pull request's merge state: whether it is merged,
	// and the queue's verdict where the repository merges through one.
	//
	// It is ONE cross-forge entry point keyed by the pull request, on every
	// family, because every product can answer whether a pull request is
	// merged and what differs is only how much each can add. It is addressed
	// exactly as MergePR and [PullRequests.ReadPR] are, by repo and pr, and it
	// carries no product token: the handle one family's asynchronous merge
	// mints stays inside that family, so a caller never holds something three
	// of the four products never issue.
	//
	// It is a READ, budgeted as one and priced per CALL, so the inter-mutation
	// interval does not apply to it. It is ONE request on every product, since one
	// response carries merged and queue state everywhere. On the one product whose
	// documents an instance can refuse at runtime that figure holds on both of its
	// arms, the document and the REST record: whether this connection's documents
	// are accepted is settled at connection setup, on the row that pays for it, so
	// no call here discovers it and none spends two where this one publishes one.
	// It is the follow-up read after a merge or a UI action's, never a
	// poller's, whose lists already carry the same queue verdict on every row.
	//
	// It follows no pages, and what it hands a caller instead is a POINTER. This
	// read returns whether the pull request is merged, the queue's verdict and
	// WebURL, and no field a page of checks could fill, so the complete fold
	// [PullRequests.ReadPR] performs had nothing here to fold and the one-to-three
	// requests this operation was priced at bought nothing. One response carries
	// merged and queue state on every product. A caller that wants the check list
	// behind the verdict calls [PullRequests.ReadPR] on the same [PRRef], which is
	// the operation that pays for those pages, and WebURL is the pull request's own
	// page for a person who wants to look rather than fetch.
	//
	// Successor is the rename mark every response-level carrier has, on the
	// same terms as [CommitChecks.Successor]: this read addresses a repository
	// and its return rides no list item, so the mark travels here rather than
	// only on the failure arm.
	//
	// Per forge:
	//
	//	GitHub   the PRRead document, or GET /repos/{owner}/{repo}/pulls/{number} where this connection has already discovered the documents refused, then one read against the id-addressed location to resolve the successor where that record answered from another address, 1 to 3 requests
	//	GitLab   the PRRead document, or GET /api/v4/projects/{id}/merge_requests/{iid} where this connection's setup found the documents refused, 1 request; needs read_merge_state
	//	Gitea    GET /api/v1/repos/{owner}/{repo}/pulls/{number}, and where the repository moved, the one 301 its old path answers, whose Location names the successor, every later request of the call addressing that successor, 1 to 2 requests
	//	Forgejo  GET /api/v1/repos/{owner}/{repo}/pulls/{number}, and where the repository moved, the one 301 its old path answers, whose Location names the successor, every later request of the call addressing that successor, 1 to 2 requests
	//
	// GitLab departs from the normalized contract:
	//
	//	Merged     differs in shape  state and mergedAt, no merged field
	//	Queue      cannot supply     QueueNone always: every field that would name a train carries a deprecation reason, so no document can select one
	//	Successor  cannot supply     nil: GraphQL sends no successor
	//
	// Gitea and Forgejo depart from the normalized contract:
	//
	//	Queue  cannot supply  QueueNone always
	MergeStatus(ctx context.Context, repo RepoRef, pr PRRef) (MergeStatus, error)
}

Merges is the merge surface: issuing a merge, and reading its state back. It is its own role because a merge is the one operation with an outcome rather than a result, and the read exists because that outcome can be "no verdict yet".

type NewIssue

type NewIssue struct {
	// Title is required.
	Title string
	// Body is the description.
	Body string
	// Labels are labels to apply.
	Labels []string
}

NewIssue describes an issue to file.

type NewPullRequest

type NewPullRequest struct {
	// Title is required.
	Title string
	// Body is the description.
	Body string
	// SourceBranch is the branch to merge.
	SourceBranch string
	// TargetBranch is the branch to merge into.
	TargetBranch string
	// Labels are labels to apply, refused on a family with no pull-request
	// labels rather than dropped.
	Labels []string
	// Draft opens it as a draft.
	Draft bool
}

NewPullRequest describes a pull request to open.

type NewRelease

type NewRelease struct {
	// TagName is required.
	TagName string
	// Name is the title.
	Name string
	// Body is the notes.
	Body string
	// Target is the commit SHA or branch the tag is created at, where the tag
	// does not exist yet.
	Target string
	// Draft creates it unpublished.
	Draft bool
	// Prerelease marks it a prerelease.
	Prerelease bool
}

NewRelease describes a release to cut.

type Option

type Option interface {
	// contains filtered or unexported methods
}

Option configures a client. Every option takes a parameter and none signals by presence, so a caller composing options at runtime never has to rebuild an argument list; where two options set the same thing, the last one wins.

The interface cannot be implemented outside this package, deliberately: an interface a consumer implements is the most frozen thing in a v1, and nothing here needs a consumer-supplied option.

func WithAttemptObserver

func WithAttemptObserver(wrap func(next http.RoundTripper) http.RoundTripper) Option

WithAttemptObserver installs a hook inside the retry layer, called once per attempt, so a retried request shows as more than one call. Its shape and containment are WithRequestObserver's: the hook must pass the request on, and a second WithAttemptObserver replaces the first.

Default: none.

func WithCounters

func WithCounters(c Counters) Option

WithCounters installs the counter seam. See Counters.

Default: no counters, so every event is logged and none is counted.

func WithCredentialSource

func WithCredentialSource(src CredentialSource) Option

WithCredentialSource sets the token source every request on this connection authenticates with. See CredentialSource.

Default: none, which a family's constructor refuses rather than sending anonymous requests: a client that silently reads only public data would look like a permission problem on the instance.

func WithListPages

func WithListPages(n int) Option

WithListPages sets how many pages one list operation traverses before reporting PartialPaginationCap. Zero traverses none.

Default: DefaultListPages.

func WithLogger

func WithLogger(l *slog.Logger) Option

WithLogger routes the library's own lines, the transport's address and port refusals included. The library retries, refreshes credentials and follows redirects, so it emits as part of its job rather than returning diagnostics and staying silent.

Default: slog.Default.

func WithMutationConcurrency

func WithMutationConcurrency(n int) Option

WithMutationConcurrency sets the concurrent-mutation limit per instance. Zero admits no mutation at all, so it is REFUSED by a family's constructor with CodeBudgetInvalid rather than installed. A caller that wants a read-only client asks for one with WithMutations(false), which refuses a mutation at the call with its own code and leaves every read working.

Default: DefaultMutationConcurrency.

func WithMutationInterval

func WithMutationInterval(d time.Duration) Option

WithMutationInterval sets the minimum gap between consecutive mutations on one instance. Zero leaves no gap, which is what a secondary rate limit answers a burst for.

Default: DefaultMutationInterval.

func WithMutationReserve

func WithMutationReserve(n int) Option

WithMutationReserve sets the budget the governor holds back from reads, in the instance's own units: a read taking BudgetState.Remaining below n is deferred, marked PartialRateLimited and counted by Counters.ReadDeferred, and no mutation is held back. Remaining is the forge's own figure, the whole account's on GitHub and GitLab. Gitea sends none, so the reserve is inert there.

Default: DefaultMutationReserve. Zero holds nothing back, and a negative n is refused by a family's constructor with CodeBudgetInvalid.

func WithMutations

func WithMutations(allowed bool) Option

WithMutations permits or refuses every mutating operation on this client. WithMutations(false) is enforced at the operation boundary with CodeMutationsDisabled on CreatePR, ClosePR, ReopenPR, MergePR, CreateIssue, CloseIssue, CreateRelease and RerunFailedChecks, which mutates no forge object but can carry deployment side effects. MergeStatus and ListRuns are reads.

Default: true. An embedding that never sets it holds a client that can merge and close pull requests; read-only is the deliberate posture.

func WithOperationTimeout

func WithOperationTimeout(d time.Duration) Option

WithOperationTimeout bounds one operation. It is applied as a context deadline, which is the only bound that can interrupt an attempt already in flight. Zero is a deadline that has already passed.

Default: DefaultOperationTimeout.

func WithPlaintextHTTP

func WithPlaintextHTTP(allowed bool) Option

WithPlaintextHTTP permits this connection to speak http rather than https. The token and every request then travel in cleartext on that network, a consequence the consumer names to the user. A redirect from https down to http stays refused whatever this is set to.

Default: false.

func WithPrivateAddresses

func WithPrivateAddresses(allowed bool) Option

WithPrivateAddresses permits this connection to reach a private-range address or a single-label host, the ordinary case for a self-hosted forge. It contains this library's own credentialed requests, not a process that can open a socket for itself, and the per-hop redirect revalidation runs regardless.

Default: false. Reaching a private host is one deliberate statement per instance, so an agent driven by untrusted content cannot open one unasked.

func WithReadConcurrency

func WithReadConcurrency(n int) Option

WithReadConcurrency sets the concurrent-read limit per instance. Zero admits no read at all, so it is REFUSED by a family's constructor with CodeBudgetInvalid rather than installed: a client that can issue no read is a client with no working operation on it.

Default: DefaultReadConcurrency.

func WithRefreshLead

func WithRefreshLead(d time.Duration) Option

WithRefreshLead sets how long before its expiry a rotating credential is due for refresh. The creds package's source reads it and clamps it to half the lifetime the credential was issued with, so a short lifetime cannot make every fresh token due on arrival.

Default: DefaultRefreshLead. Zero refreshes a token only once it has expired. A negative d is refused by that source's constructor with CodeBudgetInvalid, because a lead of negative size hands over expired tokens.

func WithRequestObserver

func WithRequestObserver(wrap func(next http.RoundTripper) http.RoundTripper) Option

WithRequestObserver installs a hook outside the retry layer, called once per logical request whatever the attempt count: the position for per-request accounting. The hook takes the next round tripper, returns one, and must pass the request on. A second WithRequestObserver replaces the first, so two hooks here compose in one wrap function. Both observer positions sit above the transport WithWireTransport replaces, so containment is unaffected.

Default: none.

func WithRetries

func WithRetries(n int) Option

WithRetries sets the per-request retry cap. Zero means no retries, which is a posture rather than an absence: a caller that wants one attempt asks for zero retries, and does not express it with a negative. A published price counts each request once; every attempt is sent, so a retried request adds up to n to the call's BudgetState.LastCost.

Default: DefaultRetries.

func WithRotationCursor

func WithRotationCursor(c RotationCursor) Option

WithRotationCursor hands the library the RotationCursor a previous client reported through BudgetState.RotationCursor, so this client resumes the rotation where that one stopped. It is checked as ValidateCursor checks a page cursor, never parsed, and a family's constructor refuses a malformed one with CodeCursorInvalid: something the consumer stored is corrupt.

Default: the empty cursor, a first run. A family with nothing to rotate always reports it, so replaying the stored value needs no per-family branch.

func WithStatusPages

func WithStatusPages(n int) Option

WithStatusPages sets how many pages one folded commit status traverses. It is a bound of its own rather than the list bound, for the reason Budget.StatusPages states, and it is what bounds the complete fold on PullRequests.ReadPR. Zero traverses none.

Default: DefaultStatusPages.

func WithStatusReadsPerInterval

func WithStatusReadsPerInterval(n int) Option

WithStatusReadsPerInterval sets the folded-status read cap per rolling interval, on the family whose folded status costs one request per pull request. Zero issues none, which leaves every such row partial with PartialBudget. The rotation is fair within one list, not across lists: a caller sweeping repositories through PullRequests.ListPRs gives the first lists the folds, and PullRequests.ListMyPRs spends none of the cap.

Default: DefaultStatusReadsPerInterval.

func WithStatusTimePerInterval

func WithStatusTimePerInterval(d time.Duration) Option

WithStatusTimePerInterval sets the wall clock one rolling interval of folded-status work is allowed. Zero allows none.

Default: DefaultStatusTimePerInterval.

func WithWireTransport

func WithWireTransport(rt http.RoundTripper) Option

WithWireTransport replaces the wire under this client's own HTTP stack, which is what a test substitutes to answer requests without a network. It bypasses containment: the address policy, the port allowlist, the trust anchor, the redirect policy and the connection's own proxy live in the transport it replaces, so a substitute enforces exactly what its author wrote. A client's Close does not reach it: a substitute's pool is its author's to release.

Default: none, and the library builds its own transport. It never falls back to http.DefaultClient, which has no timeout and no redirect policy of ours.

type PRRef

type PRRef struct {
	// Number is the number the family's own API takes. On one family that is
	// the request's per-project number rather than a global identifier.
	Number int
	// Sigil is how a human writes that number, "#" on two families and "!" on
	// one. It is a rendering concern and never enters a request path.
	Sigil string
}

PRRef addresses one pull request within a repository.

type PRState

type PRState int

PRState is the state of one pull request.

const (
	// PRStateUnknown is the zero value: the state did not map, which is what an
	// omitted wire field on an old instance must reach a consumer as.
	PRStateUnknown PRState = iota
	// PRStateOpen is open, draft included.
	PRStateOpen
	// PRStateClosed is closed without merging.
	PRStateClosed
	// PRStateMerged is merged.
	PRStateMerged
)

The PRState members.

func (PRState) String

func (p PRState) String() string

String returns the member's spelling: "unknown", "open", "closed" or "merged".

type Page

type Page[T any] struct {
	// Items are the rows read, in the order upstream returned them.
	Items []T
	// Next is the continuation, empty when the list is complete.
	Next Cursor
	// Partial is nil when the page is everything that was asked for.
	Partial *Partial
	// Successor addresses the repository the one this list addressed had moved
	// to, where the read followed the redirect. The rows are the successor's
	// and they are
	// returned rather than refused, because following is right and following
	// SILENTLY is not: a consumer re-points its stored identifier to this
	// reference. It is nil on every ordinary read, and nil too where the move
	// was detected but the successor could not be resolved, which reaches the
	// caller as [CodeRepoRefStale] instead.
	//
	// It is spelled as [Error.Successor] is, because it is the same value
	// answering the same question on the success arm: one concept carries one
	// name, and nil is the one absence test on every carrier.
	Successor *RepoRef
}

Page is one page of a list operation, with the continuation and the partiality beside the items.

No list operation returns a bare slice. A caller told "there is a remainder" with no way to ask for it has only one recourse, which is to re-run the same capped call, and adding the continuation after the tag would mean a second role interface for an operation that already has one.

type Partial

type Partial struct {
	// Reason is why the result stopped short.
	Reason PartialReason
	// Fetched is how many items were actually read. On [PartialResultWindow]
	// it counts the whole walk rather than one page.
	Fetched int
	// OmittedAtLeast is a lower bound on what was left, because upstream
	// rarely says how much remains.
	OmittedAtLeast int
}

Partial describes an incomplete result. A complete result carries no Partial at all, which is why every field on it is meaningful only when it is present.

type PartialReason

type PartialReason int

PartialReason is why a result is incomplete.

const (
	// PartialUnknown is the zero value: a result marked partial for a reason
	// nothing recorded, which is a defect rather than an outcome.
	PartialUnknown PartialReason = iota
	// PartialBudget means a per-operation bound, or one of the two per-interval
	// bounds the client keeps over its own rolling window, stopped the work.
	PartialBudget
	// PartialRateLimited has two producers and one word, because the remedy at
	// a consumer is the same wait. The instance refused further requests, which
	// fails fast for that instance rather than consuming its retries; or the
	// connection's governor DEFERRED the read rather than spend the mutation
	// reserve [WithMutationReserve] holds back. A consumer that wants to tell
	// them apart reads [BudgetState], where a deferral leaves the remaining
	// budget above zero, and the two are counted apart by
	// [Counters.RateLimited] and [Counters.ReadDeferred].
	PartialRateLimited
	// PartialGraphQLPartial means a document answered with data AND errors, so
	// the data is returned beside this marker rather than silently truncated or
	// failed outright.
	PartialGraphQLPartial
	// PartialPaginationCap means a page bound was reached. On [Page.Partial] it is
	// the list's page cap and always arrives with a [Cursor]: ask for the next page.
	// On an item or a single read a collection inside it stopped short, with no
	// cursor: a list row's checks had a further page (read that pull request alone;
	// [ActionState.Checks] states the verdict beside it per product); a complete fold
	// on [CommitChecks.Partial] or a single read reached [Budget.StatusPages] (widen
	// it); or a GitLab label connection named a further page, [Partial.Fetched]
	// counting the labels held and [Partial.OmittedAtLeast] its count less them, at least one.
	PartialPaginationCap
	// PartialLabelsNotApplied means a creation made its object and the second
	// request that applies the labels the call named did not succeed, so the
	// object exists without them. It is set on the created object's own marker,
	// which arrives beside the error naming what was not done, because an error
	// alone would have the caller create a second object on the retry. Its
	// [Partial.Fetched] is zero and its [Partial.OmittedAtLeast] is the number
	// of labels the call named, since that request applies them all or none.
	// Only a product whose creation route declares no labels produces it.
	PartialLabelsNotApplied
	// PartialResultWindow means the route stopped serving rows while its own stated
	// total says more, and no continuation reaches them. It rides [Page.Partial] on
	// the page where the walk ends, the one response-level reason that arrives
	// WITHOUT a [Cursor]. [Partial.Fetched] counts the rows the whole walk answered,
	// and [Partial.OmittedAtLeast] is a lower bound on the stated rows it did not
	// answer. A consumer renders it as a list holding more rows than the route
	// serves, never as a complete one; docs/pagination.md names its lists.
	PartialResultWindow
	// PartialForbidden means the product refused part of an item to this
	// credential and answered the rest of it. It rides the item's own marker,
	// never a page's, so the list beside it is whole; the per-forge block of
	// each operation that produces it names the field. A consumer renders that
	// part as not readable with this credential, which a retry does not change.
	PartialForbidden
)

The PartialReason members.

func (PartialReason) String

func (p PartialReason) String() string

String returns the member's wire spelling: "unknown", "budget", "rate_limited", "graphql_partial", "pagination_cap", "labels_not_applied", "result_window" or "forbidden".

type Product added in v1.2.0

type Product int

Product is the forge product an instance runs.

const (
	// ProductUnknown is the zero value, which no successful detection answers.
	ProductUnknown Product = iota
	// ProductGitHub is github.com or GitHub Enterprise Server.
	ProductGitHub
	// ProductGitLab is gitlab.com or a self-managed GitLab.
	ProductGitLab
	// ProductGitea is Gitea.
	ProductGitea
	// ProductForgejo is Forgejo.
	ProductForgejo
)

The Product members.

func (Product) String added in v1.2.0

func (p Product) String() string

String returns the member's spelling: "unknown", "github", "gitlab", "gitea" or "forgejo".

type PullRequest

type PullRequest struct {
	// Ref addresses it, sigil included.
	Ref PRRef
	// Repo is the repository it belongs to, so a row carries its own
	// addressing rather than depending on the call it arrived from.
	Repo RepoRef
	// Title is its title.
	Title string
	// Body is its description.
	Body string
	// Author is the account that opened it.
	Author string
	// SourceBranch is the branch being merged.
	SourceBranch string
	// SourceRepo is the repository SourceBranch lives in: Repo for a pull
	// request opened inside its repository, the fork for one opened from a
	// fork. It is the zero RepoRef where the answer names no head repository
	// the credential can read: a deleted fork on every family, a fork the
	// credential cannot see and, where a product's table says so, a route whose
	// answer does not carry it; a consumer joining a local clone matches it
	// beside Repo.
	SourceRepo RepoRef
	// TargetBranch is the branch it merges into.
	TargetBranch string
	// WebURL is its page on the instance.
	WebURL string
	// HeadSHA is the head commit of the source branch: the value a merge and a
	// re-run pin themselves to.
	HeadSHA string
	// Labels are the labels on it, nil on a family that has none. Where the
	// product served part of the set, [PullRequest.Partial] says so.
	Labels []Label
	// CreatedAt is when it was opened, zero where the instance reported none.
	CreatedAt time.Time
	// UpdatedAt is its last update, zero where the instance reported none.
	UpdatedAt time.Time
	// Action is everything its own controls need, and it rides the item
	// because every field in it is a property of this pull request.
	Action ActionState
	// Partial is non-nil when something this pull request needed stopped short, and
	// nil on every complete row. It carries [PartialPaginationCap] where the check
	// fold or, on GitLab, the labels stopped short (that member names each path), and
	// beside it [ActionState.Checks] stays exact where the product counted the whole
	// collection, only the check names short, and is [CheckUnknown] where it did not;
	// [PartialBudget] where the per-interval folded-status cap left the fold unissued;
	// [PartialRateLimited] where a read the row needed was deferred or throttled, the
	// Gitea family's folded status or GitLab's fork lookup, [PullRequest.SourceRepo]
	// then the zero reference on GitLab;
	// [PartialLabelsNotApplied] on a creation's pull request where its labels were not
	// applied; [PartialForbidden] where the credential may not read the checks, then
	// [CheckUnknown] with no count; and on a single read [PartialGraphQLPartial].
	Partial *Partial
	// State is open, closed or merged.
	State PRState
	// Draft reports a draft pull request.
	Draft bool
}

PullRequest is one pull request, called a merge request on one family.

type PullRequests

type PullRequests interface {
	// ListPRs lists pull requests in repo, [ListStateOpen] unless
	// [WithState] says otherwise.
	//
	// Its fold is BOUNDED, which is the difference between a list and
	// [PullRequests.ReadPR]: the library takes the FIRST page of each row's
	// nested checks collection and issues no further request, because the remedy
	// is a read of that one pull request rather than another page of this list.
	// Two measured reasons, not one. A list carries N rows each with their own
	// collection, so following pages there is an extra fetch per row per call,
	// which is the fan-out the budget exists to make visible, and the bound is the
	// same rule on the list a poll actually runs, [PullRequests.ListMyPRs]. And
	// the size compounds: selecting a hundred check contexts
	// beside twenty pull requests moves one measured response from 38,737 bytes to
	// 533,301 before any further page.
	//
	// What the bound COSTS is per product, and on one of the four it costs
	// nothing. The Gitea family pays the rule as written: where a witness says
	// there is more, that row's [ActionState.Checks] is [CheckUnknown] and its
	// [PullRequest.Partial] carries [PartialPaginationCap], with no cursor. GitHub
	// pays only the check NAMES, because the connection its first page carries
	// reports per-state counts over the WHOLE collection, so the verdict and the
	// counts are exact on page 1. GitLab has no nested check collection to bound
	// at all, only one scalar head-pipeline status, so its fold cannot truncate
	// and the marker reaches its rows only for their labels
	// ([PartialPaginationCap]). Each product's own cell below states which of
	// the three it pays.
	//
	// A hundred-plus checks is rare across repositories and SYSTEMATIC within
	// one, a matrix of ten platforms by twelve versions putting a hundred and
	// twenty on every pull request, so the callers who meet this meet it on every
	// call: the marker is what tells them to read the row they care about rather
	// than believe a verdict folded over part of the evidence.
	//
	// This is the REPOSITORY VIEW's call, one repository at a time, and that is
	// what decides how the per-interval fold budget reaches its rows. The fairness
	// [WithStatusReadsPerInterval] publishes is per LIST: this list resumes where
	// it left off, and a caller sweeping many repositories through this call on one
	// connection gives the first lists the folds and the later ones none inside an
	// interval, in the order that caller chose. [PullRequests.ListMyPRs] is the
	// call whose rows span repositories, and it buys no fold at all on the family
	// whose rows would need one, so a consumer wanting check state per row across
	// repositories reads it through [PullRequests.ReadPR] or
	// [Checks.CommitStatus] on the rows it renders.
	//
	// Per forge:
	//
	//	GitHub   the PRList document, asking for at most 50 rows a page whatever the page bound asks, since a larger page of its rows answered 502 on every attempt on a large repository, 1 request per page
	//	GitLab   the PRList document, or GET /api/v4/projects/{id}/merge_requests where this connection's setup found the documents refused, 1 request per page; needs read_merge_state
	//	Gitea    GET /api/v1/repos/{owner}/{repo}/pulls, then each row's folded commit status, and where the repository moved, the one 301 its old path answers, whose Location names the successor, every later request of the call addressing that successor, 1 to 2 requests per page plus one per row
	//	Forgejo  GET /api/v1/repos/{owner}/{repo}/pulls, then each row's folded commit status, and where the repository moved, the one 301 its old path answers, whose Location names the successor, every later request of the call addressing that successor, 1 to 2 requests per page plus one per row
	//
	// GitHub departs from the normalized contract:
	//
	//	Items[].Action.Checks, under a fine-grained token  cannot supply  CheckUnknown with no count, beside forbidden on the row's own Partial and no page marker: a fine-grained token holds no Checks permission, and on a private repository the document answered every field of the row and FORBIDDEN on its rollup's contexts nodes, measured 2026-10-11 on the PRMine search, which selects the same rollup
	//
	// GitLab departs from the normalized contract:
	//
	//	Items[].Ref                                  differs in spelling  iid, sigil "!"
	//	Items[].Labels[].Name                        differs in spelling  labels.nodes[].title, not name
	//	Items[].Labels[].Color                       differs in spelling  color string, leading #
	//	Items[].Action.Checks                        differs in shape     headPipeline.status scalar, no fold
	//	Items[].Action.ChecksPassing .. ChecksTotal  cannot supply        absent: one scalar status
	//	Items[].Action.QueueState                    cannot supply        QueueNone always: every field that would name a train carries a deprecation reason, so no document can select one
	//	Items[].Action.QueuePosition                 cannot supply        no position; train index null
	//	Successor                                    cannot supply        nil: GraphQL sends no successor
	//
	// Gitea and Forgejo depart from the normalized contract:
	//
	//	Items[].Action.AutoMergeArmed  cannot supply  no auto_merge key: unknown
	//	Items[].Action.QueueState      cannot supply  QueueNone always
	//	Items[].Action.QueuePosition   cannot supply  QueuePositionUnknown: no queue
	//	Items[].Action.MergeBlocked    cannot supply  mergeable and draft, no reason
	ListPRs(ctx context.Context, repo RepoRef, opts ...ListOption) (Page[PullRequest], error)

	// ListMyPRs lists the credential's OPEN pull requests across every repository it
	// can reach or, under [WithOwner], every open pull request under that owner; an
	// owner the product reports unknown answers [CodeOwnerUnresolved], and [WithState]
	// is refused with [CodeListStateInvalid]. Each row carries its own
	// [PullRequest.Repo] and no affordance object, [Page.Successor] is never set, a
	// search's result window ends a walk with [PartialResultWindow], and archived
	// repositories' rows are listed unless the block below says otherwise, their close
	// and merge refused with [CodeRepoArchived]. docs/cross-repository-lists.md has more.
	//
	// Per forge:
	//
	//	GitHub   the PRMine search document, asking for at most 25 rows a page whatever the page bound asks, since a larger page of its rows answered 502 under both owners measured, the smaller of them holding fewer than a hundred open pull requests; under an owner scope the same document with user:{owner} in place of the author keyword, which resolves an organization as it resolves a user, 1 request per page
	//	GitLab   GET /api/v4/merge_requests?scope=created_by_me; under an owner scope GET /api/v4/groups/{owner}/merge_requests with the open state, which answers 404 for a user's own namespace; an owner named by digits alone, which every namespace route reads as a numeric id, is first resolved by name through the OwnerLookup document to the id the route takes, one request more on every page; on either route, GET /api/v4/projects/{id} once for each distinct fork a row's source_project_id names, 1 to 2 requests per page plus up to one per row
	//	Gitea    GET /api/v1/repos/issues/search filtered to pull requests and to the rows the authenticated credential created, whose rows carry no head commit and therefore no folded status; under an owner scope the same route filtered by owner={owner} in place of created=true, 1 request per page
	//	Forgejo  GET /api/v1/repos/issues/search filtered to pull requests and to the rows the authenticated credential created, whose rows carry no head commit and therefore no folded status; under an owner scope the same route filtered by owner={owner} in place of created=true, 1 request per page
	//
	// GitHub departs from the normalized contract:
	//
	//	Items[].Action.Checks, under a fine-grained token  cannot supply     CheckUnknown with no count, beside forbidden on the row's own Partial and no page marker: a fine-grained token holds no Checks permission, and on a private repository the document answered every field of the row and FORBIDDEN on its rollup's contexts nodes, measured 2026-10-11 on this document
	//	Next                                               differs in shape  a continuation while hasNextPage holds, as on a viewer's first page of 20 rows over issueCount 23; past the search connection's 1,000th result, where it answers hasNextPage false while issueCount states 2673, and a page asked for after it answers no rows and a null end cursor, so a walk ends there with no continuation and carries result_window
	//	error, owner unresolved                            cannot supply     empty: an owner the search index holds no account for answers issueCount 0 and no error, so no such owner reads as nothing open
	//
	// GitLab departs from the normalized contract:
	//
	//	Items[].Ref                                                differs in spelling  iid, sigil "!"
	//	Items[].SourceRepo                                         differs in shape     source_project_id beside target_project_id, a fork resolved by one GET /api/v4/projects/{id} per distinct fork on the page to its path_with_namespace, the zero value where the id is null on a deleted fork or the lookup answers 404 or 403
	//	Items[].Labels[].Color                                     differs in spelling  color string, leading #, on 2 of 2, upper-case hex on 0
	//	Items[].Action.Mergeable                                   cannot supply        no mergeable key on a list row, which states mergeability only through detailed_merge_status, mergeable 1, unchecked 2, and answers unchecked before the asynchronous check
	//	Items[].Action.Checks                                      cannot supply        CheckUnknown: no head_pipeline key on 3 of 3 list rows
	//	Items[].Action.ChecksPassing .. ChecksTotal                cannot supply        zero: no head_pipeline key on a list row, so no status to count
	//	Items[].Action.QueueState                                  cannot supply        QueueNone always: no merge-train key among the 51 keys a list row answers
	//	Items[].Action.QueuePosition                               cannot supply        QueuePositionUnknown: no merge-train key on a list row
	//	Items[].Action.MergeBlocked                                cannot supply        unknown on a list row, whose detailed_merge_status is the status the product stored when it last checked, which listing may leave unrefreshed: mergeable 1, unchecked 2 on the 3 open rows both scopes answered
	//	Items[].Partial                                            cannot supply        nil: no status read runs for a list row, which carries no head_pipeline
	//	error, owner unresolved                                    cannot supply        404 Group Not Found on a user's own namespace, an account the users route answers, exactly as on an owner no namespace holds
	//	Items[], from an archived repository under an owner scope  cannot supply        none: the group route's non_archived defaults to true
	//
	// Gitea and Forgejo depart from the normalized contract:
	//
	//	Items[].SourceBranch, Items[].SourceRepo     cannot supply     no head key on an issue-search row
	//	Items[].TargetBranch                         cannot supply     no base key on an issue-search row
	//	Items[].HeadSHA                              cannot supply     no head key on an issue-search row
	//	Items[].Action.Mergeable                     cannot supply     no mergeable key on an issue-search row
	//	Items[].Action.Checks                        cannot supply     CheckUnknown: no head commit on an issue-search row, so no status read addresses it
	//	Items[].Action.ChecksPassing .. ChecksTotal  cannot supply     zero: no fold runs on a row that carries no head commit
	//	Items[].Action.AutoMergeArmed                cannot supply     no auto-merge key on an issue-search row
	//	Items[].Action.QueueState                    cannot supply     QueueNone always
	//	Items[].Action.QueuePosition                 cannot supply     QueuePositionUnknown: no queue
	//	Items[].Action.MergeBlocked                  cannot supply     no merge-blocked key on an issue-search row
	//	Items[].Partial                              cannot supply     nil: no fold runs on a row that carries no head commit, so none can stop short
	//	Items[].Draft                                differs in shape  draft on the pull_request object, not at the row's top level
	ListMyPRs(ctx context.Context, opts ...ListOption) (Page[PullRequest], error)

	// ReadPR reads one pull request.
	//
	// It exists because the only alternative is listing: a list pays the cost
	// of the slowest merge-state field across every open pull request plus
	// pagination, which is the exact cost a read of one pull request avoids. Its
	// per-call request cost is published as a range and asserted, so a read that
	// reached for a list, issued a request to fill a field the document already
	// carries, or scaled with the number of open pull requests fails a test
	// rather than a budget.
	//
	// It is priced per CALL, because the ordinary caller is a UI action or the
	// follow-up read after a mutation rather than a poller: one to three requests
	// on GitHub, exactly one on GitLab and two to four on the Gitea family, and
	// none of it counted against the folded-status cap the client keeps per
	// rolling interval. A published price counts every request the call puts on
	// the wire, a refused one included, because a refused request spends the same
	// shared quota an answered one does. Which product's upper arm is a page of a
	// fold is not uniform: it is [Budget.StatusPages] on GitHub and on the Gitea
	// family, whose low ends are a single-page fold, and on GitLab there is no
	// upper arm at all, because the measurement found that this read returns one
	// scalar head-pipeline status there and no collection, and because whether
	// that connection's documents are accepted is settled and priced at
	// connection setup rather than by whichever call meets the answer first.
	//
	// That same degradation reaches GitHub, whose document an instance can refuse
	// too, and there it is priced INSIDE the range above rather than beside it:
	// the one call per connection that discovers the refusal spends the document
	// plus the single REST pull-request read that answers it, TWO requests, and no
	// page of a fold follows it, because REST carries no checks rollup to follow
	// and [ActionState.Checks] is therefore [CheckUnknown] for that connection
	// rather than folded over a second endpoint. Every later call on that
	// connection spends one.
	//
	// Its fold is COMPLETE, which is the difference between this read and a
	// list. The caller named one pull request and is owed a verdict, so where the
	// product serves a checks collection the library follows its pages until the
	// fold is total and [ActionState.Checks] is a real answer; the extra cost is
	// bounded by [Budget.StatusPages] and arises only on the repositories that
	// need it, a matrix of ten platforms by twelve versions being the shape that
	// does.
	// Past that page bound the row carries [PartialPaginationCap] with
	// [ActionState.Checks] at [CheckUnknown], which is the one case this read
	// answers unknown.
	//
	// Per forge:
	//
	//	GitHub   the PRRead document, then a further page of its contexts connection, or GET /repos/{owner}/{repo}/pulls/{number} where this connection has already discovered the documents refused, plus one read against the id-addressed location to resolve the successor where that record answered from another address, 1 to 3 requests
	//	GitLab   the PRRead document, or GET /api/v4/projects/{id}/merge_requests/{iid} where this connection's setup found the documents refused, 1 request; needs read_merge_state
	//	Gitea    GET /api/v1/repos/{owner}/{repo}/pulls/{number}, then the complete commit-status fold, and where the repository moved, the one 301 its first read answers, whose Location names the successor the stale refusal carries, 2 to 4 requests
	//	Forgejo  GET /api/v1/repos/{owner}/{repo}/pulls/{number}, then the complete commit-status fold, and where the repository moved, the one 301 its first read answers, whose Location names the successor the stale refusal carries, 2 to 4 requests
	//
	// GitHub departs from the normalized contract:
	//
	//	Action.Checks, under a fine-grained token  cannot supply  CheckUnknown with no count, beside forbidden on Partial: a fine-grained token holds no Checks permission, and the document refuses the rollup's contexts nodes on a private repository with FORBIDDEN, measured on the PRMine search, which selects the same rollup
	//
	// GitLab departs from the normalized contract:
	//
	//	Ref                                  differs in spelling  iid, sigil "!"
	//	Labels[].Name                        differs in spelling  labels.nodes[].title, not name
	//	Labels[].Color                       differs in spelling  color string, leading #
	//	Action.Checks                        differs in shape     headPipeline.status scalar, no fold
	//	Action.ChecksPassing .. ChecksTotal  cannot supply        absent: one scalar status
	//	Action.QueueState                    cannot supply        QueueNone always: every field that would name a train carries a deprecation reason, so no document can select one
	//	Action.QueuePosition                 cannot supply        no position; train index null
	//
	// Gitea and Forgejo depart from the normalized contract:
	//
	//	Action.AutoMergeArmed  cannot supply  no auto_merge key: unknown
	//	Action.QueueState      cannot supply  QueueNone always
	//	Action.QueuePosition   cannot supply  QueuePositionUnknown: no queue
	//	Action.MergeBlocked    cannot supply  mergeable and draft, no reason
	ReadPR(ctx context.Context, repo RepoRef, pr PRRef) (PullRequest, error)

	// CreatePR opens a pull request and returns the created object.
	//
	// Per forge:
	//
	//	GitHub   POST /repos/{owner}/{repo}/pulls, then POST /repos/{owner}/{repo}/issues/{number}/labels where the caller names labels, which is the route this product declares for them because its creation takes none, plus one read against the id-addressed location to resolve the successor where either of them answered from another address, 1 to 3 requests
	//	GitLab   POST /api/v4/projects/{id}/merge_requests, 1 request
	//	Gitea    GET /api/v1/repos/{owner}/{repo}/labels where the caller names labels, whose ids this product's option takes in place of their names, then POST /api/v1/repos/{owner}/{repo}/pulls, 1 to 2 requests
	//	Forgejo  GET /api/v1/repos/{owner}/{repo}/labels where the caller names labels, whose ids this product's option takes in place of their names, then POST /api/v1/repos/{owner}/{repo}/pulls, 1 to 2 requests
	//
	// GitHub departs from the normalized contract:
	//
	//	Labels[].Name, Labels[].Color, Labels[].Description  cannot supply  no label row on a create
	//	Action.Mergeable                                     cannot supply  unknown on a mutation's own answer, whose mergeable is the asynchronous check's state as the creation lands, null on every creation measured
	//	Action.QueueState                                    cannot supply  no merge-queue key among 46
	//	Action.QueuePosition                                 cannot supply  no queue key, so unknown
	//	Action.MergeBlocked                                  cannot supply  unknown on a mutation's own answer, whose mergeable_state is the asynchronous check's state as the creation lands, unknown on every creation measured
	//
	// GitLab departs from the normalized contract:
	//
	//	Ref                                  differs in spelling  iid, sigil "!"
	//	Labels[].Color                       cannot supply        no colour on the answer: its labels arrive as name strings
	//	Labels[].Description                 cannot supply        no description on the answer: its labels arrive as name strings
	//	Action.Mergeable                     cannot supply        merge_status checking and detailed_merge_status preparing on the answer, unknown until the asynchronous check settles
	//	Action.Checks                        cannot supply        CheckUnknown: head_pipeline null on the creation answer, which the product sets after it, null here although the source branch's pipeline had already started on its head
	//	Action.ChecksPassing .. ChecksTotal  cannot supply        zero: head_pipeline null on the answer, so no status to count
	//	Action.QueueState                    cannot supply        QueueNone always: no merge-train key among the 61 keys the answer carries
	//	Action.QueuePosition                 cannot supply        QueuePositionUnknown: no merge-train key on the answer
	//	Action.MergeBlocked                  cannot supply        unknown on a mutation's own answer, whose detailed_merge_status is the asynchronous check's state as the creation lands, preparing on every creation measured
	//	State                                differs in spelling  state string, opened
	//
	// Gitea and Forgejo depart from the normalized contract:
	//
	//	Action.Mergeable       cannot supply  unknown on a mutation's own answer, whose mergeable bool is false while the instance's asynchronous check holds the pull request queued as the creation lands, true on every creation measured
	//	Action.AutoMergeArmed  cannot supply  no auto_merge key: unknown
	//	Action.QueueState      cannot supply  QueueNone always
	//	Action.QueuePosition   cannot supply  QueuePositionUnknown: no queue
	//	Action.MergeBlocked    cannot supply  mergeable and draft, no reason
	//
	// GitHub sends this, where a wrong default would change the request silently:
	//
	//	labels  body  the labels the caller named, as the NAMES the labels route takes, on the second request this row prices: this product's pull-request creation declares no labels parameter at all, so a creation that sent them there would have them ignored and one that dropped them would answer a pull request the caller did not ask for
	//
	// GitLab sends this, where a wrong default would change the request silently:
	//
	//	labels  body  the labels the caller named, as the NAMES this product's creation takes, so nothing has to resolve them first
	//
	// Gitea and Forgejo send this, where a wrong default would change the request silently:
	//
	//	labels  body  the labels the caller named, as the numeric ids this product's creation declares in place of their names, which is what the label read this row prices resolves
	CreatePR(ctx context.Context, repo RepoRef, req NewPullRequest) (PullRequest, error)

	// ClosePR closes a pull request without merging and returns what that
	// product's response carried, which the table below states per field. What a
	// consumer does with it, patch a row or refetch, is the consumer's ruling
	// against that table rather than something this library promises.
	//
	// Per forge:
	//
	//	GitHub   PATCH /repos/{owner}/{repo}/pulls/{number}, then one read against the id-addressed location to resolve the successor where it answered from another address, 1 to 2 requests
	//	GitLab   PUT /api/v4/projects/{id}/merge_requests/{iid}, 1 request
	//	Gitea    PATCH /api/v1/repos/{owner}/{repo}/pulls/{number}, 1 request
	//	Forgejo  PATCH /api/v1/repos/{owner}/{repo}/pulls/{number}, 1 request
	//
	// GitHub departs from the normalized contract:
	//
	//	Action.Mergeable      cannot supply  unknown on a mutation's own answer, whose mergeable is the asynchronous check's state as the close lands: null before the check settles, true after it
	//	Action.QueueState     cannot supply  no merge-queue key among 46
	//	Action.QueuePosition  cannot supply  no queue key, so unknown
	//	Action.MergeBlocked   cannot supply  unknown on a mutation's own answer, whose mergeable_state is the asynchronous check's state as the close lands: unknown before the check settles, clean after it
	//
	// GitLab departs from the normalized contract:
	//
	//	Ref                                  differs in spelling  iid, sigil "!"
	//	SourceRepo                           differs in shape     source_project_id beside target_project_id, a fork named by id alone: the addressed project where the two are equal, the zero value otherwise
	//	Labels[].Color                       cannot supply        no colour on the answer: its labels arrive as name strings
	//	Labels[].Description                 cannot supply        no description on the answer: its labels arrive as name strings
	//	Action.Mergeable                     cannot supply        merge_status checking and detailed_merge_status preparing on the answer before the asynchronous check settles, can_be_merged and not_open after it, unknown on both: the REST arm reads no mergeable flag
	//	Action.Checks                        differs in shape     head_pipeline.status scalar on the answer, no fold: null where the head ran no pipeline, so CheckUnknown, and the head pipeline's own status, running here, where one ran
	//	Action.ChecksPassing .. ChecksTotal  cannot supply        zero: the answer's head_pipeline is one scalar status, null or set, with nothing under it to count
	//	Action.QueueState                    cannot supply        QueueNone always: no merge-train key among the 61 keys the answer carries
	//	Action.QueuePosition                 cannot supply        QueuePositionUnknown: no merge-train key on the answer
	//	Action.MergeBlocked                  cannot supply        unknown on a mutation's own answer, whose detailed_merge_status is the asynchronous check's state as the close lands: preparing before the check settles, not_open after it, checking after a reopen
	//
	// Gitea and Forgejo depart from the normalized contract:
	//
	//	Action.Mergeable       cannot supply  unknown on a mutation's own answer, whose mergeable bool is false while the instance's asynchronous check holds the pull request queued as the close lands, true on every close measured
	//	Action.AutoMergeArmed  cannot supply  no auto_merge key: unknown
	//	Action.QueueState      cannot supply  QueueNone always
	//	Action.QueuePosition   cannot supply  QueuePositionUnknown: no queue
	//	Action.MergeBlocked    cannot supply  mergeable and draft, no reason
	ClosePR(ctx context.Context, repo RepoRef, pr PRRef) (PullRequest, error)

	// ReopenPR reopens a closed pull request and returns it.
	//
	// Per forge:
	//
	//	GitHub   PATCH /repos/{owner}/{repo}/pulls/{number}, then one read against the id-addressed location to resolve the successor where it answered from another address, 1 to 2 requests
	//	GitLab   PUT /api/v4/projects/{id}/merge_requests/{iid}, 1 request
	//	Gitea    PATCH /api/v1/repos/{owner}/{repo}/pulls/{number}, 1 request
	//	Forgejo  PATCH /api/v1/repos/{owner}/{repo}/pulls/{number}, 1 request
	//
	// GitHub departs from the normalized contract:
	//
	//	Action.Mergeable      cannot supply  unknown on a mutation's own answer, whose mergeable is the asynchronous check's state as the reopen lands, null on every reopen measured
	//	Action.QueueState     cannot supply  no merge-queue key among 46
	//	Action.QueuePosition  cannot supply  no queue key, so unknown
	//	Action.MergeBlocked   cannot supply  unknown on a mutation's own answer, whose mergeable_state is the asynchronous check's state as the reopen lands, unknown on every reopen measured
	//
	// GitLab departs from the normalized contract:
	//
	//	Ref                                  differs in spelling  iid, sigil "!"
	//	SourceRepo                           differs in shape     source_project_id beside target_project_id, a fork named by id alone: the addressed project where the two are equal, the zero value otherwise
	//	Labels[].Color                       cannot supply        no colour on the answer: its labels arrive as name strings
	//	Labels[].Description                 cannot supply        no description on the answer: its labels arrive as name strings
	//	Action.Mergeable                     cannot supply        merge_status unchecked and detailed_merge_status unchecked on the answer, unknown until the asynchronous check settles
	//	Action.Checks                        differs in shape     head_pipeline.status scalar on the answer, no fold: null where the head ran no pipeline, so CheckUnknown, and the head pipeline's own status, running here, where one ran
	//	Action.ChecksPassing .. ChecksTotal  cannot supply        zero: the answer's head_pipeline is one scalar status, null or set, with nothing under it to count
	//	Action.QueueState                    cannot supply        QueueNone always: no merge-train key among the 61 keys the answer carries
	//	Action.QueuePosition                 cannot supply        QueuePositionUnknown: no merge-train key on the answer
	//	Action.MergeBlocked                  cannot supply        unknown on a mutation's own answer, whose detailed_merge_status is the asynchronous check's state as the reopen lands, unchecked on every reopen measured, the status a reopen resets to
	//	State                                differs in spelling  state string, opened, closed_at cleared
	//
	// Gitea departs from the normalized contract:
	//
	//	Action.Mergeable       cannot supply  unknown on a mutation's own answer, whose mergeable bool is false while the instance's asynchronous check holds the pull request queued as the reopen lands, true on every reopen measured
	//	Action.AutoMergeArmed  cannot supply  no auto_merge key: unknown
	//	Action.QueueState      cannot supply  QueueNone always
	//	Action.QueuePosition   cannot supply  QueuePositionUnknown: no queue
	//	Action.MergeBlocked    cannot supply  mergeable and draft, no reason
	//
	// Forgejo departs from the normalized contract:
	//
	//	Action.Mergeable       cannot supply  unknown on a mutation's own answer, whose mergeable bool is false while the instance's asynchronous check holds the pull request queued as the reopen lands, false on one reopen measured and true on the others
	//	Action.AutoMergeArmed  cannot supply  no auto_merge key: unknown
	//	Action.QueueState      cannot supply  QueueNone always
	//	Action.QueuePosition   cannot supply  QueuePositionUnknown: no queue
	//	Action.MergeBlocked    cannot supply  mergeable and draft, no reason
	ReopenPR(ctx context.Context, repo RepoRef, pr PRRef) (PullRequest, error)

	// RerunFailedChecks re-runs the failed CI of a pull request.
	//
	// headSHA is a PRECONDITION rather than a preference, exactly as
	// [MergeRequest.HeadSHA] is: the family package refuses with
	// [CodeStaleHead] rather than re-running when the pull request has moved
	// since, because a re-run can carry deployment side effects and a row
	// displaying one commit's red status must not act on another's. Empty means
	// the forge reported no head, and the re-run then proceeds against the pull
	// request's current one, which is the one case where the pin is unavailable
	// rather than waived.
	//
	// Whether an instance can do this at all is [CapRerunChecks], read from
	// [ConnectionCaps]: a product with no re-run verb reports the capability
	// rather than failing to satisfy this role, because the two products of one
	// family differ here and a family serves both.
	//
	// Called against an instance detection says lacks the verb, it refuses with
	// [CodeCapabilityUnsupported] and issues NO request, carrying the capability
	// and the evidence the verdict was reached on. A consumer that read
	// [ConnectionCaps] first and disabled the control never sees it, which is why
	// that refusal is written for whoever wrote the call rather than for the user.
	//
	// Per forge:
	//
	//	GitHub   GET /repos/{owner}/{repo}/actions/runs to resolve the head commit's run, then POST /repos/{owner}/{repo}/actions/runs/{run}/rerun-failed-jobs, plus one read against the id-addressed location to resolve the successor where either of them answered from another address, 2 to 3 requests; needs rerun_checks
	//	GitLab   GET /api/v4/projects/{id}/pipelines, filtered by the head SHA, to resolve the head commit's pipeline, then POST /api/v4/projects/{id}/pipelines/{run}/retry, 2 requests; needs rerun_checks
	//	Gitea    GET /api/v1/repos/{owner}/{repo}/actions/runs, filtered by the head SHA, to resolve the head commit's run, then POST /api/v1/repos/{owner}/{repo}/actions/runs/{run}/rerun-failed-jobs, both of which this product's swagger document names, 2 requests; needs rerun_checks
	//	Forgejo  no route: this product carries no such verb, so the call is refused; needs rerun_checks
	//
	// Forgejo departs from the normalized contract:
	//
	//	error, verb absent  cannot supply  no re-run verb in this product's API, which its swagger document settles
	//
	// GitHub sends this, where a wrong default would change the request silently:
	//
	//	head_sha  query  the head the caller pinned, as the filter this product's runs route declares, so the retry addresses the workflow run of the commit the caller's row was rendered from rather than whichever the instance returns first
	//
	// GitLab sends this, where a wrong default would change the request silently:
	//
	//	sha  query  the head the caller pinned, as the filter this product's pipelines route declares, so the retry addresses the pipeline of the commit the caller's row was rendered from rather than whichever the instance returns first
	//
	// Gitea sends this, where a wrong default would change the request silently:
	//
	//	head_sha  query  the head the caller pinned, as the filter this product's runs route declares, so the retry addresses the run of the commit the caller's row was rendered from rather than whichever the instance returns first
	RerunFailedChecks(ctx context.Context, repo RepoRef, pr PRRef, headSHA string) error
}

PullRequests is the pull-request surface other than the merge itself.

type QueueState

type QueueState int

QueueState is a merge queue's verdict on one pull request.

It is a defined type rather than a bare string so that an unmapped upstream value, a product with no merge queue, and a field a document failed to select cannot collapse to one value on the wire.

const (
	// QueueUnknown is the zero value: the queue state was not read.
	QueueUnknown QueueState = iota
	// QueueNone means this repository merges without a queue.
	QueueNone
	// QueueQueued means the pull request is in the queue.
	QueueQueued
	// QueueAwaitingChecks means the queue is waiting on its checks.
	QueueAwaitingChecks
	// QueueMergeable means the queue considers the entry ready.
	QueueMergeable
	// QueueUnmergeable means the queue considers the entry unmergeable.
	QueueUnmergeable
	// QueueLocked means the queue has locked the entry.
	QueueLocked
)

The QueueState members: the five a merge queue reports, plus unknown and the no-queue case.

func (QueueState) String

func (q QueueState) String() string

String returns the member's wire spelling, which is the member name lowercased with underscores: "unknown", "none", "queued", "awaiting_checks", "mergeable", "unmergeable" or "locked".

type Release

type Release struct {
	// TagName is the tag it points at.
	TagName string
	// Name is its title.
	Name string
	// Body is its notes.
	Body string
	// WebURL is its page on the instance.
	WebURL string
	// PublishedAt is when it was published, zero for an unpublished draft.
	PublishedAt time.Time
	// Draft reports an unpublished release.
	Draft bool
	// Prerelease reports a prerelease.
	Prerelease bool
}

Release is one tagged release.

type Releases

type Releases interface {
	// ListReleases returns releases of repo.
	//
	// Per forge:
	//
	//	GitHub   GET /repos/{owner}/{repo}/releases, then one read against the id-addressed location to resolve the successor where it answered from another address, 1 to 3 requests per page
	//	GitLab   GET /api/v4/projects/{id}/releases, 1 request per page
	//	Gitea    GET /api/v1/repos/{owner}/{repo}/releases, and where the repository moved, the one 301 its old path answers, whose Location names the successor, every later request of the call addressing that successor, 1 to 2 requests per page
	//	Forgejo  GET /api/v1/repos/{owner}/{repo}/releases, and where the repository moved, the one 301 its old path answers, whose Location names the successor, every later request of the call addressing that successor, 1 to 2 requests per page
	//
	// GitLab departs from the normalized contract:
	//
	//	Items[].Body        differs in spelling  description, no body key
	//	Items[].Draft       cannot supply        no draft key on the row
	//	Items[].Prerelease  cannot supply        no prerelease key; upcoming_release false here
	//	Successor           cannot supply        nil: no successor key
	ListReleases(ctx context.Context, repo RepoRef, opts ...ListOption) (Page[Release], error)

	// CreateRelease cuts a release and returns the created object.
	//
	// Per forge:
	//
	//	GitHub   POST /repos/{owner}/{repo}/releases, then one read against the id-addressed location to resolve the successor where it answered from another address, 1 to 2 requests
	//	GitLab   POST /api/v4/projects/{id}/releases, 1 request
	//	Gitea    POST /api/v1/repos/{owner}/{repo}/releases, 1 request
	//	Forgejo  POST /api/v1/repos/{owner}/{repo}/releases, 1 request
	//
	// GitLab departs from the normalized contract:
	//
	//	Body        differs in spelling  description, no body key
	//	Draft       cannot supply        no draft key on the answer
	//	Prerelease  cannot supply        no prerelease key; upcoming_release false here, though one was asked for
	CreateRelease(ctx context.Context, repo RepoRef, req NewRelease) (Release, error)
}

Releases is the release surface. It is OPTIONAL: a family whose product has no release object does not implement it, and the absence is a type fact a caller can test for rather than a stub returning an error.

type RepoAffordances

type RepoAffordances struct {
	// MergeStrategies is the merge strategies this repository allows, in the
	// family's own spellings: a style the repository record switches on, never
	// one it disables. On a family whose merge takes no strategy (GitLab), it is
	// the project's merge method and whether squash is allowed, display text no
	// [MergeRequest.Strategy] takes; squash is expressed there through
	// [MergeRequest.Intent]. [MergeRequest.Strategy] is checked locally against the
	// family's closed set rather than this list, so a strategy the repository
	// disables comes back as the forge's own refusal.
	MergeStrategies []string
	// HasIssues is not a bool: one product varies the field by permission, so
	// "no issues" and "cannot see" are different answers.
	HasIssues Support
	// CanPush is not a bool: one product reports a role integer, from which
	// absence of push and absence of visibility are not the same.
	CanPush Support
	// MergeTrain reports whether merging here goes through a queue or train,
	// never inferred from a refusal. What answered it is what [Ev] records:
	// the project's own setting on the family that configures it per
	// repository, and the instance's version body on the family where it is a
	// fixed product property, per the exception this struct's own doc names.
	MergeTrain Support
	// Ev is the evidence behind each Support-typed field above, keyed by
	// [CapHasIssues], [CapCanPush] and [CapMergeTrain] exactly as the two
	// sibling scopes are keyed, so an unknown affordance arrives with a reason
	// to render in the same {support, source, detail} shape a connection or
	// grant capability gives it, rather than as a silent absence. The fields
	// that are not Support carry their own values and have no entry here.
	//
	// It sits beside the fields it explains rather than appended after them,
	// which is why the directive on this struct names it: moving it last, so
	// that the other fields read as a prefix, gives this struct a leading run
	// of pointer data that costs [Repository] eight bytes of it and a
	// fieldalignment finding of its own.
	Ev map[Capability]Evidence
	// DefaultBranch is the repository's default branch.
	DefaultBranch string
}

RepoAffordances is what this repository allows, read from its own authenticated record wherever the record answers.

One field is not read from it and the exception is named rather than left to the table: on the Gitea family RepoAffordances.MergeTrain is fixed by the PRODUCT rather than configured per repository, so it is answered from the instance's version body under EvidenceVersion. A fixed product property carries the evidence that proves it, which is why RepoAffordances.Ev is keyed per field rather than stated once for the struct.

type RepoRef

type RepoRef struct {
	// ID is the wire-facing derived identifier. It is not authorization:
	// every resolution re-checks the connection's grant, and opacity is a
	// consumer convenience.
	ID string
	// Family is the family that owns Selector's spelling. It is not recoverable
	// from ID, which encodes the selector alone.
	Family Family
	// Selector is family-private: the exact string that family's API takes in a
	// path. Two families spell it "owner/repo"; one takes a full namespace path
	// whose separators are encoded once at request time rather than stored
	// pre-encoded.
	Selector string
	// DisplayPath is what a consumer shows a human.
	DisplayPath string
}

RepoRef addresses one repository.

ID is DERIVED from Selector and never allocated, which three consumers force: a poller that builds its addressing from a local git remote cannot mint an identifier without a round trip it does not make, a notification carries an identity across process restarts, and no mapping table has to survive a restart. The derivation is RepoIDPrefix followed by the hex encoding of the ASCII-lowercased canonical selector: an encoding, reversibly, never a digest, because resolution has to recover the selector and a digest would need the lookup table the scheme exists to avoid. Lowercasing first stops a one-character case difference amplifying into two different identifiers for one repository, and lower-hex rather than base64url because a consumer's row selector matches case-insensitively.

A rename or a transfer changes the selector and therefore the ID: a stale one resolves to CodeRepoRefStale, carrying the successor only where the connection could discover it.

func DecodeRepoRef

func DecodeRepoRef(id string, family Family) (RepoRef, error)

DecodeRepoRef recovers a reference from a wire identifier, validating the decoded selector before it can reach a URL.

family is a parameter because the derivation encodes the selector alone, so a decoded identifier carries no family of its own; a consumer's route knows it from the connection the identifier arrived on. A validation failure is CodeRepoRefInvalid and never a request: a derived identifier is consumer-controlled input that gets interpolated into a path. An id that is not the canonical encoding of the selector it decodes to, upper-case hex or the hex of a selector carrying ASCII upper case, is refused the same way, so an accepted id is RepoRef.Encode of the answer and one repository has one identifier.

func (RepoRef) Encode

func (r RepoRef) Encode() string

Encode returns the derived wire identifier for this reference. It reads Selector alone, which is why DecodeRepoRef has to be told the family, and it ignores ID, so it is also how ID is produced.

It validates nothing and cannot: one result and no error. A selector minted locally is refused by ValidateSelector before it is encoded, which is the call DecodeRepoRef makes on the way back in.

type Repos

type Repos interface {
	// ListRepos returns repositories accessible to the authenticated account.
	//
	// Under [WithOwner] it returns every repository that owner owns which the
	// credential can see instead, private ones included where it can, archived
	// repositories and forks included, each row carrying [Repository.Archived] and
	// [Repository.Fork]. A GitLab group's projects include its subgroups' and exclude
	// projects shared into it. An owner the instance does not resolve answers
	// [CodeOwnerUnresolved].
	//
	// Per forge:
	//
	//	GitHub   GET /user/repos; under an owner scope the RepoOwned document, repositoryOwner(login: {owner}) with ownerAffiliations [OWNER], which resolves a user and an organization alike, 1 request per page
	//	GitLab   GET /api/v4/projects; under an owner scope GET /api/v4/groups/{owner}/projects with include_subgroups=true and with_shared=false, and where that answers 404 for a one-segment owner, GET /api/v4/users/{owner}/projects, the user's personal namespace, on the first page alone, since the continuation names the route; an owner named by digits alone, which every namespace route reads as a numeric id, is first resolved by name through the OwnerLookup document to the id the route takes, one request more on every page, the group or the user it names read with no fallback, 1 to 2 requests per page
	//	Gitea    GET /api/v1/user/repos; under an owner scope GET /api/v1/users/{owner}/repos, which resolves a user and an organization alike and pages before it drops the rows the caller has no unit access to, so its walk continues while the pages read cover fewer rows than X-Total-Count, 1 request per page
	//	Forgejo  GET /api/v1/user/repos; under an owner scope GET /api/v1/users/{owner}/repos, which resolves a user and an organization alike and pages before it drops the rows the caller has no unit access to, so its walk continues while the pages read cover fewer rows than X-Total-Count, 1 request per page
	//
	// GitHub departs from the normalized contract:
	//
	//	Items[].Affordances.MergeStrategies     cannot supply     empty: absent on a listing row without an owner scope
	//	Items[].Affordances.MergeTrain          cannot supply     no record field: a ruleset property
	//	Items[].CloneURL, under an owner scope  differs in shape  the document has no HTTPS clone field, so it is the repository url with .git appended
	//
	// GitLab departs from the normalized contract:
	//
	//	Items[].Ref                                             differs in spelling  namespace path selector
	//	Items[].Affordances.HasIssues                           differs in shape     varies by permission
	//	Items[].Affordances.CanPush                             differs in shape     from a role integer
	//	Items[].Affordances.CanPush, under a group owner scope  cannot supply        SupportUnknown: the group route answers its rows without the permissions object the other two routes carry
	//	Items[].Affordances.Ev                                  cannot supply        no source on the row for its three keys, anonymous
	//	Items[].Private                                         differs in shape     visibility string, no private key
	//	error, owner unresolved                                 cannot supply        no refusal for a user whose profile is private: the user route answers it an empty list, so such an owner reads as owning nothing
	//
	// Gitea and Forgejo depart from the normalized contract:
	//
	//	Items[].Affordances.MergeTrain  cannot supply  SupportNo: no queue
	ListRepos(ctx context.Context, opts ...ListOption) (Page[Repository], error)
}

Repos lists the repositories a connection can reach.

No read on this surface takes a polling unit, and that is deliberate: a cycle is one consumer's control flow, so naming it here would put a product's loop in a 1.0 a second consumer with a different loop could neither use nor remove, and a caller cannot be handed the unit that spends a budget only the client can see. The two bounds on folded-status work are counted by the client over a rolling window of its own (Budget.StatusReadsPerInterval and Budget.StatusTimePerInterval), the rotation across repositories is the client's own policy, and the one value a consumer holds between two clients is the RotationCursor it stores. A consumer that wants its whole cycle bounded brings its own context.

type Repository

type Repository struct {
	// Ref addresses it.
	Ref RepoRef
	// Description is the repository description.
	Description string
	// WebURL is its page on the instance.
	WebURL string
	// CloneURL is the HTTPS clone URL, whose origin is the one a credential
	// helper answers for.
	CloneURL string
	// UpdatedAt is the last update the instance reported, zero where it
	// reported none.
	UpdatedAt time.Time
	// Affordances is what this repository allows, including its default
	// branch. It is the repository-scoped capability record and carries its own
	// evidence semantics.
	Affordances RepoAffordances
	// Private reports a private repository.
	Private bool
	// Archived reports an archived repository, where every mutation is refused
	// with [CodeRepoArchived] until an operator unarchives it.
	Archived bool
	// Fork reports a fork.
	Fork bool
}

Repository is one repository a connection can reach.

type RotationCursor

type RotationCursor string

RotationCursor is the opaque rotation cursor a consumer persists across client lifetimes: where the library's rotation of the reads it pays one request each for stopped, so the next client resumes at the row after the last one served rather than at the first.

Like Cursor it is this library's own encoding and is never interpreted. A consumer hands the value it stored to WithRotationCursor unchanged, reads the current one back from BudgetState.RotationCursor, and validates it as opaque rather than parsing it. The empty RotationCursor is a first run, and it is the only value the two families with nothing to rotate ever report.

The round trip exists because durable storage is the one thing a consumer has and this library does not: the only store it ships is the credential store and a cursor is not a credential. So the library holds the cursor for the life of one client and the consumer holds it between two.

It is a type of its own rather than a Cursor because the two are not interchangeable: a page cursor resumes one list and this one resumes a rotation across repositories, and handing one to the other's entry point is a compile error here rather than a rotation resumed from garbage. The refusal spelling is shared, CodeCursorInvalid, because the remedy at a consumer is identical, that something it stored is corrupt, and a second spelling would be wire vocabulary a 1.0 freezes for no branch anyone takes.

type Run

type Run struct {
	// Ref identifies the run within Repo.
	Ref RunRef
	// Repo is the repository the run belongs to.
	Repo RepoRef
	// Name is the workflow's or pipeline's display name, empty where the
	// product names none for this run.
	Name string
	// Branch is the branch the run ran for, as the product spells the ref.
	Branch string
	// HeadSHA is the commit the run ran on.
	HeadSHA string
	// WebURL is the run's page on the instance.
	WebURL string
	// CreatedAt is when the run was created.
	CreatedAt time.Time
	// StartedAt is when the run's current attempt started, zero where the product
	// sends none.
	StartedAt time.Time
	// UpdatedAt is when the run last changed.
	UpdatedAt time.Time
	// State is folded from the run's status and conclusion, in the verdict a
	// check fold names: a failed run is [CheckFailing], and one still running or
	// waiting for a person to approve it is [CheckPending]. An upstream value
	// outside the product's mapping table yields CheckUnknown, a counter
	// increment and a log line naming it.
	State CheckState
	// Trigger is what started the run, mapped from the product's own vocabulary.
	Trigger RunTrigger
}

Run is one CI run of one repository: a GitHub Actions workflow run, a GitLab pipeline, or a Gitea or Forgejo Actions run.

type RunRef added in v1.2.0

type RunRef struct {
	// ID is the identifier the product's own run routes take. A re-run keeps it on
	// the products that count re-runs as attempts of one run.
	ID int64
	// Number is the run's place in the repository's own sequence, never negative,
	// and zero where the product sends none.
	Number int64
}

RunRef identifies one CI run within its repository.

type RunTrigger added in v1.2.0

type RunTrigger int

RunTrigger is what started one CI run.

const (
	// TriggerUnknown is the zero value: the product sent no trigger, or one
	// outside its mapping table, which is counted and logged.
	TriggerUnknown RunTrigger = iota
	// TriggerPush is a push of a branch or a tag.
	TriggerPush
	// TriggerPullRequest is an event on a pull request, or a merge queue's or a
	// merge train's run for one.
	TriggerPullRequest
	// TriggerSchedule is the product's own schedule.
	TriggerSchedule
	// TriggerManual is a run started on demand, through the product's pages or
	// its API.
	TriggerManual
	// TriggerOther is a trigger the product names that none of the above
	// describes. It is a mapped member, so it is never counted as unknown.
	TriggerOther
)

The RunTrigger members.

func (RunTrigger) String added in v1.2.0

func (r RunTrigger) String() string

String returns the member's spelling: "unknown", "push", "pull_request", "schedule", "manual" or "other".

type Settings

type Settings struct {
	// Logger receives the library's own lines. Nil means slog.Default.
	Logger *slog.Logger
	// Credential is the token source every request authenticates with. Nil
	// means the connection is unauthenticated, which a family's constructor
	// refuses rather than sending anonymous requests.
	Credential CredentialSource
	// WireTransport replaces the wire under the library's own client stack.
	// Nil, which is the ordinary case, means the library builds its own.
	WireTransport http.RoundTripper
	// RequestObserver wraps the transport outside the retry layer, so what it
	// installs sees one call per logical request. Nil means no hook.
	RequestObserver func(next http.RoundTripper) http.RoundTripper
	// AttemptObserver wraps inside the retry layer, so what it installs sees
	// one call per attempt. Nil means no hook.
	AttemptObserver func(next http.RoundTripper) http.RoundTripper
	// Counters receives the event counts.
	Counters Counters
	// Budget is the resolved budget: [DefaultBudget]'s numbers with each knob
	// the caller set in place of its default.
	Budget Budget
	// MutationReserve is the budget the governor holds back from reads, in the
	// instance's own units.
	MutationReserve int
	// RotationCursor is where the rotation resumes, empty for a first run. It
	// is the value [WithRotationCursor] took and the one
	// [BudgetState.RotationCursor] hands back.
	RotationCursor RotationCursor
	// Mutations reports whether mutating operations are permitted.
	Mutations bool
	// PrivateAddresses reports whether this connection may reach a
	// private-range or single-label host.
	PrivateAddresses bool
	// PlaintextHTTP reports whether this connection may speak plaintext HTTP.
	PlaintextHTTP bool
	// RefreshLead is how long before expiry a rotating credential is due for
	// refresh, the value [WithRefreshLead] took.
	RefreshLead time.Duration
}

Settings is the resolved option state: what Resolve produces and a family's constructor reads.

A consumer cannot construct an Option, so it cannot reach these fields except through the With* functions. A new field is additive.

func Resolve

func Resolve(opts ...Option) Settings

Resolve applies defaults and then every option in order, and is what a family's constructor calls on the options it was handed.

type Support

type Support int

Support is a three-valued answer to "can this be done here?".

The zero value is SupportUnknown, deliberately: an unpopulated field or a missing map key must read as an honest unknown rather than as a wrong green. A bool cannot carry this shape, because it has one value for "no" and for "could not see", and the two need different treatment at a control the user is about to click.

const (
	// SupportUnknown is the zero value: nothing answered, so a consumer
	// renders a disabled control WITH its reason, visibly different from a
	// control that is absent.
	SupportUnknown Support = iota
	// SupportYes means an evidence source said the capability is available.
	SupportYes
	// SupportNo means an evidence source said it is not.
	SupportNo
)

The Support members.

func (Support) String

func (s Support) String() string

String returns the member's wire spelling: "unknown", "yes" or "no". Every Support-typed field crosses a consumer's wire in this spelling and never as a JSON boolean, so a field that could not be read arrives as unknown rather than as disarmed.

Directories

Path Synopsis
Package conformance is forgeapi's conformance suite: one case per operation per product, driven by the internal/spec table alone.
Package conformance is forgeapi's conformance suite: one case per operation per product, driven by the internal/spec table alone.
Package creds is the credential machinery behind a forgeapi connection: the refresh state machine a rotating OAuth credential runs under, the OAuth device grant that mints one, and the file-backed store both of them read.
Package creds is the credential machinery behind a forgeapi connection: the refresh state machine a rotating OAuth credential runs under, the OAuth device grant that mints one, and the file-backed store both of them read.
Package families opens a client for whichever forge family answers at a connection.
Package families opens a client for whichever forge family answers at a connection.
Package gitcred answers git's credential-helper protocol from the records in a creds.Store, so a git operation against a connected forge authenticates with that connection's token.
Package gitcred answers git's credential-helper protocol from the records in a creds.Store, so a git operation against a connected forge authenticates with that connection's token.
Package gitea is the forgeapi client for Gitea and Forgejo, two products that share one API.
Package gitea is the forgeapi client for Gitea and Forgejo, two products that share one API.
Package github is the forgeapi client for GitHub: github.com and Enterprise Server, served by the same code.
Package github is the forgeapi client for GitHub: github.com and Enterprise Server, served by the same code.
Package gitlab is the forgeapi client for GitLab: gitlab.com and self-managed instances, served by the same code.
Package gitlab is the forgeapi client for GitLab: gitlab.com and self-managed instances, served by the same code.
internal
credfile
Package credfile keeps files in one private directory held open as a root: the custody the credential store's file needs, and the one package of this module that names the atomic-file dependency.
Package credfile keeps files in one private directory held open as a root: the custody the credential store's file needs, and the one package of this module that names the atomic-file dependency.
gen
Package gen renders everything forgeapi publishes about per-product behaviour from the expectation table, and nothing else states those facts.
Package gen renders everything forgeapi publishes about per-product behaviour from the expectation table, and nothing else states those facts.
spec
Package spec is forgeapi's expectation table: what each operation does on each product, held once.
Package spec is forgeapi's expectation table: what each operation does on each product, held once.
surface
Package surface renders a module's exported Go surface as text, so a golden file can pin it and a test can fail on any drift.
Package surface renders a module's exported Go surface as text, so a golden file can pin it and a test can fail on any drift.
transport
Package transport is forgeapi's one request core: addressing, auth injection, retry, the SSRF hook, trust, decode bounds, pagination, budget, error mapping and the per-request log line.
Package transport is forgeapi's one request core: addressing, auth injection, retry, the SSRF hook, trust, decode bounds, pagination, budget, error mapping and the per-request log line.

Jump to

Keyboard shortcuts

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