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
- Variables
- func Await(ctx context.Context, rdb redis.Cmdable, tip string, block time.Duration, ...) (string, bool, error)
- func AwaitAt(ctx context.Context, rdb redis.Cmdable, tip string, block time.Duration, ...) (string, bool, error)
- func CallsKey(verb, endpoint string) string
- func Endpoint(method, path string) string
- func HourCalls(ctx context.Context, rdb redis.Cmdable, now time.Time) (int64, error)
- func HourOf(m map[string]string, now time.Time) int64
- func InvalidateRef(ctx context.Context, rdb redis.Cmdable, repo string, n int) error
- func RefKey(repo string, n int) string
- func Tip(ctx context.Context, rdb redis.Cmdable) (string, error)
- func Token() (string, error)
- func TokenFrom(getenv func(string) string) (string, error)
- func WriteRef(ctx context.Context, rdb redis.Cmdable, repo string, n int, r Ref, ...) error
- type Client
- func (c *Client) CachedRef(ctx context.Context, repo string, n int) (Ref, bool, error)
- func (c *Client) Close(ctx context.Context, repo string, n int) error
- func (c *Client) CloseIssue(ctx context.Context, repo string, n int) error
- func (c *Client) Comment(ctx context.Context, repo string, n int, body string) (int64, error)
- func (c *Client) CommentBody(ctx context.Context, repo string, id int64) (string, error)
- func (c *Client) CreateIssue(ctx context.Context, repo, title, body string) (int, string, error)
- func (c *Client) Do(ctx context.Context, method, path string, body any, out any) (Response, error)
- func (c *Client) IssueBody(ctx context.Context, repo string, n int) (string, error)
- func (c *Client) Issues(ctx context.Context, repo string, q url.Values, out any) error
- func (c *Client) MergePR(ctx context.Context, repo string, n int, sha, title string) (string, error)
- func (c *Client) OpenPR(ctx context.Context, repo, head, base, title, body string) (int, error)
- func (c *Client) PRBody(ctx context.Context, repo string, n int) (string, error)
- func (c *Client) RateLimit(ctx context.Context) (core, graphql int, err error)
- func (c *Client) Ref(ctx context.Context, repo string, n int) (Ref, error)
- func (c *Client) ViewPR(ctx context.Context, repo string, n int) (PR, error)
- type HTTPError
- type Match
- type PR
- type Rate
- type Ref
- type Report
- type Response
- type Row
Constants ¶
const DefaultAPI = "https://api.github.com"
DefaultAPI is the REST root.
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.
const DefaultRetries = 3
DefaultRetries is how many times a secondary limit is retried.
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).
const IndexKey = "gh:calls"
IndexKey is the set of "<verb> <endpoint>" members with a calls hash.
const Keep = 24 * time.Hour
Keep is how long a minute bucket stays before the budget read prunes it.
const MaxBody = 8 << 20
MaxBody is the most of a reply body that is read.
const RateKey = "gh:rate"
RateKey is the last rate record: remaining, limit, reset, at, verb, endpoint.
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.
const Window = time.Hour
Window is what `gh budget` and the status line count over.
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 ¶
var Clock = time.Now
Clock is the wall for Await; a test injects its own.
var ErrBudget = errors.New("REST budget spent")
ErrBudget is returned once the client's per-run budget is spent.
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 Endpoint ¶
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 ¶
HourCalls is the window's calls, for the sprint status line's gh n/h: one HGETALL of TotalKey.
func InvalidateRef ¶
InvalidateRef drops the copy, so the next read asks GitHub once.
func RefKey ¶
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 ¶
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 ¶
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.
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 ¶
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 ¶
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) CloseIssue ¶
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) CommentBody ¶
CommentBody reads one comment's body back (the file verb's read-back).
func (*Client) CreateIssue ¶
CreateIssue files an issue and returns its number and html_url.
func (*Client) Do ¶
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) 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) PRBody ¶
PRBody reads a PR's body: the landed record's one read, for a member whose record does not say which issues it closes.
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.
type Match ¶
Match decides whether one ev:github entry is the one waited for.
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.
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.