Documentation
¶
Overview ¶
Package dashboard holds the domain model both forge clients produce and the aggregator that merges them into one snapshot. Neither forge client depends on the other; both only depend on this package's types.
Index ¶
- Constants
- func WebhookTargetsPath(rawURL, path string) bool
- type Aggregator
- func (a *Aggregator) Get() Snapshot
- func (a *Aggregator) Refresh(ctx context.Context)
- func (a *Aggregator) RefreshRepo(ctx context.Context, forge Forge, owner, name, fullName string) bool
- func (a *Aggregator) Run(ctx context.Context, interval time.Duration)
- func (a *Aggregator) Subscribe() (<-chan Snapshot, func())
- type BranchUpdater
- type CIStatus
- type ClientError
- type Forge
- type ForgeClient
- type ForgeErrorKind
- type ForgeHealth
- type GenericSource
- func (s *GenericSource) EnsureWebhook(ctx context.Context, owner, name, targetURL, secret string) error
- func (s *GenericSource) Fetch(ctx context.Context) Result
- func (s *GenericSource) FetchRepo(ctx context.Context, owner, name, fullName string) ([]PullRequest, []Issue, error)
- func (s *GenericSource) Forge() Forge
- func (s *GenericSource) MergePullRequest(ctx context.Context, owner, name string, number int) error
- func (s *GenericSource) UpdateBranch(ctx context.Context, owner, name string, number int) (bool, error)
- type Issue
- type Label
- type Manager
- func (m *Manager) Ensure(ctx context.Context, userID []byte, sources []Source)
- func (m *Manager) EnsureIfAbsent(ctx context.Context, userID []byte, sources []Source)
- func (m *Manager) Get(userID []byte) Snapshot
- func (m *Manager) RefreshNow(ctx context.Context, userID []byte) bool
- func (m *Manager) RefreshRepo(ctx context.Context, userID []byte, forge Forge, owner, name, fullName string) bool
- func (m *Manager) Remove(userID []byte)
- func (m *Manager) Running(userID []byte) bool
- func (m *Manager) Stop()
- func (m *Manager) Subscribe(userID []byte) (<-chan Snapshot, func(), bool)
- type MergeStatus
- type PullRequest
- type PullRequestMerger
- type RateLimit
- type RateLimiter
- type Repo
- type RepoRef
- type RepoRefresher
- type Result
- type Snapshot
- type Source
- type WebhookChecker
- type WebhookManager
Constants ¶
const DefaultMaxConcurrency = 8
DefaultMaxConcurrency bounds how many repositories a GenericSource fetches at once. Generous enough to make quick work of a ~100-repo account without opening that many sockets at once for no benefit.
Variables ¶
This section is empty.
Functions ¶
func WebhookTargetsPath ¶ added in v0.54.0
WebhookTargetsPath reports whether rawURL's path component is exactly path — how a Source recognizes "this hook is the one forge-dashboard itself would have created," regardless of the scheme or host a webhook was configured against. Matching on the path alone, not the full URL, is deliberate: this process may sit behind a reverse proxy whose public origin it has no independent way to know, and the webhook token in the path is already unique enough (256 bits of randomness) that a path match carries no real collision risk. An unparseable rawURL, or an empty path, never matches.
Types ¶
type Aggregator ¶
type Aggregator struct {
// contains filtered or unexported fields
}
Aggregator holds the most recently fetched Snapshot and refreshes it in the background. Get never blocks on a forge: it always returns whatever was last successfully assembled, even if a refresh is running right now or every source just failed.
func NewAggregator ¶
func NewAggregator(sources []Source) *Aggregator
NewAggregator returns an Aggregator whose Get answers an empty snapshot until the first Refresh (or Run) completes.
func (*Aggregator) Get ¶
func (a *Aggregator) Get() Snapshot
Get returns the current snapshot. Safe to call concurrently with Refresh.
func (*Aggregator) Refresh ¶
func (a *Aggregator) Refresh(ctx context.Context)
Refresh fetches every source concurrently and replaces the snapshot with the merged result, and does not return until a pass that started at or after this call has completed — see coalescer's doc comment for the guarantee and the incident behind it. A source's own Fetch never returns an error — a forge it can't reach at all shows up as an unreachable ForgeHealth entry instead, so one broken forge never drops the other's data.
func (*Aggregator) RefreshRepo ¶ added in v0.39.0
func (a *Aggregator) RefreshRepo(ctx context.Context, forge Forge, owner, name, fullName string) bool
RefreshRepo refreshes just the named repository's pull requests and issues, merging the result into the current snapshot in place of that repo's previous entries rather than replacing the whole snapshot — the scoped counterpart to Refresh, for a webhook delivery that already knows exactly which repo changed. Reports false if no configured Source drives forge, or if the one that does can't do a scoped fetch (RepoRefresher, the same optional-capability pattern as RateLimiter) — the caller's own signal to fall back to a full Refresh, so a repo never silently goes unrefreshed just because its Source can't do this yet.
Concurrent calls for the same repo coalesce the same way Refresh's own calls do — see coalescer — keyed so an unrelated repo's own refresh never waits on this one.
func (*Aggregator) Run ¶
func (a *Aggregator) Run(ctx context.Context, interval time.Duration)
Run refreshes immediately, then again every interval, until ctx is canceled. Meant to run in its own goroutine for the lifetime of the process.
func (*Aggregator) Subscribe ¶ added in v0.23.0
func (a *Aggregator) Subscribe() (<-chan Snapshot, func())
Subscribe returns a channel that receives the new Snapshot after every completed Refresh, and a function to stop receiving them. The channel is buffered by exactly one — a subscriber that falls behind (an SSE client whose write is blocked on a slow network) never makes Refresh itself block; it just misses an intermediate update and catches up on the next one. Call the returned function when done, typically via defer: it closes the channel, so a range over it ends cleanly.
type BranchUpdater ¶ added in v0.63.0
type BranchUpdater interface {
UpdateBranch(ctx context.Context, owner, name string, number int) (accepted bool, err error)
}
BranchUpdater is implemented by a ForgeClient (or, for github.Client, a Source directly) that can bring one of its own pull requests' head branch up to date with its base — checked via a type assertion, the same optional-capability pattern PullRequestMerger uses. accepted is true only for GitHub's async case (the update was scheduled as a background job, not finished yet, per GitHub's own PullRequestsService .UpdateBranch doc comment); every other outcome — Forgejo's own synchronous update, or GitHub's synchronous 200/201 case — returns false, since the update had already finished by the time this returns.
type CIStatus ¶
type CIStatus string
CIStatus is the combined result across every check reported against a pull request's head commit. Matches components.schemas.CIStatus.
type ClientError ¶ added in v0.52.0
type ClientError struct {
Kind ForgeErrorKind
Err error
}
ClientError carries a ForgeErrorKind classification alongside the underlying error, the same "structured data alongside a message string" shape internal/github's own apiError.rateLimit already uses — promoted to this package because GenericSource.Fetch, which builds ForgeHealth for any ForgeClient-driven forge, can't see an unexported type in the client package that produced the error.
func (*ClientError) Error ¶ added in v0.52.0
func (e *ClientError) Error() string
func (*ClientError) Unwrap ¶ added in v0.52.0
func (e *ClientError) Unwrap() error
type Forge ¶
type Forge string
Forge names one of the two forges this service talks to. Matches components.schemas.Forge in api/openapi.yaml.
type ForgeClient ¶
type ForgeClient interface {
ListRepos(ctx context.Context) ([]RepoRef, error)
ListOpenPullRequests(ctx context.Context, owner, name, repo string) ([]PullRequest, error)
ListOpenIssues(ctx context.Context, owner, name, repo string) ([]Issue, error)
}
ForgeClient is what a forge-specific client (internal/github, internal/forgejo) provides for GenericSource to drive. Both clients' method sets already match this shape, so neither needs an adapter.
type ForgeErrorKind ¶ added in v0.52.0
type ForgeErrorKind string
ForgeErrorKind classifies why a forge is unreachable, or why a write to it was rejected, into a small, coarse set of actionable categories. Matches components.schemas.ForgeErrorKind. Empty string is the zero value: "nothing to classify," only ever meaningful alongside a non-empty ForgeHealth.Error.
const ( ForgeErrorUnreachable ForgeErrorKind = "unreachable" ForgeErrorNotFound ForgeErrorKind = "not_found" ForgeErrorRateLimited ForgeErrorKind = "rate_limited" // ForgeErrorConflict is only ever returned from a pull-request write — // the forge reports the PR itself isn't currently mergeable, either a // real conflict or a state that changed since the dashboard's last // refresh. ForgeErrorConflict ForgeErrorKind = "conflict" ForgeErrorUnknown ForgeErrorKind = "unknown" )
A forge failure only needs to tell the user one of a handful of things to do about it — retry, check a token, check a URL, wait out a rate limit — so this stays as coarse as MergeStatus does, rather than chasing every status code a forge can return.
type ForgeHealth ¶
type ForgeHealth struct {
Forge Forge `json:"forge"`
Reachable bool `json:"reachable"`
Error string `json:"error,omitempty"`
ErrorKind ForgeErrorKind `json:"errorKind,omitempty"`
RepoCount int `json:"repoCount"`
RateLimit *RateLimit `json:"rateLimit,omitempty"`
}
ForgeHealth matches components.schemas.ForgeHealth.
type GenericSource ¶
type GenericSource struct {
// contains filtered or unexported fields
}
GenericSource drives a ForgeClient the same way regardless of which forge it talks to: list repositories, then fetch each one's open pull requests and issues concurrently, bounded, skipping a single repo's failure rather than failing the whole forge.
func NewGenericSource ¶
func NewGenericSource(forge Forge, client ForgeClient, maxConcurrency int) *GenericSource
NewGenericSource returns a Source reporting as forge, driving client with at most maxConcurrency repositories fetched at once.
func (*GenericSource) EnsureWebhook ¶ added in v0.56.0
func (s *GenericSource) EnsureWebhook(ctx context.Context, owner, name, targetURL, secret string) error
EnsureWebhook implements WebhookManager at the Source level by delegating to the underlying client — the same "Source unwraps to its ForgeClient" pattern FetchRepo doesn't need, since ForgeClient's own method set already matches what RepoRefresher wants, but which WebhookManager does need since EnsureWebhook isn't part of the plain ForgeClient interface every client implements.
func (*GenericSource) Fetch ¶
func (s *GenericSource) Fetch(ctx context.Context) Result
Fetch implements Source by listing repos, then fetching each one's open pull requests and issues concurrently, bounded by maxConcurrency. A single repo's failure is logged and skipped, not fatal to the forge.
func (*GenericSource) FetchRepo ¶ added in v0.38.0
func (s *GenericSource) FetchRepo(ctx context.Context, owner, name, fullName string) ([]PullRequest, []Issue, error)
FetchRepo implements RepoRefresher: the same per-repo calls Fetch already makes for every tracked repo, but for just the one a caller (a webhook delivery) already knows the identity of — no ListRepos call needed first.
func (*GenericSource) Forge ¶ added in v0.38.0
func (s *GenericSource) Forge() Forge
Forge implements Source.
func (*GenericSource) MergePullRequest ¶ added in v0.62.0
MergePullRequest implements PullRequestMerger at the Source level by delegating to the underlying client, the same "Source unwraps to its ForgeClient" shape EnsureWebhook already uses.
func (*GenericSource) UpdateBranch ¶ added in v0.63.0
func (s *GenericSource) UpdateBranch(ctx context.Context, owner, name string, number int) (bool, error)
UpdateBranch implements BranchUpdater at the Source level by delegating to the underlying client, the same "Source unwraps to its ForgeClient" shape EnsureWebhook/MergePullRequest already use.
type Issue ¶
type Issue struct {
Forge Forge `json:"forge"`
Repo string `json:"repo"`
Number int `json:"number"`
Title string `json:"title"`
URL string `json:"url"`
Author string `json:"author"`
Labels []Label `json:"labels"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
Issue matches components.schemas.Issue in api/openapi.yaml.
type Label ¶ added in v0.22.0
Label matches components.schemas.Label — a label's name and its real colour from the forge, not just the name.
type Manager ¶ added in v0.8.0
type Manager struct {
// contains filtered or unexported fields
}
Manager owns one Aggregator per user, each refreshing itself in the background on its own schedule — the per-user equivalent of a single global Aggregator, now that each user's dashboard reflects their own saved forge credentials rather than one shared environment-configured set.
func NewManager ¶ added in v0.8.0
NewManager returns a Manager whose per-user Aggregators refresh every refreshInterval.
func (*Manager) Ensure ¶ added in v0.8.0
Ensure (re)builds userID's Aggregator from sources and starts its background refresh, stopping whatever was running for that user before. Call it once after login (using their currently saved credentials) and again every time those credentials are saved — there's no way to add or remove a Source from a running Aggregator, so a changed configuration means a new one.
func (*Manager) EnsureIfAbsent ¶ added in v0.38.1
EnsureIfAbsent is Ensure, but only takes effect if userID has no Aggregator running yet — the check and the insert happen under the same lock, unlike a separate Running() check followed by a call to Ensure(), which races when several requests for the same user land in the same instant (several browser tabs and an SSE reconnect, all arriving right after a restart wipes the Manager clean): each would see "not running" and each would create its own Aggregator with its own immediate Refresh. Concurrent callers here still each do their own harmless work building sources beforehand; only one gets to actually create the Aggregator and kick off its first Refresh.
func (*Manager) Get ¶ added in v0.8.0
Get returns userID's current snapshot — empty, not nil arrays, if Ensure was never called for them (no credentials saved yet).
func (*Manager) RefreshNow ¶ added in v0.23.0
RefreshNow fetches userID's sources immediately, out of band from their regular refreshInterval ticker — the webhook handler's entry point for turning a forge event into an up-to-date snapshot within seconds rather than waiting for the next scheduled refresh. Reports false if userID has no running Aggregator (Ensure was never called for them), rather than silently doing nothing.
func (*Manager) RefreshRepo ¶ added in v0.39.0
func (m *Manager) RefreshRepo(ctx context.Context, userID []byte, forge Forge, owner, name, fullName string) bool
RefreshRepo delegates to userID's Aggregator — see Aggregator.RefreshRepo. Reports false if userID has no running Aggregator, the same as RefreshNow, and also false wherever Aggregator.RefreshRepo itself would — a caller should fall back to RefreshNow either way, without needing to tell the two apart.
func (*Manager) Remove ¶ added in v0.9.0
Remove stops userID's refresh loop and evicts their snapshot — a no-op if they had none running. Used when an admin revokes or deletes an account, so a locked-out user's background refresh doesn't keep polling either forge for no one.
func (*Manager) Running ¶ added in v0.27.1
Running reports whether userID currently has an Aggregator refreshing in the background. False right after a process restart for every user — the Manager holds no state across one — until something re-Ensures them; a caller can use this to tell "nothing saved in Settings" apart from "just needs rewarming from what's already saved."
type MergeStatus ¶ added in v0.50.0
type MergeStatus string
MergeStatus is a pull request's mergeable/blocked state, as coarse as every forge this service talks to can agree on. Matches components.schemas.MergeStatus.
const ( MergeMergeable MergeStatus = "mergeable" MergeConflicting MergeStatus = "conflicting" MergeBlocked MergeStatus = "blocked" MergeUnknown MergeStatus = "unknown" )
A forge only ever reports a handful of real distinctions here — GitHub's own mergeStateStatus has eight values, Forgejo's SDK has one plain bool — so this stays deliberately coarse rather than chasing GitHub's full enum. MergeUnknown covers both "the forge itself doesn't know yet" and "this service couldn't determine it."
type PullRequest ¶
type PullRequest struct {
Forge Forge `json:"forge"`
Repo string `json:"repo"`
Number int `json:"number"`
Title string `json:"title"`
URL string `json:"url"`
Author string `json:"author"`
Draft bool `json:"draft"`
Labels []Label `json:"labels"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
CI CIStatus `json:"ci"`
MergeStatus MergeStatus `json:"mergeStatus"`
// Behind is deliberately its own field, not folded into MergeStatus,
// because the two aren't mutually exclusive on every forge: confirmed
// live against a real Forgejo instance (a PR's own Mergeable field
// stayed true after a new commit landed on its base, since Forgejo
// doesn't recompute it synchronously — see mergeStatusFromMergeable's
// own doc comment) — so a Forgejo PR can be both "mergeable" and
// "behind" at once, and folding this into MergeStatus would force
// picking one and losing the other. GitHub's mergeStateStatus is a
// single enum where BEHIND and CLEAN can't both be true, but this
// field stays orthogonal there too, for the same reason CI sits
// beside MergeStatus rather than inside it: one fact per field.
Behind bool `json:"behind"`
// AutoMergeEnabled is nil when the owning forge has no way to report
// this at all (Forgejo, today) — the same "doesn't report it" shape
// RateLimit's own nilable pointer already uses, so a forge with
// nothing to say here never renders as a false "not enabled."
AutoMergeEnabled *bool `json:"autoMergeEnabled,omitempty"`
}
PullRequest matches components.schemas.PullRequest in api/openapi.yaml.
type PullRequestMerger ¶ added in v0.62.0
type PullRequestMerger interface {
MergePullRequest(ctx context.Context, owner, name string, number int) error
}
PullRequestMerger is implemented by a ForgeClient (or, for github.Client, a Source directly) that can merge one of its own pull requests — checked via a type assertion, the same optional-capability pattern WebhookManager uses. Takes no merge-method override: the caller always wants the repo's own configured default, and picking a method is each implementation's own job (GitHub asks the forge to pick its default; Forgejo's API has no such default and needs it looked up and passed explicitly).
type RateLimit ¶ added in v0.36.0
type RateLimit struct {
Limit int `json:"limit"`
Remaining int `json:"remaining"`
ResetsAt time.Time `json:"resetsAt"`
}
RateLimit matches components.schemas.RateLimit — the forge API's own request budget for the credential the last refresh used. Only some forges expose this (GitHub does, Forgejo doesn't by default), so it's always a pointer: nil means "this forge doesn't report one," not "the budget is zero."
type RateLimiter ¶ added in v0.36.0
RateLimiter is implemented by a ForgeClient that can report its own API rate-limit status — GitHub can; Forgejo can't, since it has no rate limiting of its own by default. GenericSource checks for this via a type assertion rather than adding it to ForgeClient itself, so a client with nothing to report doesn't need a no-op method.
type Repo ¶ added in v0.53.0
type Repo struct {
Forge Forge `json:"forge"`
FullName string `json:"fullName"`
// HasWebhook is a live signal from the Source itself — the forge's
// own webhook list, matched against this app's expected URL — not
// the settings.Store delivery table. False here doesn't mean "no
// webhook confirmed at all": the API layer ORs this with the
// delivery-based signal, since a Source with no webhook path
// configured, or whose live check failed, always reports false.
HasWebhook bool `json:"hasWebhook"`
}
Repo names one tracked repository, independent of whether it currently has any open pull request or issue — RepoCount alone can only answer "how many," and some consumers (webhook-coverage reporting) need "which," including a repo with nothing open at all right now. Matches components.schemas.Repo.
type RepoRef ¶
RepoRef names one repository a ForgeClient is configured to track — every repo the configured credential has write access to, or, with no credential at all, every public repo a configured username owns.
type RepoRefresher ¶ added in v0.38.0
type RepoRefresher interface {
FetchRepo(ctx context.Context, owner, name, fullName string) (prs []PullRequest, issues []Issue, err error)
}
RepoRefresher is implemented by a Source that can refresh just one named repository instead of its whole configured scope — checked via a type assertion, the same optional-capability pattern RateLimiter uses, so a Source with no such shortcut doesn't need a no-op method. A webhook delivery already names the exact repo that changed; without this, the only way to pick up that change is Fetch's full scope, which for a large tracked-repo count is real, avoidable cost on every single delivery.
type Result ¶
type Result struct {
Health ForgeHealth
PullRequests []PullRequest
Issues []Issue
// Repos is every repo this forge is tracking, regardless of whether
// it currently has anything open — RepoCount alone can't name them,
// and a repo with zero open pull requests/issues would otherwise
// never appear in PullRequests/Issues' own Repo fields either.
Repos []Repo
}
Result is what one forge contributes to a Snapshot. Health.Reachable is false only for a fetch that failed outright (couldn't list repositories at all); a single repository's own fetch failing is logged and skipped by the Source, not surfaced here — a good forge with one broken repo still reports Reachable: true.
type Snapshot ¶
type Snapshot struct {
GeneratedAt time.Time `json:"generatedAt"`
Forges []ForgeHealth `json:"forges"`
PullRequests []PullRequest `json:"pullRequests"`
Issues []Issue `json:"issues"`
Repos []Repo `json:"repos"`
}
Snapshot matches components.schemas.Dashboard — the whole body GET /api/dashboard answers with.
type Source ¶
type Source interface {
Fetch(ctx context.Context) Result
// Forge reports which forge this Source drives — how RefreshRepo
// picks the one matching a webhook delivery's own forge out of an
// Aggregator's sources.
Forge() Forge
}
Source is one forge's half of the aggregate. github.Source and forgejo.Source both implement it; the aggregator knows nothing about either forge beyond this.
type WebhookChecker ¶ added in v0.54.0
WebhookChecker is implemented by a ForgeClient (or, for github.Client, a Source directly) that can report whether a repo already has a webhook pointed at this app — checked via a type assertion, the same optional-capability pattern RateLimiter uses. A client with no webhook path configured reports false for every repo without making an API call at all, not an error — the same "optional, degrades quietly" shape RateLimit's own nil pointer already uses.
type WebhookManager ¶ added in v0.56.0
type WebhookManager interface {
EnsureWebhook(ctx context.Context, owner, name, targetURL, secret string) error
}
WebhookManager is implemented by a ForgeClient (or, for github.Client, a Source directly) that can create or update this app's own webhook on one of its tracked repos — checked via a type assertion, the same optional-capability pattern RepoRefresher uses. A single method rather than separate Create/Edit ones: the caller doesn't know or care whether a matching hook already exists, only that one ends up correctly configured — deciding that, and finding an existing one via WebhookTargetsPath rather than creating a duplicate, is the client's own job.