discovery

package
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Nov 13, 2026 License: MIT Imports: 21 Imported by: 0

README

discovery

Keeps a private cache of each picked repository's open pull requests (the local app's inbox), read from GitHub with the user's token. It makes no git fetch, no generation and no write to GitHub. Cached and CachedStable never make a request; Poll, PollNow and Probe do, on the credential's shared budget.

The cache lives in app.sqlite under cache/discovery/<hash>, partitioned by host, credential identity and repository. The credential's record (<hash>.budget) holds its REST and GraphQL budgets and its team memberships, which every repository shares.

Decoded once per change. A service keeps each cache document it read or wrote, and decodes the fields readers use (the snapshot, the probe's fingerprint, the budgets) once, on first use. Cached, Probe and the budget share that decoded copy until the document changes; Cached gives each caller its own pull request and observation slices, and nothing may write through their pointers. Polls, Observe, Merged and Unresolved decode a copy of their own to change. The kept copy is trusted while app.sqlite and its write-ahead log (app.sqlite-wal) keep the identity, size and modification time they had when it was read. A commit appends to the log and leaves app.sqlite as it was until a checkpoint copies the log into it, so the log's stat is what moves with each write. When they changed, or were modified within the last three seconds (racyWindow), the document is read again and decoded only if its bytes differ, so another process or service writing the cache is seen at the next read. The database is only stat'ed, never opened as an ordinary file.

Unchanged polls are not written. A successful or failed poll that changed nothing but its times (fetched, next poll, rate, backoff interval and last sweep) keeps them in memory instead of rewriting the document, as long as the disk copy is less than unsavedLimit (an hour) old. Cached, Revision and the next poll see the new times; any other change (a pull request, an observation, a permission answer) writes the whole document with them, in one transaction as before. A restart, or another process, reads the older times on disk, which only brings a poll or sweep forward. A save whose bytes equal the disk copy's is skipped too.

Revision stamps what Cached would read, without opening the database: the partition, the identity epoch, whether the last poll validated the cache, and how many times this service changed it. Two equal revisions read the same snapshot, except that Stale turns true once NextPoll passes. The count lives in memory, so it holds only because the daemon's service is the cache's one writer: the daemon runs once, and nothing else writes cache/discovery. Live sessions and the review index use it to read a cache again only after it changed. Updated, when set, is called after each write of a repository's cache, an identity reset and each change to live freshness. A write of the budget record alone (a probe saves only that) is not reported, since Cached does not read it.

CachedStable returns the same data without making it stale merely because NextPoll passed. The review index uses this form: an error or a cache not yet validated in this process remains stale, but waiting for the next poll does not change every row. Cached retains deadline-based staleness for readers that use it to decide whether to fetch fresh data.

A poll

Most polls are incremental. A poll reads the window of pull requests updated since the cursor, newest first and of any state, 25 to a page (see Time limits for smaller pages). It stops after the first page that reaches below the cursor less a two-minute overlap. The overlap re-reads a pull request updated in the cursor's second, or one that GitHub recorded a little late. An open pull request replaces its cached row; a closed or merged one leaves the cache. If GitHub reads the same pull request twice in one poll, the older copy never replaces the newer. The cursor is GitHub's updatedAt, never this computer's clock. An update time recorded more than the overlap behind a newer one waits for the next sweep, at most sweepInterval away. A window longer than windowPageLimit (20) pages, as after days asleep, gives way to a fresh sweep.

The window also reads GitHub's count of open pull requests and the newest one's number. When the cache disagrees with either, the same poll runs a full sweep. That happens when a pull request was deleted, or when a change was missed for any other reason.

Some changes leave a pull request's updatedAt as it was. An incremental poll therefore re-reads by number, in aliased queries of a page each, at most 50:

  • pull requests whose mergeability GitHub is still working out while the poll settles after a push (settleInterval);
  • open pull requests on a base that a pull request in the window merged into, which GitHub works out again once asked;
  • pull requests whose checks are still running.

A finished check does not show in the probe either, so while any open pull request has checks running the polls stay 30 seconds apart rather than backing off: the checks' result shows within about a minute. Checks that never report stop holding the interval once the pull requests have stayed as they are for checksHold (an hour).

A full sweep reads every open pull request, the most recently updated first. It runs on the first poll, after a cache-format upgrade, every sweepInterval (10 minutes) and when the window's count disagrees.

A sweep cut short by a timeout or an error keeps the pages it read and saves its GraphQL cursor; the next poll goes on from there. Until a first sweep finishes, each poll also reads the window from GitHub's latest update of any pull request when that sweep began, which also becomes the cursor. Once a sweep finishes, the cached pull requests it did not see, and those of an older format's snapshot, are asked about by number, a page to a query. One is dropped only when GitHub says it closed, merged or no longer exists, however the pages shifted meanwhile.

Partial results. Pages read before a failed request are merged into the cache and shown, stale, with the error. Nothing is dropped because a failed poll did not see it. The cursor moves only once the poll has read its whole window and the re-reads by number it called for. A poll that read a new head and then failed still settles. Authors not yet asked about since an upgrade keep the older snapshot's permission.

Upgrades. A cache of an older format (before 3) keeps its snapshot, which stays shown. The first sweep updates it pull request by pull request; it is replaced only when the sweep finishes. Its conditional responses are dropped and not reused.

Teams, permissions and budgets

  • /user/teams is read once per credential every teamsInterval (10 minutes), and every repository's poll uses that answer. A 401, 403 or 404 (no read:org) is kept as "membership unknown" for as long. A failed request keeps the previous answer.
  • An author's write permission (/collaborators/<login>/permission) is kept per repository for permissionTTL (1 hour), and a 403 or 404 (unknown) is kept as long as any other answer. A rate limit or a failed request is not kept; the next poll asks again.
  • The REST and GraphQL budgets are kept in memory per credential and shared by every poll and probe under way. Each request checks them first, so a rate limit one poll meets stops the next request of the others. Within one rate window the lower remaining count stands. They are saved with each poll and merged with the saved record at the start of the next, so a restart honours them. GraphQLBudget reads the GraphQL budget without a request, so the background refresh can leave discovery and generation their share.

Time limits and concurrency

A poll has no fixed deadline: a sweep of a busy repository takes minutes. Its callers pass a WithIdleTimeout context instead, which ends the poll once GitHub has not answered one of its requests for 45 seconds (the background service and the app's refresh). The timeout pauses while the poll waits for its repository's lock or for another poll's read of /user/teams. A probe has a fixed 45-second deadline. A poll or probe that runs out of time returns ErrTimeout, and what it read is saved.

GitHub gives up on a GraphQL query after about ten seconds and answers 502, 503 or 504. A query asks for 25 pull requests (inboxPageSize); one that gets a gateway error is asked again, up to four times, for half as many (at least 5), and the smaller size stays for the rest of the poll. A sweep that still fails keeps its cursor. An identity reset (ResetIdentity) during a poll returns github.ErrIdentityChanged and discards what the poll read. A credential that changes under a poll's requests fails them, and what was read before stays in the old credential's partition.

Polls of different repositories run at the same time. Each repository has its own lock. Poll, Observe, Merged and Unresolved take that lock, so a call for one repository does not wait for another repository's GitHub requests. The exceptions are brief: a poll may wait for another's read of /user/teams, and for a cache write. Probe takes no repository lock. A repository's lock is still held across its own poll's GitHub requests. Every cache and budget write goes through one service-wide write lock, so app.sqlite has no more concurrent writers from discovery than before.

Probe

Probe asks in one GraphQL query per 50 repositories for each repository's newest open pull request, open count and latest update. It compares them with the fingerprint the last successful poll left. A repository that differs is polled at once.

Tests

just oss-go test -race ./internal/discovery ./internal/background

Documentation

Overview

Package discovery maintains a private GitHub inbox cache. It performs no git fetch, generation or write to GitHub.

Index

Constants

This section is empty.

Variables

View Source
var ErrTimeout = errors.New("GitHub discovery timed out")

ErrTimeout is a poll or probe that ran out of time. Whatever it read before is kept.

View Source
var PushedDelays = []time.Duration{0, 5 * time.Second, 10 * time.Second, 15 * time.Second, 30 * time.Second}

PushedDelays are the waits between the polls after baum itself pushed a pull request's head (a fix). A poll right after the push finds the head before GitHub has queued its checks: no checks and a clean merge state, which reads as ready to merge. The checks usually appear within seconds, and neither the probe (check results do not move a pull request's update time) nor the settle poll (the background scheduler does not see a poll it did not make) would notice them before the usual backoff.

Functions

func AfterPush

func AfterPush(ctx context.Context, poll func(context.Context, github.Repository) (Snapshot, error), repository github.Repository, number int, head string, changed func(), delays []time.Duration)

AfterPush polls repository at PushedDelays (delays, in tests) until the pull request shows head with its checks reported, or is no longer open, and calls changed after each poll that succeeded. A repository without checks reports none for every poll, so the polls stop with the delays. poll is Service.PollNow, which still honors the shared rate budgets.

func DefaultDirectory

func DefaultDirectory() (string, error)

DefaultDirectory is the shared application database directory.

func WithIdleTimeout added in v0.2.0

func WithIdleTimeout(parent context.Context, idle time.Duration) (context.Context, context.CancelFunc)

WithIdleTimeout returns a context for a poll that is cancelled once idle passes without GitHub answering one of the poll's requests. A poll reads every open pull request, so how long it takes grows with the repository; a fixed deadline long enough for the busiest repository would also leave a stalled connection hanging that long.

The timeout is paused while the poll waits for its locks: a poll queued behind another one, which may itself take minutes, is not waiting on GitHub.

Types

type Observation

type Observation struct {
	Identity  string    `json:"-"`
	Number    int       `json:"number"`
	HeadSHA   string    `json:"head_sha"`
	Title     string    `json:"title"`
	State     string    `json:"state"`
	Draft     bool      `json:"draft"`
	FetchedAt time.Time `json:"fetched_at"`
	Checks    string    `json:"checks"`
}

Observation contains only safe current GitHub metadata, never description bodies, signed image URLs or session contents.

type PullRequest

type PullRequest struct {
	Repository string `json:"repository"`
	Number     int    `json:"number"`
	HeadSHA    string `json:"head_sha"`
	// HeadRef is the head branch's name, which links a local session on that
	// branch to the pull request. It is empty in a snapshot cached before it
	// was kept, until the next poll.
	HeadRef string `json:"head_ref"`
	// HeadRepository is the canonical host/repository owning the head branch.
	HeadRepository string `json:"head_repository,omitempty"`
	// BaseRef is the base branch's name; a pull request whose base is
	// another open pull request's head branch is stacked on it. Empty in a
	// snapshot cached before it was kept, until the next poll.
	BaseRef string `json:"base_ref"`
	// Additions, Deletions and ChangedFiles size the change as GitHub counts
	// it. They are zero in a snapshot cached before they were kept, until the
	// next poll.
	Additions    int    `json:"additions"`
	Deletions    int    `json:"deletions"`
	ChangedFiles int    `json:"changed_files"`
	Title        string `json:"title"`
	URL          string `json:"url"`
	Author       string `json:"author"`
	// AuthorBot is true for a GitHub App or other bot author. Snapshots cached
	// before it existed decode as false.
	AuthorBot             bool   `json:"author_bot"`
	State                 string `json:"state"`
	Draft                 bool   `json:"draft"`
	Fork                  bool   `json:"fork"`
	AuthoredByMe          bool   `json:"authored_by_me"`
	ReviewRequestedFromMe bool   `json:"review_requested_from_me"`
	// AssignedToMe is true when the viewer is one of the first 20 assignees.
	// Snapshots cached before it existed decode as false.
	AssignedToMe bool `json:"assigned_to_me"`
	// Assigned is true when the pull request has at least one of the first 20
	// assignees fetched, whoever they are. Snapshots cached before it existed
	// decode as false.
	Assigned bool `json:"assigned"`
	// ReviewRequested is true when a review is requested from at least one
	// user or team, whoever they are. Snapshots cached before it existed
	// decode as false.
	ReviewRequested bool `json:"review_requested"`
	// Nil means the token could not establish repository write permission.
	AuthorHasWrite *bool `json:"author_has_write"`
	// ViewerCanPush is whether the signed-in account may push to the
	// repository (GitHub's viewerPermission is write, maintain or admin),
	// which a fix needs; nil when GitHub did not say.
	ViewerCanPush *bool `json:"viewer_can_push"`
	// False means team membership was unavailable; false review-request flags
	// must not be interpreted as proof that no team requested this viewer.
	TeamMembershipKnown bool   `json:"team_membership_known"`
	Review              string `json:"review"`
	// ReviewRequired is true only when GitHub's reviewDecision is REVIEW_REQUIRED;
	// nil means an older snapshot did not retain the requirement.
	ReviewRequired *bool  `json:"review_required,omitempty"`
	Checks         string `json:"checks"`
	Merge          string `json:"merge"`
	// Behind is GitHub's report (mergeStateStatus BEHIND) that the head
	// branch is out of date with its base and not conflicting, which Update
	// branch fixes. A draft never reports it.
	Behind           bool `json:"behind"`
	Unresolved       *int `json:"unresolved"`
	UnresolvedCapped bool `json:"unresolved_capped"`
	// UpdatedAt is GitHub's last update of the pull request: a push, an edit,
	// a review or a comment. The background refresh re-reads a saved review's
	// evidence when it is newer than the last read.
	UpdatedAt time.Time `json:"updated_at,omitzero"`
}

type RateLimit

type RateLimit struct {
	Cost      *int      `json:"cost,omitempty"`
	Remaining *int      `json:"remaining"`
	Reset     time.Time `json:"reset"`
	RetryAt   time.Time `json:"retry_at"`
}

type Revision added in v0.2.0

type Revision struct {
	Epoch     uint64
	Partition string
	Writes    uint64
	Validated bool
}

Revision stamps what Cached reads of a repository without opening the cache: Cached reads the same snapshot at two equal revisions, except that its Stale turns true once NextPoll passes. It changes when the identity resets or the credential changes, when this service changes the repository's cache (store, including times it keeps unsaved), and when a poll validates it or fails. Only this service writes the cache, and only the daemon, which runs once, makes one on app.sqlite.

type Service

type Service struct {

	// Updated, when set before use, is called after anything Cached reads
	// changes: a repository's cache write, an identity reset, or live
	// freshness. A write of the credential's budget record alone, which
	// Cached does not read, is not reported. It may run with the service's
	// locks held, so it must not call the service.
	Updated func()
	// contains filtered or unexported fields
}

func New

func New(client *github.Client, directory string) *Service

New uses app.sqlite in directory, or the shared application database when empty.

func (*Service) Cached

func (s *Service) Cached(ctx context.Context, repository github.Repository) (Snapshot, error)

Cached never makes a network request. Resolving the configured local token partitions results by credential, so changing accounts cannot reuse an inbox. Disk snapshots are always stale until a successful conditional poll.

func (*Service) CachedStable added in v0.2.0

func (s *Service) CachedStable(ctx context.Context, repository github.Repository) (Snapshot, error)

CachedStable reports data freshness without aging a successful poll at its next deadline. Errors and caches not validated in this process remain stale. The review index uses it so the passage of time does not change its rows.

func (*Service) Epoch

func (s *Service) Epoch() uint64

func (*Service) GraphQLBudget added in v0.2.0

func (s *Service) GraphQLBudget(ctx context.Context, repository github.Repository) (RateLimit, error)

GraphQLBudget is the credential's GraphQL points budget as discovery last read it, for another reader of the same token, such as the background refresh, to leave discovery its share. It makes no request. Remaining is nil while no poll or probe has read it, and a Reset that has passed means a new window GitHub has not reported yet.

func (*Service) Merged

func (s *Service) Merged(ctx context.Context, repository github.Repository, number int, head string) error

Merged records that the app just merged the pull request at head: GitHub accepted the merge, so it leaves the open pull requests and is observed merged without another read. Its title and draft state are the cached ones.

func (*Service) Observe

func (s *Service) Observe(ctx context.Context, repository github.Repository, value Observation) error

Observe retains fresh free metadata for open and saved historical PRs. A separate service instance still treats the disk cache as stale on startup. An observation older than the one kept is dropped: a read under way when the app merged the pull request (Merged) does not undo it.

func (*Service) Poll

func (s *Service) Poll(ctx context.Context, repository github.Repository) (Snapshot, error)

Poll refreshes the open pull requests and conditional REST metadata without waiting through a rate limit. Callers schedule NextPoll; unchanged results back off from 30s to 15m. Failure returns the cached snapshot, with what this poll did read merged into it, explicitly stale, and an error.

func (*Service) PollNow

func (s *Service) PollNow(ctx context.Context, repository github.Repository) (Snapshot, error)

PollNow is Poll without waiting for NextPoll: something local (a push, an agent session ending) says the pull requests changed. It still honors the shared REST and GraphQL rate budgets.

func (*Service) Probe

func (s *Service) Probe(ctx context.Context, repositories []github.Repository) (map[string]bool, error)

Probe asks GitHub whether each repository's open pull requests changed since its last complete poll, and returns the ones that did, keyed by Repository.String(). It sends one GraphQL query per probeBatch repositories, about one point each, on the polls' shared budget. A repository never polled, or one GitHub does not return (renamed, or access lost), is left to its scheduled poll, as is one whose credential differs from the first repository's. It takes no repository's lock, so polls under way do not hold it up.

func (*Service) ResetIdentity

func (s *Service) ResetIdentity()

ResetIdentity cancels the old transactions and invalidates live discovery. Disk partitions remain private and are keyed by their original credential.

func (*Service) Revision added in v0.2.0

func (s *Service) Revision(ctx context.Context, repository github.Repository) (Revision, error)

Revision never makes a request and reads nothing from disk.

func (*Service) Unresolved

func (s *Service) Unresolved(ctx context.Context, repository github.Repository, number, count int) (bool, error)

Unresolved records the count of an open pull request's unresolved review threads that the app just read from GitHub, ahead of the next poll: GitHub reports no update to a pull request when a thread is resolved, so a probe cannot see it. It reports whether the count changed.

type Snapshot

type Snapshot struct {
	Epoch        uint64        `json:"-"`
	Repository   string        `json:"repository"`
	Observations []Observation `json:"observations,omitempty"`
	Viewer       string        `json:"viewer"`
	PullRequests []PullRequest `json:"pull_requests"`
	Stale        bool          `json:"stale"`
	FetchedAt    time.Time     `json:"fetched_at"`
	NextPoll     time.Time     `json:"next_poll"`
	Rate         RateLimit     `json:"rate"`
	Error        string        `json:"error,omitempty"`
	// Settling: this poll found a new head or a mergeability GitHub forgot,
	// or GitHub still works one out since, so NextPoll comes after
	// settleInterval.
	Settling bool `json:"settling,omitempty"`
}

Jump to

Keyboard shortcuts

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