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)
}
}
Output:
Index ¶
- Constants
- func ReservedHeaders() []string
- func ValidateCursor(c Cursor) error
- func ValidateOwner(family Family, owner string) error
- func ValidateRef(ref string) error
- func ValidateSelector(family Family, selector string) error
- type Account
- type ActionState
- type Budget
- type BudgetState
- type Capabilities
- type Capability
- type CheckContext
- type CheckState
- type Checks
- type CommitChecks
- type Connection
- type ConnectionCaps
- type Core
- type Counters
- type CredKind
- type CredState
- type CredentialSource
- type Cursor
- type Error
- type ErrorKind
- type Evidence
- type EvidenceSource
- type Family
- type Governor
- type GrantCaps
- type Header
- type Identity
- type Issue
- type IssueRef
- type IssueState
- type Issues
- type Label
- type Labels
- type ListOption
- type ListSettings
- type ListState
- type MergeBlockReason
- type MergeIntent
- type MergeOutcome
- type MergeOutcomeState
- type MergeRequest
- type MergeStatus
- type Merges
- type NewIssue
- type NewPullRequest
- type NewRelease
- type Option
- func WithAttemptObserver(wrap func(next http.RoundTripper) http.RoundTripper) Option
- func WithCounters(c Counters) Option
- func WithCredentialSource(src CredentialSource) Option
- func WithListPages(n int) Option
- func WithLogger(l *slog.Logger) Option
- func WithMutationConcurrency(n int) Option
- func WithMutationInterval(d time.Duration) Option
- func WithMutationReserve(n int) Option
- func WithMutations(allowed bool) Option
- func WithOperationTimeout(d time.Duration) Option
- func WithPlaintextHTTP(allowed bool) Option
- func WithPrivateAddresses(allowed bool) Option
- func WithReadConcurrency(n int) Option
- func WithRefreshLead(d time.Duration) Option
- func WithRequestObserver(wrap func(next http.RoundTripper) http.RoundTripper) Option
- func WithRetries(n int) Option
- func WithRotationCursor(c RotationCursor) Option
- func WithStatusPages(n int) Option
- func WithStatusReadsPerInterval(n int) Option
- func WithStatusTimePerInterval(d time.Duration) Option
- func WithWireTransport(rt http.RoundTripper) Option
- type PRRef
- type PRState
- type Page
- type Partial
- type PartialReason
- type PullRequest
- type PullRequests
- type QueueState
- type Release
- type Releases
- type RepoAffordances
- type RepoRef
- type Repos
- type Repository
- type RotationCursor
- type Run
- type Settings
- type Support
Examples ¶
Constants ¶
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 cross-repository 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" // 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.
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.
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.
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.
const DefaultRefreshLead = 5 * time.Minute
DefaultRefreshLead is how long before its expiry a rotating credential is due for refresh when no WithRefreshLead is given.
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.
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.
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".
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 ¶
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 ¶
ValidateOwner refuses, without a request, an owner the family's cross-repository lists would refuse under WithOwner: one outside the shared form, and a path on a family whose owner is one name. Only GitLab takes a path, a group's full path; FamilyUnknown takes the single-name rule, as ValidateSelector gives it the single-separator one.
A failure is CodeListOwnerInvalid, the code the list answers, so an owner stored for later calls can be refused when it is stored.
func ValidateRef ¶
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 ¶
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: a REST endpoint counts requests and a document endpoint counts
// points. What one call spends in those units is per product, and every read
// this library issues is unconditional, so no call of its own is answered by
// the one status a product prices at nothing.
// It is [BudgetRemainingUnknown] where the instance reports none.
//
// It is what the FORGE reports for the credential, which on GitHub and GitLab
// is the user's own quota rather than this client's share of it: every
// application acting for that user draws on the same pool, so this number
// falls while this client sends nothing and a consumer that renders it as
// "what the library has used" is showing something false. Rendered as the
// account's remaining budget it is exactly right, and it is the number the
// mutation reserve is held against.
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 reported failure. CheckFailing // CheckPending means at least one check is still running 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
//
// 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 repo's CI runs, in the order the product answers them:
// no sort parameter is sent, because one family's route declares none.
//
// It reads the route [PullRequests.RerunFailedChecks] resolves its run on,
// with the page bound as its page size and none of the head filter that
// read sends, so the answer is every run of the repository rather than one
// commit's. A consumer wanting the failed ones filters [Run.State] on
// [CheckFailing].
//
// [WithState] and [WithOwner] are refused on it, with [CodeListStateInvalid]
// and [CodeListOwnerInvalid], before any request: a run has no open or
// closed state to select on, and one repository has no owner to scope by.
//
// 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, 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, 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, 1 to 2 requests per page
//
// 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
// 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
//
// 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
//
// 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
//
// 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.
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
}
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.
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.
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.
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 // 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.
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. The other fourteen enumerations on this surface render through their own String methods, all fourteen of them defined types over int; of the FOUR string-based types this is the only one with a member whose value is not its spelling, since Cursor and RotationCursor are opaque by contract and a Capability member IS its spelling. So without this method 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 a different vocabulary, 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.
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; see [BudgetState] for how the
// governor uses it and for the neutral values on Gitea, the one product that
// sends no signal.
//
// The budget it reports belongs to the USER and not to this connection: on
// GitHub and GitLab it is the account's own quota, drawn on by every
// application acting for that user, so the number moves while this client
// sends nothing. That is stated here as well as on [BudgetState] and
// [BudgetState.Remaining] because a reader who stops at this accessor is the
// one the misreading costs: a user-wide number read as this connection's own
// is a budget the caller believes it alone is spending.
//
// It is also the one place the [RotationCursor] a consumer persists is read
// back, which is why a client that rotates nothing still answers here: the
// value is empty there, and a consumer stores what it is given without a
// per-family branch.
//
// 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 two pools, one per surface
// 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 ¶
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, in one call, scoped exactly as [PullRequests.ListMyPRs] is: what
// the credential authored by default, and every open issue under one owner
// under [WithOwner].
//
// It names no repository, for the reason [PullRequests.ListMyPRs] does, so
// neither [CodeRepoRefInvalid] nor [CodeRepoRefStale] can arise from the call
// itself, each row carries its own [Issue.Repo], and [Page.Successor] is never
// set. It costs one request per page under either scope: the owner is never
// resolved by a read of its own and no row adds a term. A search's result
// window ends a walk as it does there, which the Next row below states.
//
// Its state is OPEN by construction, so [WithState] is refused here with
// [CodeListStateInvalid] as it is on [PullRequests.ListMyPRs].
//
// 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, 1 request 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
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 or scope 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 a cross-repository list to every OPEN item under one owner, whoever authored it, in place of what the credential authored; the two scopes are exclusive. The owner is a user or organization login on GitHub, Gitea and Forgejo and a group's full path on GitLab, subgroups included, and it is never resolved by a read of its own: an unresolved owner answers CodeOwnerUnresolved, or an empty page where the operation's per-forge block says the product cannot tell, so the call stays one request per page.
Only PullRequests.ListMyPRs and Issues.ListMyIssues take it; every other list refuses it before any request with CodeListOwnerInvalid, as both do an owner outside the shared form. The form is one or more segments of ASCII letters, digits, '-', '_' and '.', joined by '/', none empty, "." or "..", at most 255 bytes, with '/' accepted on GitLab alone; ValidateOwner answers the same refusal without a call. It is checked rather than escaped because the owner reaches a search string whose grammar this library does not own. An empty owner sets ListSettings.OwnerSet and is refused.
Default: the credential's own authorship.
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 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
// cross-repository lists read Owner where it is true; every other list
// refuses whenever it is true.
OwnerSet 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, and CodeCursorInvalid for a WithAfter cursor that is not well formed. It is not told which list it resolves for, so it records a named filter or owner in ListSettings.StateSet and ListSettings.OwnerSet; the family method then refuses one its list cannot take, under the filter's or the owner'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.
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 ¶
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 ¶
WithListPages sets how many pages one list operation traverses before reporting PartialPaginationCap. Zero traverses none.
Default: DefaultListPages.
func WithLogger ¶
WithLogger routes the library's own lines. 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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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.
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 exist, and no continuation reaches them, because // the remainder lies outside what the route serves at all. It rides // [Page.Partial] on the page where the walk ends, and it is the one // response-level reason that arrives WITHOUT a [Cursor]. Its // [Partial.Fetched] counts the rows the whole walk was served rather than // the one page, and its [Partial.OmittedAtLeast] is the stated total less // that, so the two sum to the total the route stated. A consumer renders // it as a list holding more rows than the route will serve, never as a // complete one. The cross-repository lists ([PullRequests.ListMyPRs], // [Issues.ListMyIssues]) produce it on a product whose search serves a // fixed window of results; a page whose document also answered errors // carries [PartialGraphQLPartial] instead, since that says the page's own // rows are in question. [Checks.ListRuns] produces it on a page that // served no row while the listing's stated total says more than the walk // was served. PartialResultWindow )
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" or "result_window".
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;
// and [PartialLabelsNotApplied] on the pull request a creation answers beside its
// error where the labels it named were not applied.
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
//
// 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 authenticated credential's OPEN pull requests across
// every repository it can reach, in one call.
//
// Under [WithOwner] it lists every OPEN pull request in the repositories
// under that owner instead, whoever authored it, at the same price, with the
// same row and the same continuation; the two scopes are exclusive, and an
// owner the instance does not resolve answers [CodeOwnerUnresolved] where the
// product's answer can say so, which each product's cell below states.
//
// It names no repository, because it is keyed by the credential or by the
// owner rather than by a selector: there is nothing
// for [ValidateSelector] to check here and no stored identifier being
// resolved, so neither [CodeRepoRefInvalid] nor [CodeRepoRefStale] can arise
// from the call itself. Each row carries its own [PullRequest.Repo], which is
// what a consumer acts on, and the response-level [Page.Successor] is never
// SET for the same reason [Repos.ListRepos]' is not: neither call addresses a
// repository, so neither has one that could have moved. A rename under a row
// reaches the consumer as that row's own repository differing from the one it
// held.
//
// It exists because the shape of the alternative is what spends a budget.
// Answering "which of my pull requests are open" by listing per repository
// costs one request per repository per cycle, so the cost is set by how many
// repositories the user has, which this library cannot bound: measured on a
// workspace of 62 repositories against a per-USER quota of 5,000 requests an
// hour, one such poller consumed about 3,700 of it and starved every other
// application drawing on the same account. This read makes a cycle
// [Identity.Whoami] plus this call, TWO requests as its base count on every
// product. No product adds a per-row term to it, the Gitea family included:
// that family's rows carry neither a check state nor a head commit, and a
// folded status is addressed BY the head, so its rows answer
// [CheckUnknown] with a zero total and the per-forge block below says so
// field by field. What no product's count carries is a term in the repository
// count, which is the property this operation exists for.
// [PullRequests.ListPRs] stays for the per-repository view a user opens
// deliberately, and it is the call, with [PullRequests.ReadPR] and
// [Checks.CommitStatus], that answers a check verdict.
//
// Its state is OPEN by construction rather than by default, so [WithState] is
// REFUSED here with [CodeListStateInvalid] as it is on every list with no
// state to filter on: each product's endpoint carries the open state as
// part of the query this operation is, so a filter has nothing to select.
// Widening that later is additive; narrowing a published filter is not, which
// is why the refusal is the side to start on.
//
// It is paged and continued exactly as the other lists are, one request per
// page up to [Budget.ListPages], and what the count moves with is how many
// pull requests the credential has open rather than how many repositories it
// watches. Where a product answers the call from a search with a result
// window, a walk ends at the window's edge with no continuation, and the
// page that ends it carries [PartialResultWindow] wherever the search's own
// total states more than the walk was served; the per-forge block's Next
// row states the window. A row carries no affordance object of its own, so a consumer that
// wants a row's merge strategies reads them per repository through
// [Capabilities.RepoAffordances], bounded by the distinct repositories the
// credential has open pull requests in.
//
// 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; on either route, GET /api/v4/projects/{id} once for each distinct fork a row's source_project_id names, 1 request 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:
//
// 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
//
// 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
//
// 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 ¶
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 ¶
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.
//
// Per forge:
//
// GitHub GET /user/repos, 1 request per page
// GitLab GET /api/v4/projects, 1 request per page
// Gitea GET /api/v1/user/repos, 1 request per page
// Forgejo GET /api/v1/user/repos, 1 request per page
//
// GitHub departs from the normalized contract:
//
// Items[].Affordances.MergeStrategies cannot supply empty: absent on a listing row
// Items[].Affordances.MergeTrain cannot supply no record field: a ruleset property
//
// 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.Ev cannot supply no source on the row for its three keys, anonymous
// Items[].Private differs in shape visibility string, no private key
//
// 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 {
// 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
// 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
// is [CheckPending]. An upstream value outside the product's mapping table
// yields CheckUnknown, a counter increment and a log line naming it.
State CheckState
}
Run is one CI run of one repository: a GitHub Actions workflow run, a GitLab pipeline, or a Gitea or Forgejo Actions run.
It carries no id, no number and no trigger event. An id is a product token this surface does not hand out, and the event is one product's vocabulary with no counterpart on the others.
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.
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.
Source Files
¶
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. |