github

package
v0.1.0-rc.2 Latest Latest
Warning

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

Go to latest
Published: Aug 19, 2026 License: MIT Imports: 26 Imported by: 0

Documentation

Overview

Package github is labelsync's GitHub boundary. Everything that speaks HTTP to the API lives here, and nothing outside it does: internal/plan and internal/palette take plain structs and return plain structs precisely so that the interesting logic stays testable without any of this.

auth.go resolves the credential the rest of the package authenticates with.

The resolution chain

A token is resolved from four sources, in a fixed order, first non-empty wins:

  1. the --token flag
  2. GH_TOKEN, then GITHUB_TOKEN
  3. the gh config file, via go-gh's auth.TokenForHost
  4. shelling out to `gh auth token`

go-gh is a dependency for this and nothing else. Every other request in this package goes through go-github.

cache.go is the ETag store behind conditional label reads.

It is the single most valuable optimisation in the tool: a conditional request that comes back 304 Not Modified does not count against the primary rate limit. Labels change rarely, so hit rates are high, which is what makes `sync --dry-run` cheap enough to run on every pull request.

The cache may never fail a run

It is an optimisation, and an optimisation that can break a run is a liability. Every failure here — an unreadable directory, a truncated file, half-written JSON, an entry from an older labelsync — is a miss. Nothing in this file returns an error to a caller, and the only thing a caller can do wrong is not use it.

client.go wraps go-github and classifies its errors once, so that no caller anywhere else in the tree sniffs a status code.

Why go-github

The deciding reason is typed *github.RateLimitError and *github.AbuseRateLimitError. Secondary rate limits are this tool's most likely failure mode — hundreds of sequential writes against a roughly 80/minute ceiling — and a distinguishable error type is what makes the backoff logic clean and testable rather than a status-code guess. resp.NextPage for enumeration is a bonus.

The taxonomy in one place

Classify is the only function in labelsync that looks at an HTTP status, and Client.Do is the only thing that calls it. Everything downstream reasons about labelsync.ErrRepoInaccessible and IsAlreadyExists instead.

labels.go holds the four label operations and nothing else. Every one of them goes through Client.Do, so a repository that cannot be reached is recorded and skipped rather than ending the run.

Why a local Label type

Label mirrors [plan.Label] field for field, on purpose: the planner takes plain structs and declares its own, which is what keeps internal/plan free of internal/github. Identical names, types, and order make the two directly convertible, so the call site that bridges them writes plan.Label(l) rather than a mapping function — and the compiler notices if either side drifts. The dependency stays pointing one way, and neither package imports the other.

repos.go turns the selectors config resolved into concrete repositories.

Filtering is free and stays free

A repository listing already carries archived, fork, private, and has_issues on every entry, so every filter is answered from the enumeration response. Nothing here issues a per-repository GET to check an attribute: that would turn one request per hundred repositories into one per repository, for information already in hand.

The filters themselves live in config.Selector.Reject, offline and testable without an HTTP mock. This file's job is to produce the config.Repo values it judges — and, for `labelsync groups`, to keep what it rejected and why.

store.go is the ETag cache seen from outside: what is in it, and how to empty it. cache.go is the same directory seen from the read path.

The bound is explicit

`labelsync cache clear` takes a path that ultimately comes from the environment — XDG_CACHE_HOME — and then deletes what is in it. Nothing about that is safe by construction, so OpenStore takes the root it must sit under as an argument and refuses anything else. A bound derived inside this file from the same environment variable would not be a bound at all.

Index

Constants

View Source
const (
	// DefaultRetries is how many times a 5xx is retried, *in addition* to the
	// first attempt — so four requests at most.
	DefaultRetries = 3

	// DefaultBackoff is the wait before the first retry. It doubles each time,
	// so the three waits are 500ms, 1s, and 2s.
	DefaultBackoff = 500 * time.Millisecond
)

Retry defaults. A 5xx from GitHub is nearly always transient, and a label write is idempotent enough that repeating one is always safe: creating a label that now exists returns the 422 IsAlreadyExists recognises, and a PATCH applying the same values twice is the same label.

View Source
const CacheSchema = cacheSchema

CacheSchema is the version of the on-disk entry shape, as `cache info` reports it. See [cacheSchema].

View Source
const Host = "github.com"

Host is the only GitHub host labelsync talks to. GitHub Enterprise Server is a non-goal, so the host is a constant rather than a flag — but it is named rather than inlined, because both the gh config lookup and the `gh auth token` shell-out have to agree on which host they are asking about.

Variables

This section is empty.

Functions

func Classify

func Classify(repo, op string, resp *gogithub.Response, err error) error

Classify turns what a go-github call returned into one of three things: nil, a RepoError the run should skip past, or an error the run should fail on.

The three per-repository statuses come from the design:

403  archived, or the token lacks permission
404  renamed or deleted between enumeration and sync, or invisible to the token
410  gone

A rate-limit error is checked *first*, because it also arrives as a 403 and is emphatically not a repository that cannot be reached — it is the whole run needing to wait. Mistaking one for the other would skip every remaining repository and report success.

func IsAlreadyExists

func IsAlreadyExists(err error) bool

IsAlreadyExists reports whether err is GitHub refusing to create a label that is already there: a 422 carrying an "already_exists" code.

This is **not** a failure, and the caller turns it into an update. Two perfectly ordinary situations produce it — a plan computed against state that has since changed, and two runs overlapping — and in both the desired end state is a label with the configured values, which an update reaches.

It is also how case-only drift surfaces: a repository holding `bug` rejects the creation of `Bug`, because label names are case-insensitively unique.

func Union

func Union(selections []Selection) []config.Repo

Union deduplicates the repositories of several selections, case-insensitively, and sorts them by owner and then name.

Sorted because a map is not, and because every downstream artefact — the diff, the JSON stream, the golden files — is compared between runs.

Types

type CacheCleared

type CacheCleared struct {
	Dir     string
	Entries int
	Bytes   int64
}

CacheCleared is what `cache clear` removed.

type CacheInfo

type CacheInfo struct {
	// Dir is the cache directory, whether or not it exists yet.
	Dir string

	// Entries is how many cached label lists are in it.
	Entries int

	// Bytes is their total size on disk.
	Bytes int64

	// Schema is the entry-shape version this binary writes and accepts.
	Schema int

	// Oldest is the modification time of the oldest entry, and the zero time
	// when there are none.
	Oldest time.Time
}

CacheInfo is what `cache info` reports.

The types are the machine's: bytes as an int64 and a timestamp as a time.Time, never "1.2 MiB" and "3 days ago". Those are the table's rendering of the same values, and putting them in the struct would make them the only thing a JSON consumer could have.

type Client

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

Client is labelsync's handle on the GitHub API.

It owns the per-repository failures the run collected, because "keep going and report at the end" is a property of the whole run rather than of any one call site — see Failures.

func New

func New(token Token, opts ...Option) (*Client, error)

New builds a client that authenticates with token.

The retry wrapper sits *under* go-github's auth transport, so a retried request carries the Authorization header the first one did.

func (*Client) Affordable

func (c *Client) Affordable(writes int) bool

Affordable reports whether writes requests fit in the primary budget as the limiter last understood it.

It is the question an apply asks before its first write: a run that spends half its plan and then stalls for an hour has left every repository it touched in a state nobody asked for, and the reading that would have predicted it was free.

An unknown budget is affordable, and so is a client with no limiter — refusing on no information would stop a run that would have succeeded.

func (*Client) CreateLabel

func (c *Client) CreateLabel(ctx context.Context, owner, repo string, label Label) error

CreateLabel creates label in the repository.

A 422 already_exists is **not** a failure and is reclassified as an update: the label is already there under some casing, and the desired end state is a label carrying the configured values, which the update reaches. Two ordinary situations produce it — a plan computed against state that has since changed, and two runs overlapping — and case-only drift is a third, because GitHub holds label names case-insensitively unique.

The update addresses the label by the **desired** name rather than by the one the repository holds, because a create has no observed name to work from. That is safe: label lookup is case-insensitive, so the path resolves to the `bug` that rejected `Bug`, and new_name then corrects the casing in the same call.

func (*Client) DeleteLabel

func (c *Client) DeleteLabel(ctx context.Context, owner, repo, name string) error

DeleteLabel removes the label from the repository.

**This is destructive beyond the label itself**: GitHub removes it from every issue and pull request that carried it, and nothing restores that. Call it only in prune mode, on a candidate the user has been shown and has accepted — the planner emits removal candidates and never decides which of them are deleted, and this function is the reason that separation exists.

func (*Client) Do

func (c *Client) Do(ctx context.Context, repo, op string, call func(context.Context) (*gogithub.Response, error)) error

Do runs one repository-scoped call and classifies whatever it returns.

op is a short present-tense description used in messages — "list labels", "create label" — and repo is owner/repo.

A per-repository failure is **recorded before it is returned**, so a caller that means to continue can do so without remembering to collect anything:

if err := c.Do(ctx, repo, "list labels", call); err != nil {
    if errors.Is(err, labelsync.ErrRepoInaccessible) {
        continue // already collected; the summary will name it
    }
    return err // a real failure: the run stops
}

Recording inside Do rather than at the call site is deliberate. A skipped repository that never reaches the summary is indistinguishable from one that synced cleanly, and that is the one mistake here a user cannot detect. # Rate limits are waited out, not returned

A call that comes back rate-limited is retried once the limiter has slept it off, because a rate limit is not an outcome — it is the API asking for the same request later. The loop ends when the call stops being rate-limited, or when the wait would take the run past --max-wait, which comes back as labelsync.ErrMaxWaitExceeded.

func (*Client) Enumerate

func (c *Client) Enumerate(ctx context.Context, selectors []config.Selector, concurrency int) ([]config.Repo, error)

Enumerate walks every selector and returns the distinct repositories they select, sorted by owner and then name.

The union is deduplicated case-insensitively: two groups naming the same repository is ordinary — it is how a repository ends up with the labels of both — and enumerating it twice would plan it twice.

func (*Client) ExpectWrites

func (c *Client) ExpectWrites(n int)

ExpectWrites tells the limiter how many writes the run is about to make, so that a rate-limit countdown can report what is left of the job alongside what is left of the wait. A client with no limiter has nowhere to put it.

func (*Client) Failures

func (c *Client) Failures() *Failures

Failures returns the per-repository failures collected so far.

func (*Client) ListLabels

func (c *Client) ListLabels(ctx context.Context, owner, repo string) ([]Label, error)

ListLabels returns every label in the repository, walking every page.

The conditional request

When a cached list is available its ETag goes out as If-None-Match, and a 304 serves the cached labels **at zero quota cost** — a conditional request that comes back Not Modified does not count against the primary rate limit. That is what makes a repeat dry run over fifty repositories effectively free.

Only a **single-page** list is served from cache. An ETag covers the response it came from, which is page one, and a repository with more than a hundred labels can change beyond that page without page one's representation changing at all. Serving that from cache would plan creates for labels that already exist. More than a hundred labels in one repository is rare, so the optimisation keeps the case it is correct for and reads the other one live.

func (*Client) Login

func (c *Client) Login(ctx context.Context) (string, error)

Login returns the login of the user the token belongs to, from GET /user.

It is asked for one reason: config.Resolve needs it to decide which of the two user endpoints a `user:` selector has to call, and whether asking for that user's private repositories is going to come back empty. A config with no `user:` group never needs it, so callers check before spending the request.

The answer is cached for the life of the client. It cannot change during a run, and a second request for it would be a request spent on a question already answered.

A failure is the caller's to shrug off: a token that cannot read /user — a GitHub App installation token, most likely — still lists organisations perfectly well. Resolve treats an empty login as "somebody else", which is the conservative reading.

func (*Client) PatchLabel

func (c *Client) PatchLabel(ctx context.Context, owner, repo, current string, patch LabelPatch) error

PatchLabel changes the fields of the label the repository holds as current, and leaves the rest.

A rename is a PATCH carrying new_name and never a delete plus a create, because new_name **preserves** every issue and pull-request association and keeps the label's id. Deleting and recreating would strip the label from every issue that used it — the same damage as Client.DeleteLabel, for a rename nobody asked to be destructive.

current is the name the repository was observed to hold, so the request stays consistent with the state the plan was computed against. Sending a new_name identical to the path is a no-op on GitHub's side, which keeps recolours and renames one code path rather than two.

The request is built here rather than through go-github's EditLabel, which sends the label's `name` field. GitHub's update endpoint reads **new_name** and ignores `name`, so EditLabel would return a cheerful 200 having renamed nothing.

func (*Client) REST

func (c *Client) REST() *gogithub.Client

REST exposes the underlying go-github client, for the request-issuing code in repos.go and labels.go. Call it through Client.Do so the result is classified.

func (*Client) RateLimit

func (c *Client) RateLimit(ctx context.Context) (*gogithub.Rate, error)

RateLimit reads the current budget from GET /rate_limit.

The endpoint is **free**: it does not itself count against the limit, which is what makes it worth calling at startup. The reading seeds the limiter, so the first request of a run is issued as informed as the last, and --debug reports what is left before anything is spent.

A failure here is not fatal to the caller's decision-making — the run can proceed uninformed, which is what it did before the call existed — but it is returned rather than swallowed so a caller can say so.

func (*Client) ReadLabels

func (c *Client) ReadLabels(ctx context.Context, repos []config.Repo, concurrency int) ([]RepoLabels, error)

ReadLabels reads the label sets of many repositories in parallel, bounded by concurrency — non-positive means [defaultConcurrency].

The result keeps the input order rather than the order the reads happened to finish in, because it becomes a plan, and a plan that reshuffled between two identical runs is not one anyone can diff.

A repository that cannot be reached is **absent from the result**, not present with an empty label set. The two would otherwise be indistinguishable, and the second reading is the dangerous one: an empty label set is what a repository that needs every label created looks like. It is recorded in Client.Failures on the way past, which is what turns into the skipped outcome bit.

func (*Client) RemainingBudget

func (c *Client) RemainingBudget() (int, time.Time, bool)

RemainingBudget is the last primary-budget reading, when it resets, and whether there has been one. A client with no limiter has never had one.

It is what a refusal quotes: "not enough budget" is not actionable, and "42 requests left until 14:22Z" is.

func (*Client) Select

func (c *Client) Select(ctx context.Context, selectors []config.Selector, concurrency int) ([]Selection, error)

Select walks every selector and returns what each one resolved to, in the order the selectors were given.

This is the per-group answer. Client.Enumerate is the union of it, and is what a run that only needs "which repositories" should call.

Selectors are walked in parallel, bounded by concurrency — non-positive means [defaultConcurrency]. Reads are not subject to the content-creation secondary limit, so the bound is politeness and round-trip latency rather than a quota concern.

An owner that cannot be listed is recorded and skipped, not fatal: its selection comes back empty. One mistyped org in a config that names four should report itself at the end of the run rather than take the other three down with it. Anything else — a 401, a cancelled context — ends the run.

func (*Client) UpdateLabel

func (c *Client) UpdateLabel(ctx context.Context, owner, repo, current string, label Label) error

UpdateLabel patches the label the repository holds as current so that it carries every value in label.

This is the whole-label form of Client.PatchLabel, for the caller that has a complete desired label rather than a diff: label.Name is always sent as new_name, and the description is always sent because descriptions are authoritative and omitting the field would leave a stale one in place on a label the config says has none.

type Failures

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

Failures collects the repositories a run could not reach.

Reads run in parallel, so it is safe for concurrent use.

func (*Failures) All

func (f *Failures) All() []*RepoError

All returns the collected failures, sorted by repository and then by operation. Sorted rather than in arrival order because arrival order is whatever the parallel reads happened to finish in, and a summary that reshuffles between two identical runs is not a summary anyone can diff.

func (*Failures) ExitCode

func (f *Failures) ExitCode() exit.Code

ExitCode is exit.Skipped when any repository was skipped, and exit.OK otherwise. It is an outcome bit: the caller ORs it with whatever else the run concluded, so a dry run that both drifted and skipped exits 6.

func (*Failures) Len

func (f *Failures) Len() int

Len is how many repositories were skipped.

func (*Failures) Record

func (f *Failures) Record(err error) bool

Record files err if it is a per-repository failure, and reports whether it did. A false means the error is the run's, and the caller must not continue.

func (*Failures) Report

func (f *Failures) Report(w output.Writer)

Report writes the end-of-run summary of skipped repositories.

On stderr, through Warn: a skipped repository is a recoverable problem and the story of the run, not its product. `labelsync groups --output=json | jq` has to keep working when three repositories turn out to be archived.

type Label

type Label struct {
	// Name is the name exactly as the repository stores it, casing included.
	// Matching against the config is case-insensitive, but the stored casing is
	// what an update has to correct.
	Name string

	// Color is six-digit hex, as GitHub stores it: no leading #.
	Color string

	// Description is the description, empty when there is none. GitHub does not
	// distinguish an absent description from an empty one, and neither does this.
	Description string
}

Label is a label as a repository holds it today.

It is deliberately convertible to [plan.Label] — same fields, same order — so that translating an API response into planner input costs a conversion rather than a mapping function. See the package-level note in this file.

type LabelPatch

type LabelPatch struct {
	NewName     *string
	Color       *string
	Description *string
}

LabelPatch is the set of fields an update changes. A nil field is **left alone**: GitHub's update endpoint treats every one of them as optional and leaves an omitted field as it was.

The three fields line up with plan.Action's three optional fields, field for field and pointer for pointer, because that is what an action is — the change and not the state it replaces. A squatter's recolour carries a colour and nothing else, and applying it must not touch the name or the description of a label nobody configured.

A pointer to the empty string is a value, not an absence: it clears the description, which is a thing the config's authoritative descriptions legitimately ask for. Plain strings could not carry that distinction, which is the whole reason these are pointers.

type Option

type Option func(*options)

Option configures a Client.

func WithBackoff

func WithBackoff(d time.Duration) Option

WithBackoff sets the wait before the first retry; it doubles thereafter.

func WithBaseURL

func WithBaseURL(raw string) Option

WithBaseURL points the client at another API root. This is what lets the tests run against net/http/httptest rather than github.com; it is not a GitHub Enterprise Server switch, which is a non-goal.

A trailing slash is added if absent, because go-github resolves every request path relative to this URL and silently loses the last segment without one.

func WithCacheDir

func WithCacheDir(dir string) Option

WithCacheDir points the ETag cache at a directory, and is what turns caching on: an empty directory — the default — is a client that never reads or writes one. That is how --no-cache arrives, and it is deliberately the absence of a destination rather than a flag threaded through the read path.

Production passes labelsync.CacheDir(); a test passes t.TempDir(), which is also what stops a test run from touching the developer's real cache.

func WithHTTPClient

func WithHTTPClient(c *http.Client) Option

WithHTTPClient supplies the transport the retry wrapper is layered over.

func WithLimiter

func WithLimiter(l *ratelimit.Limiter) Option

WithLimiter installs the rate limiter. Without one the client issues requests as fast as it is asked to, which is the right behaviour for a test and the wrong one for a few hundred label writes.

func WithRetries

func WithRetries(n int) Option

WithRetries sets how many times a 5xx is retried after the first attempt. Zero disables retrying.

func WithSleep

func WithSleep(sleep func(ctx context.Context, d time.Duration) error) Option

WithSleep replaces the backoff's sleep. The tests inject one that records the waits and returns immediately, which is what keeps a retry suite fast and makes the doubling assertable rather than merely plausible.

type Rejected

type Rejected struct {
	Repo   config.Repo
	Reason string
}

Rejected is a repository a selector listed and then filtered out, with the reason it was.

It is carried rather than discarded because the absence of an expected repository is exactly what `labelsync groups` exists to explain. Nothing else reads it: enumeration's answer is Selection.Repos.

type RepoError

type RepoError struct {
	// Repo is owner/repo.
	Repo string

	// Op is what was being attempted, in the present tense: "list labels".
	Op string

	// Status is the HTTP status that produced the classification.
	Status int

	// Reason says what the status means for this repository, in the terms a user
	// can act on rather than as a number.
	Reason string
	// contains filtered or unexported fields
}

RepoError is a failure that belongs to one repository rather than to the run.

It wraps labelsync.ErrRepoInaccessible, so errors.Is matches it and KindOf renders repo_inaccessible, while the fields carry what a summary line needs.

func (*RepoError) Error

func (e *RepoError) Error() string

Error implements error.

func (*RepoError) Unwrap

func (e *RepoError) Unwrap() error

Unwrap exposes the wrapped sentinel, keeping errors.Is and labelsync.KindOf working through the struct.

type RepoLabels

type RepoLabels struct {
	Repo   config.Repo
	Labels []Label
}

RepoLabels is one repository's current labels, as Client.ReadLabels read them.

type Resolver

type Resolver struct {
	// Flag is the value of --token, or "" when the flag was not passed.
	Flag string

	// LookupEnv reads an environment variable. nil means os.LookupEnv.
	LookupEnv func(key string) (value string, ok bool)

	// ConfigToken reads the token the gh config file holds for a host, and the
	// name of the config key it came from. nil means go-gh's auth.TokenForHost.
	ConfigToken func(host string) (token string, source string)

	// CLIToken runs `gh auth token` for a host. nil means the real shell-out.
	CLIToken func(ctx context.Context, host string) (string, error)
}

Resolver resolves a GitHub token from the four sources the design fixes, in order.

The three function fields are the seams the tests drive: with all of them nil a zero Resolver reads the real environment, the real gh config, and the real gh binary, which is what production wants. Only Flag is ordinary data.

func (Resolver) Resolve

func (r Resolver) Resolve(ctx context.Context) (Token, error)

Resolve walks the chain and returns the first non-empty token it finds.

A step that fails is not fatal and does not end the walk: `gh` not being installed is the ordinary case on a CI runner, not an error worth reporting to someone who set GITHUB_TOKEN anyway. Failures are logged at debug level and the walk continues, so the only outcome a user ever sees is a token or labelsync.ErrNoToken.

type Selection

type Selection struct {
	Selector config.Selector

	// Repos are the selected repositories, in the order the API listed them.
	Repos []config.Repo

	// Rejected are the repositories the listing returned that the selector's
	// filters removed. It is always empty for a repos selector: nothing is
	// enumerated for one, so nothing can be filtered out of it.
	Rejected []Rejected
}

Selection is one selector's enumeration: the repositories it selects, and the ones it listed and filtered out.

type Store

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

Store is the ETag cache as the cache commands see it: a bounded directory that can be described and emptied.

func OpenStore

func OpenStore(dir, root string) (*Store, error)

OpenStore returns a handle on the cache directory at dir, which must sit **inside** root.

root is labelsync.CacheRoot in production and a temporary directory under test. Passing it in rather than reading it here is the whole point: this is the function that decides whether a delete is allowed, and a check that derives its own bound from the same environment variable the path came from checks nothing.

A directory that does not exist yet is fine — an empty cache is a cache — and is reported as zero entries rather than as a failure.

func (*Store) Clear

func (s *Store) Clear() (CacheCleared, error)

Clear removes every cache entry, and reports what went.

The directory itself stays, and nothing is recursed into: only the entry files this tool writes, in this one directory, are ever removed. An empty or absent cache is a no-op rather than an error — clearing a cache that is already clear is exactly what a user asking for a clean run means.

func (*Store) Dir

func (s *Store) Dir() string

Dir is the directory the store is bound to.

func (*Store) Info

func (s *Store) Info() (CacheInfo, error)

Info describes what is in the cache.

type Token

type Token struct {
	// Value is the credential itself.
	Value string

	// Source is the step of the chain that produced Value.
	Source TokenSource
}

Token is a resolved credential and the source that produced it.

Value is never rendered by either of the two ways a struct usually reaches an output stream: Token.String covers the fmt verbs and Token.LogValue covers slog. A credential that leaks into a debug log is not recoverable by editing the log, so the redaction lives on the type rather than in the discipline of every call site.

func (Token) LogValue

func (t Token) LogValue() slog.Value

LogValue implements slog.LogValuer, redacting the credential.

func (Token) String

func (t Token) String() string

String implements fmt.Stringer, redacting the credential.

type TokenSource

type TokenSource string

TokenSource names the step of the resolution chain that produced a token. The values are what --debug prints, so they read as the thing the user would go and change.

const (
	TokenSourceFlag        TokenSource = "--token"
	TokenSourceGHToken     TokenSource = "GH_TOKEN"
	TokenSourceGitHubToken TokenSource = "GITHUB_TOKEN"
	TokenSourceGHConfig    TokenSource = "gh config"
	TokenSourceGHCLI       TokenSource = "gh auth token"
)

The four steps of the chain. GH_TOKEN and GITHUB_TOKEN are separate sources because they are separate variables: reporting "an environment variable won" would leave a user with both set no better off than before.

Directories

Path Synopsis
Package ratelimit keeps a run inside GitHub's limits, proactively and reactively.
Package ratelimit keeps a run inside GitHub's limits, proactively and reactively.

Jump to

Keyboard shortcuts

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