gh

package
v1.0.0 Latest Latest
Warning

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

Go to latest
Published: Sep 28, 2026 License: MIT Imports: 18 Imported by: 0

Documentation

Overview

Package gh is the one GitHub client (nova-tools #4343): every REST call a nova-sprint verb makes goes through Client.Do, which counts the call per verb and endpoint in Redis, records X-RateLimit-Remaining, paces writes through one per-second writer, and retries a 403 or 429 secondary limit after its Retry-After instead of surfacing it. Never GraphQL. The class tests in this package hold the boundary: no other package shells out to gh, imports go-github or names the REST root.

Measured 2026-09-26 (Glenn ~11:42 AM ET): the hand landing scripts polled gh 60-80 times per landing, children each ran `gh pr checks --watch`, the reconciler and lander read PR state by REST where the webhook ingest had already written it to ev:github, and no rate header was ever read.

Keys (no TTL, #keys-do-not-expire; the budget read prunes buckets older than a day):

gh:calls                          SET   "<verb> <endpoint>" (the index; no SCAN)
gh:calls:<verb>:<endpoint>        HASH  <unix minute> -> calls in that minute
gh:rate                           HASH  remaining, limit, reset, at, verb, endpoint
gh:writer                         HASH  <unix second> -> writes made in it

Index

Constants

View Source
const DefaultAPI = "https://api.github.com"

DefaultAPI is the REST root.

View Source
const DefaultRate = 1

DefaultRate is the writes per second the writer paces to. GitHub's secondary limit is 80 content-generating requests a minute; one a second keeps a burst of 100 cut cards under it.

View Source
const DefaultRetries = 3

DefaultRetries is how many times a secondary limit is retried.

View Source
const DefaultRetryAfter = time.Minute

DefaultRetryAfter is the wait when a secondary-limit reply carries no Retry-After (GitHub's own advice: at least one minute).

View Source
const IndexKey = "gh:calls"

IndexKey is the set of "<verb> <endpoint>" members with a calls hash.

View Source
const Keep = 24 * time.Hour

Keep is how long a minute bucket stays before the budget read prunes it.

View Source
const MaxBody = 8 << 20

MaxBody is the most of a reply body that is read.

View Source
const RateKey = "gh:rate"

RateKey is the last rate record: remaining, limit, reset, at, verb, endpoint.

View Source
const TotalKey = IndexKey + ":all"

TotalKey is every verb's calls by minute, the one HGETALL the sprint table's tick reads for its gh n/h.

View Source
const Window = time.Hour

Window is what `gh budget` and the status line count over.

View Source
const WriterKey = "gh:writer"

WriterKey is the writer's pace: one field per unix second (the store's TIME, so benches with different clocks share one second), the writes made in it. Every field older than the previous second is deleted in the same call, so the hash holds at most two seconds whatever the idle gap between writes, and needs no TTL.

Variables

View Source
var Clock = time.Now

Clock is the wall for Await; a test injects its own.

View Source
var ErrBudget = errors.New("REST budget spent")

ErrBudget is returned once the client's per-run budget is spent.

View Source
var ErrQuota = errors.New("GitHub primary quota exhausted")

ErrQuota is the primary quota at zero: the call is refused, not retried, and the refusal prints the reset time.

Functions

func Await

func Await(ctx context.Context, rdb redis.Cmdable, tip string, block time.Duration, match Match) (string, bool, error)

Await reads ev:github after tip until an entry matches or block passes (block <= 0 reads what is there and does not block): a batch of entries that match nothing keeps the wait blocking for the time left. It returns the cursor to continue from and whether an entry matched; entries after the match are left for the next wait. The deadline is Clock's; under a millisecond left the wait returns, since go-redis sends a sub-millisecond BLOCK as BLOCK 0, which is forever.

func AwaitAt

func AwaitAt(ctx context.Context, rdb redis.Cmdable, tip string, block time.Duration, match Match, now func() time.Time) (string, bool, error)

AwaitAt is Await on the given clock.

func CallsKey

func CallsKey(verb, endpoint string) string

CallsKey is the minute-bucket hash of one verb and endpoint.

func Endpoint

func Endpoint(method, path string) string

Endpoint is the shape a call is counted under: METHOD and the path with the owner/name pair after repos as {repo}, every number as {n}, every 40-hex sha as {sha}, and no query. GET /repos/o/r/pulls/12 and GET /repos/o/r/pulls/13 are the same endpoint.

func HourCalls

func HourCalls(ctx context.Context, rdb redis.Cmdable, now time.Time) (int64, error)

HourCalls is the window's calls, for the sprint status line's gh n/h: one HGETALL of TotalKey.

func HourOf

func HourOf(m map[string]string, now time.Time) int64

HourOf sums a minute-bucket hash over the window before now.

func InvalidateRef

func InvalidateRef(ctx context.Context, rdb redis.Cmdable, repo string, n int) error

InvalidateRef drops the copy, so the next read asks GitHub once.

func RefKey

func RefKey(repo string, n int) string

RefKey is the copy of <repo>#<n>, keyed by the repository name without its owner (ev:github carries owner/name, a card's DEPENDS-ON may not).

func Tip

func Tip(ctx context.Context, rdb redis.Cmdable) (string, error)

Tip is the stream's last id, or 0-0 for an empty stream: the cursor a wait starts from so nothing already there is missed by a $ read.

func Token

func Token() (string, error)

Token is the bearer token: GH_TOKEN, then GITHUB_TOKEN, then `gh auth token` (which honours GH_CONFIG_DIR, the fleet's per-seat gh config). The gh run here is the one gh shell-out in nova-sprint (TestOneGitHubClient) and it makes no GitHub call: it reads the login gh stored.

func TokenFrom

func TokenFrom(getenv func(string) string) (string, error)

TokenFrom is Token over the given environment.

func WriteRef

func WriteRef(ctx context.Context, rdb redis.Cmdable, repo string, n int, r Ref, now time.Time) error

WriteRef writes the copy.

Types

type Client

type Client struct {
	API   string
	Token string
	HTTP  *http.Client
	// Verb is what the calls are counted under (land-stream, file, ...).
	Verb string
	// Redis is where the counts, the rate record and the writer's pace live.
	Redis redis.Cmdable
	// Budget caps the requests one run makes; 0 is unlimited. Calls is the
	// requests made so far, retries included.
	Budget int
	Calls  int
	// Retries is the secondary-limit retries made so far.
	Retries int
	// Rate is writes per second; 0 is DefaultRate. MaxRetries is the retries
	// of one call; 0 is DefaultRetries.
	Rate       int
	MaxRetries int
	// Now and Sleep are the clock; a test injects both.
	Now   func() time.Time
	Sleep func(ctx context.Context, d time.Duration) error
	// Log takes the retry and refusal lines; nil is stderr.
	Log io.Writer
	// Env reads the token environment; nil is os.Getenv.
	Env func(string) string
	// contains filtered or unexported fields
}

Client is the one GitHub client. A zero API is DefaultAPI; a client from New reads an empty Token once from the environment on its first call, a literal one sends no Authorization (a test's fake forge); a nil HTTP is a 30 s client; a nil Redis counts nothing and paces in-process (a test without a store: every production client has one, TestEveryProductionClientHasAStore); nil Now and Sleep are the wall clock; a nil Log is stderr; a nil Env is os.Getenv.

func New

func New(verb string, rdb redis.Cmdable) *Client

New is the production client of one verb over the store: its token is read once from the environment (Token) on the first call.

func (*Client) CachedRef

func (c *Client) CachedRef(ctx context.Context, repo string, n int) (Ref, bool, error)

CachedRef answers from the copy when it has a terminal answer, or any answer written after since; otherwise it asks GitHub once and writes the copy. since zero means any copy stands (the webhook ingest keeps it current: events over polling).

func (*Client) Close

func (c *Client) Close(ctx context.Context, repo string, n int) error

Close sets a PR's state to closed.

func (*Client) CloseIssue

func (c *Client) CloseIssue(ctx context.Context, repo string, n int) error

CloseIssue sets an issue's state to closed (completed). GitHub closes a "Closes #n" issue only on a merge into the default branch; the lander lands on dev, so it closes the issue itself.

func (*Client) Comment

func (c *Client) Comment(ctx context.Context, repo string, n int, body string) (int64, error)

Comment posts one comment on an issue or PR and returns its id.

func (*Client) CommentBody

func (c *Client) CommentBody(ctx context.Context, repo string, id int64) (string, error)

CommentBody reads one comment's body back (the file verb's read-back).

func (*Client) CreateIssue

func (c *Client) CreateIssue(ctx context.Context, repo, title, body string) (int, string, error)

CreateIssue files an issue and returns its number and html_url.

func (*Client) Do

func (c *Client) Do(ctx context.Context, method, path string, body any, out any) (Response, error)

Do makes one call: path is the REST path (/repos/o/r/pulls), body is marshalled as JSON when not nil, out is decoded from a 2xx reply when not nil. A write is paced by the writer; a 403 or 429 secondary limit is retried after Retry-After; a non-2xx reply is an *HTTPError with the body; the budget spent is ErrBudget. Every request made is counted.

func (*Client) IssueBody

func (c *Client) IssueBody(ctx context.Context, repo string, n int) (string, error)

IssueBody reads an issue's body: the import's read (issue -> card).

func (*Client) Issues

func (c *Client) Issues(ctx context.Context, repo string, q url.Values, out any) error

Issues lists /repos/{repo}/issues with the query (state, since, ...).

func (*Client) MergePR

func (c *Client) MergePR(ctx context.Context, repo string, n int, sha, title string) (string, error)

MergePR merges the PR at exactly sha (GitHub refuses when the head moved) and returns the merge commit.

func (*Client) OpenPR

func (c *Client) OpenPR(ctx context.Context, repo, head, base, title, body string) (int, error)

OpenPR opens head -> base and returns its number.

func (*Client) PRBody

func (c *Client) PRBody(ctx context.Context, repo string, n int) (string, error)

PRBody reads a PR's body: the landed record's one read, for a member whose record does not say which issues it closes.

func (*Client) RateLimit

func (c *Client) RateLimit(ctx context.Context) (core, graphql int, err error)

RateLimit is GET /rate_limit: the core and graphql remaining, by REST.

func (*Client) Ref

func (c *Client) Ref(ctx context.Context, repo string, n int) (Ref, error)

Ref reads <repo>#<n> from GitHub: GET pulls/n answers a PR; a 404 there reads the number as an issue. One or two calls, both counted.

func (*Client) ViewPR

func (c *Client) ViewPR(ctx context.Context, repo string, n int) (PR, error)

ViewPR reads one PR: state, merged, head, mergeable_state, title, body.

type HTTPError

type HTTPError struct {
	Method  string
	Path    string
	Status  int
	Body    string
	Message string // the reply's message field when it has one
}

HTTPError is a non-2xx reply.

func (*HTTPError) Error

func (e *HTTPError) Error() string

type Match

type Match func(fields map[string]any) bool

Match decides whether one ev:github entry is the one waited for.

func HeadEvent

func HeadEvent(head string) Match

HeadEvent matches a check_run, check_suite, workflow_run or pull_request entry for head: the deliveries that move a head's check state.

func PREvent

func PREvent(repo, n string) Match

PREvent matches a pull_request entry for repo#n (closed, merged, ...).

type PR

type PR struct {
	Number         int    `json:"number"`
	State          string `json:"state"`
	Merged         bool   `json:"merged"`
	MergeCommitSHA string `json:"merge_commit_sha"`
	MergeableState string `json:"mergeable_state"`
	Title          string `json:"title"`
	Body           string `json:"body"`
	HTMLURL        string `json:"html_url"`
	Head           struct {
		SHA string `json:"sha"`
		Ref string `json:"ref"`
	} `json:"head"`
	Base struct {
		Ref string `json:"ref"`
	} `json:"base"`
}

PR is the fields of GET /repos/{repo}/pulls/{n} the verbs read.

type Rate

type Rate struct {
	Remaining int
	Limit     int
	Reset     time.Time
	At        time.Time
	Verb      string
	Endpoint  string
	Found     bool
}

Rate is the last X-RateLimit record.

type Ref

type Ref struct {
	IsPR   bool
	Merged bool
	State  string // open or closed
	Base   string // the PR's base branch
	At     string // when the copy was written (unix seconds)
}

Ref is the state of one reference.

func ReadRef

func ReadRef(ctx context.Context, rdb redis.Cmdable, repo string, n int) (Ref, bool, error)

ReadRef reads the copy; found is false when there is none.

func (Ref) Terminal

func (r Ref) Terminal() bool

Terminal is true when the answer cannot change: a merged or closed PR, a closed issue.

type Report

type Report struct {
	Hour int64 // every call in the window
	Rows []Row // by hour desc, then verb, endpoint
	Rate Rate
}

Report is what `gh budget` prints.

func Budget

func Budget(ctx context.Context, rdb redis.Cmdable, now time.Time) (Report, error)

Budget reads the hour's calls per verb and endpoint and the rate record. One SMEMBERS, one pipeline of HGETALLs, one HGETALL; buckets older than Keep are deleted in a second pipeline (the read is the pruner, no TTL).

func (Report) Lines

func (r Report) Lines() []string

Lines is the budget as `gh budget` prints it: the GH BUDGET headline, then one GH line per verb and endpoint with a call in the hour.

type Response

type Response struct {
	Status    int
	Body      []byte
	Remaining int
}

Response is one reply. Remaining is X-RateLimit-Remaining, -1 when the reply carried none.

type Row

type Row struct {
	Verb     string
	Endpoint string
	Hour     int64
	Total    int64
}

Row is one verb and endpoint's calls: Hour in the window, Total in every kept bucket.

Jump to

Keyboard shortcuts

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