mirror of
https://github.com/lukaszraczylo/ghtracker.git
synced 2026-10-12 00:45:38 +00:00
feat: add GitHub repository dashboard with Prometheus metrics
ghtracker authenticates as a GitHub App and scans a configured list of repositories on a schedule. It serves a dashboard, a JSON state endpoint, a health probe and Prometheus metrics. The dashboard lists only items that need attention: failing workflows, pull requests that wait too long or fail their checks, stale issues and projects that failed to scan. It has search, dynamic filters and a button that rescans on demand. The Vue app is embedded in the Go binary. The client protects the GitHub rate limit with conditional requests, a request reserve and Retry-After handling.
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
// Package collector refreshes repository data on a schedule and evaluates health from it.
|
||||
package collector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/ghtracker/internal/config"
|
||||
"github.com/lukaszraczylo/ghtracker/internal/model"
|
||||
)
|
||||
|
||||
const repoFetchTimeout = 5 * time.Minute
|
||||
|
||||
// Fetcher loads the data for one "owner/name" repository.
|
||||
type Fetcher interface {
|
||||
FetchRepo(ctx context.Context, full string) (model.Repo, error)
|
||||
}
|
||||
|
||||
// View is a consistent, health-evaluated read of the collector state.
|
||||
type View struct {
|
||||
Now time.Time
|
||||
LastRefresh time.Time
|
||||
Repos []model.Repo
|
||||
Alerts []model.Alert
|
||||
Thresholds config.Thresholds
|
||||
Interval time.Duration
|
||||
Crit int
|
||||
Warn int
|
||||
Loaded bool
|
||||
Refreshing bool
|
||||
}
|
||||
|
||||
// Stats are monotonic counters and timings of the refresh loop.
|
||||
type Stats struct {
|
||||
LastRefresh time.Time
|
||||
LastDuration time.Duration
|
||||
RefreshTotal uint64
|
||||
RepoErrorsTotal uint64
|
||||
Loaded bool
|
||||
RefreshInFlight bool
|
||||
}
|
||||
|
||||
type Collector struct {
|
||||
lastRefresh time.Time
|
||||
fetch Fetcher
|
||||
repos map[string]model.Repo
|
||||
now func() time.Time
|
||||
log *slog.Logger
|
||||
kick chan struct{}
|
||||
names []string
|
||||
th config.Thresholds
|
||||
interval time.Duration
|
||||
refreshes atomic.Uint64
|
||||
repoErrs atomic.Uint64
|
||||
lastDur time.Duration
|
||||
concurrency int
|
||||
mu sync.RWMutex
|
||||
limited atomic.Bool
|
||||
refreshing atomic.Bool
|
||||
loaded bool
|
||||
}
|
||||
|
||||
func New(f Fetcher, cfg *config.Config, log *slog.Logger) *Collector {
|
||||
return &Collector{
|
||||
fetch: f,
|
||||
names: cfg.Repos,
|
||||
th: cfg.Thresholds,
|
||||
interval: cfg.RefreshInterval,
|
||||
concurrency: cfg.Concurrency,
|
||||
now: time.Now,
|
||||
log: log,
|
||||
kick: make(chan struct{}, 1),
|
||||
repos: make(map[string]model.Repo, len(cfg.Repos)),
|
||||
}
|
||||
}
|
||||
|
||||
// Run refreshes immediately, then on every interval or kick, until ctx ends.
|
||||
func (c *Collector) Run(ctx context.Context) {
|
||||
t := time.NewTicker(c.interval)
|
||||
defer t.Stop()
|
||||
c.Refresh(ctx)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
c.Refresh(ctx)
|
||||
case <-c.kick:
|
||||
c.Refresh(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RequestRefresh asks Run for an extra refresh; it reports false when one is already running or queued.
|
||||
func (c *Collector) RequestRefresh() bool {
|
||||
if c.refreshing.Load() {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case c.kick <- struct{}{}:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh fetches every repo once; a failing repo keeps its last good data and is marked down.
|
||||
func (c *Collector) Refresh(ctx context.Context) {
|
||||
if !c.refreshing.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
defer c.refreshing.Store(false)
|
||||
|
||||
start := c.now()
|
||||
c.limited.Store(false)
|
||||
results := make([]model.Repo, len(c.names))
|
||||
sem := make(chan struct{}, c.concurrency)
|
||||
var wg sync.WaitGroup
|
||||
for i, name := range c.names {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
results[i] = c.refreshOne(ctx, name, start)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
c.mu.Lock()
|
||||
for _, r := range results {
|
||||
c.repos[r.FullName] = r
|
||||
}
|
||||
c.lastRefresh = start
|
||||
c.lastDur = c.now().Sub(start)
|
||||
c.loaded = true
|
||||
c.mu.Unlock()
|
||||
c.refreshes.Add(1)
|
||||
c.log.Info("refresh complete", "repos", len(c.names), "duration", c.lastDur.Round(time.Millisecond))
|
||||
}
|
||||
|
||||
func (c *Collector) refreshOne(ctx context.Context, name string, at time.Time) model.Repo {
|
||||
ctx, cancel := context.WithTimeout(ctx, repoFetchTimeout)
|
||||
defer cancel()
|
||||
var r model.Repo
|
||||
err := model.ErrRateLimited
|
||||
// Once any repo hits the limit, remaining repos are skipped so no further requests go out.
|
||||
if !c.limited.Load() {
|
||||
r, err = c.fetch.FetchRepo(ctx, name)
|
||||
}
|
||||
if errors.Is(err, model.ErrRateLimited) {
|
||||
c.limited.Store(true)
|
||||
}
|
||||
if err == nil {
|
||||
r.FullName, r.Up, r.RefreshedAt = name, true, at
|
||||
return r
|
||||
}
|
||||
c.repoErrs.Add(1)
|
||||
c.log.Error("repo refresh failed", "repo", name, "err", err)
|
||||
c.mu.RLock()
|
||||
prev, ok := c.repos[name]
|
||||
c.mu.RUnlock()
|
||||
if !ok {
|
||||
prev = model.Repo{FullName: name, URL: "https://github.com/" + name}
|
||||
}
|
||||
prev.Up, prev.Err = false, err.Error()
|
||||
return prev
|
||||
}
|
||||
|
||||
// Stats returns refresh-loop counters.
|
||||
func (c *Collector) Stats() Stats {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return Stats{
|
||||
LastRefresh: c.lastRefresh,
|
||||
LastDuration: c.lastDur,
|
||||
RefreshTotal: c.refreshes.Load(),
|
||||
RepoErrorsTotal: c.repoErrs.Load(),
|
||||
Loaded: c.loaded,
|
||||
RefreshInFlight: c.refreshing.Load(),
|
||||
}
|
||||
}
|
||||
|
||||
// View evaluates all repos at now. Repos not yet fetched appear with no data and are not alerted on.
|
||||
func (c *Collector) View() View {
|
||||
now := c.now()
|
||||
c.mu.RLock()
|
||||
v := View{
|
||||
Now: now, LastRefresh: c.lastRefresh, Interval: c.interval, Thresholds: c.th,
|
||||
Loaded: c.loaded, Refreshing: c.refreshing.Load(),
|
||||
}
|
||||
v.Repos = make([]model.Repo, 0, len(c.names))
|
||||
for _, n := range c.names {
|
||||
if r, ok := c.repos[n]; ok {
|
||||
v.Repos = append(v.Repos, r)
|
||||
}
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
|
||||
for i := range v.Repos {
|
||||
v.Alerts = append(v.Alerts, Evaluate(&v.Repos[i], now, c.th)...)
|
||||
}
|
||||
sortAlerts(v.Alerts)
|
||||
for _, a := range v.Alerts {
|
||||
switch a.Severity {
|
||||
case model.Crit:
|
||||
v.Crit++
|
||||
case model.Warn:
|
||||
v.Warn++
|
||||
}
|
||||
}
|
||||
// Worst repos first so problems surface at the top of the dashboard.
|
||||
slices.SortStableFunc(v.Repos, func(a, b model.Repo) int { return int(b.Health) - int(a.Health) })
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package collector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/ghtracker/internal/config"
|
||||
"github.com/lukaszraczylo/ghtracker/internal/model"
|
||||
)
|
||||
|
||||
var t0 = time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
func th() config.Thresholds {
|
||||
return config.Thresholds{
|
||||
PRWarnAfter: 48 * time.Hour, PRCritAfter: 7 * 24 * time.Hour,
|
||||
IssueStaleAfter: 30 * 24 * time.Hour, WorkflowStuck: 6 * time.Hour, WorkflowDormant: 30 * 24 * time.Hour, DataStaleAfter: 13 * time.Hour,
|
||||
}
|
||||
}
|
||||
|
||||
func kinds(a []model.Alert) map[string]model.Severity {
|
||||
m := map[string]model.Severity{}
|
||||
for _, x := range a {
|
||||
m[x.Kind] = max(m[x.Kind], x.Severity)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestEvaluate(t *testing.T) {
|
||||
fresh := func() model.Repo {
|
||||
return model.Repo{FullName: "o/r", URL: "https://github.com/o/r", Up: true, RefreshedAt: t0}
|
||||
}
|
||||
failedRun := &model.Run{Status: "completed", Conclusion: "failure", URL: "run"}
|
||||
tests := map[string]struct {
|
||||
mutate func(*model.Repo)
|
||||
want map[string]model.Severity
|
||||
health model.Severity
|
||||
}{
|
||||
"healthy": {func(*model.Repo) {}, map[string]model.Severity{}, model.OK},
|
||||
"refresh failed": {func(r *model.Repo) { r.Err = "boom"; r.Up = false }, map[string]model.Severity{KindRefreshFailed: model.Crit}, model.Crit},
|
||||
"data stale": {func(r *model.Repo) { r.RefreshedAt = t0.Add(-14 * time.Hour) }, map[string]model.Severity{KindDataStale: model.Warn}, model.Warn},
|
||||
"partial data": {func(r *model.Repo) { r.Warnings = []string{"workflows: 403"} }, map[string]model.Severity{KindPartialData: model.Warn}, model.Warn},
|
||||
"workflow failed": {func(r *model.Repo) {
|
||||
r.Workflows = []model.Workflow{{Name: "CI", Latest: *failedRun, LastDone: failedRun}}
|
||||
}, map[string]model.Severity{KindWorkflowFail: model.Crit}, model.Crit},
|
||||
"workflow rerun in progress after failure still critical": {func(r *model.Repo) {
|
||||
r.Workflows = []model.Workflow{{Name: "CI", Latest: model.Run{Status: "in_progress", CreatedAt: t0}, LastDone: failedRun}}
|
||||
}, map[string]model.Severity{KindWorkflowFail: model.Crit}, model.Crit},
|
||||
"dormant workflow failure muted": {func(r *model.Repo) {
|
||||
old := &model.Run{Status: "completed", Conclusion: "failure", CreatedAt: t0.Add(-40 * 24 * time.Hour)}
|
||||
r.Workflows = []model.Workflow{{Name: "Old", Latest: *old, LastDone: old}}
|
||||
}, map[string]model.Severity{}, model.OK},
|
||||
"workflow success": {func(r *model.Repo) {
|
||||
ok := &model.Run{Status: "completed", Conclusion: "success"}
|
||||
r.Workflows = []model.Workflow{{Name: "CI", Latest: *ok, LastDone: ok}}
|
||||
}, map[string]model.Severity{}, model.OK},
|
||||
"cancelled is not failure": {func(r *model.Repo) {
|
||||
c := &model.Run{Status: "completed", Conclusion: "cancelled"}
|
||||
r.Workflows = []model.Workflow{{Name: "CI", Latest: *c, LastDone: c}}
|
||||
}, map[string]model.Severity{}, model.OK},
|
||||
"workflow stuck": {func(r *model.Repo) {
|
||||
r.Workflows = []model.Workflow{{Name: "CI", Latest: model.Run{Status: "queued", CreatedAt: t0.Add(-7 * time.Hour)}}}
|
||||
}, map[string]model.Severity{KindWorkflowStuck: model.Warn}, model.Warn},
|
||||
"pr fresh": {func(r *model.Repo) {
|
||||
r.PRs = []model.PullRequest{{Number: 1, CreatedAt: t0.Add(-47 * time.Hour)}}
|
||||
}, map[string]model.Severity{}, model.OK},
|
||||
"pr waiting warn": {func(r *model.Repo) {
|
||||
r.PRs = []model.PullRequest{{Number: 1, CreatedAt: t0.Add(-49 * time.Hour)}}
|
||||
}, map[string]model.Severity{KindPRWaiting: model.Warn}, model.Warn},
|
||||
"pr waiting crit": {func(r *model.Repo) {
|
||||
r.PRs = []model.PullRequest{{Number: 1, CreatedAt: t0.Add(-8 * 24 * time.Hour)}}
|
||||
}, map[string]model.Severity{KindPRWaiting: model.Crit}, model.Crit},
|
||||
"draft pr ignored": {func(r *model.Repo) {
|
||||
r.PRs = []model.PullRequest{{Number: 1, Draft: true, CreatedAt: t0.Add(-30 * 24 * time.Hour), Checks: model.CheckSummary{State: model.ChecksFail}}}
|
||||
}, map[string]model.Severity{}, model.OK},
|
||||
"pr checks failing": {func(r *model.Repo) {
|
||||
r.PRs = []model.PullRequest{{Number: 1, CreatedAt: t0, Checks: model.CheckSummary{State: model.ChecksFail, Failing: []string{"build"}}}}
|
||||
}, map[string]model.Severity{KindPRChecks: model.Warn}, model.Warn},
|
||||
"issues stale aggregated": {func(r *model.Repo) {
|
||||
old := t0.Add(-40 * 24 * time.Hour)
|
||||
r.Issues = []model.Issue{{Number: 1, UpdatedAt: old}, {Number: 2, UpdatedAt: old}, {Number: 3, UpdatedAt: t0}}
|
||||
}, map[string]model.Severity{KindIssuesStale: model.Warn}, model.Warn},
|
||||
"archived repo skips work alerts": {func(r *model.Repo) {
|
||||
r.Archived = true
|
||||
r.PRs = []model.PullRequest{{Number: 1, CreatedAt: t0.Add(-30 * 24 * time.Hour)}}
|
||||
}, map[string]model.Severity{}, model.OK},
|
||||
}
|
||||
for name, tt := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
r := fresh()
|
||||
tt.mutate(&r)
|
||||
alerts := Evaluate(&r, t0, th())
|
||||
got := kinds(alerts)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("alerts = %+v, want kinds %v", alerts, tt.want)
|
||||
}
|
||||
for k, sev := range tt.want {
|
||||
if got[k] != sev {
|
||||
t.Fatalf("kind %s severity = %v, want %v", k, got[k], sev)
|
||||
}
|
||||
}
|
||||
if r.Health != tt.health {
|
||||
t.Fatalf("health = %v, want %v", r.Health, tt.health)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateDisabledThresholds(t *testing.T) {
|
||||
r := model.Repo{FullName: "o/r", RefreshedAt: t0, PRs: []model.PullRequest{{Number: 1, CreatedAt: t0.Add(-100 * 24 * time.Hour)}},
|
||||
Issues: []model.Issue{{UpdatedAt: t0.Add(-100 * 24 * time.Hour)}}}
|
||||
off := config.Thresholds{PRWarnAfter: -1, PRCritAfter: -1, IssueStaleAfter: -1, WorkflowStuck: -1, DataStaleAfter: -1}
|
||||
if a := Evaluate(&r, t0, off); len(a) != 0 {
|
||||
t.Fatalf("disabled checks produced %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgeAdvancesWithoutRefresh(t *testing.T) {
|
||||
r := model.Repo{FullName: "o/r", RefreshedAt: t0, PRs: []model.PullRequest{{Number: 1, CreatedAt: t0.Add(-40 * time.Hour)}}}
|
||||
cfg := th()
|
||||
cfg.DataStaleAfter = -1
|
||||
if a := Evaluate(&r, t0, cfg); len(a) != 0 {
|
||||
t.Fatalf("unexpected %+v", a)
|
||||
}
|
||||
if a := Evaluate(&r, t0.Add(10*time.Hour), cfg); len(a) != 1 || a[0].Severity != model.Warn {
|
||||
t.Fatalf("PR must cross the warn threshold as time passes: %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAge(t *testing.T) {
|
||||
cases := map[time.Duration]string{-time.Minute: "0m", 5 * time.Minute: "5m", 90 * time.Minute: "1h", 47 * time.Hour: "47h", 49 * time.Hour: "2d", 10 * 24 * time.Hour: "10d"}
|
||||
for d, want := range cases {
|
||||
if got := Age(d); got != want {
|
||||
t.Errorf("Age(%s) = %s, want %s", d, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type fakeFetcher struct {
|
||||
fn func(name string) (model.Repo, error)
|
||||
calls []string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (f *fakeFetcher) FetchRepo(_ context.Context, name string) (model.Repo, error) {
|
||||
f.mu.Lock()
|
||||
f.calls = append(f.calls, name)
|
||||
f.mu.Unlock()
|
||||
return f.fn(name)
|
||||
}
|
||||
|
||||
func newCollector(f Fetcher, repos ...string) *Collector {
|
||||
cfg := &config.Config{Repos: repos, RefreshInterval: time.Hour, Concurrency: 1, Thresholds: th()}
|
||||
c := New(f, cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
c.now = func() time.Time { return t0 }
|
||||
return c
|
||||
}
|
||||
|
||||
func TestRefreshKeepsLastGoodDataOnFailure(t *testing.T) {
|
||||
fail := false
|
||||
f := &fakeFetcher{fn: func(name string) (model.Repo, error) {
|
||||
if fail {
|
||||
return model.Repo{}, errors.New("boom")
|
||||
}
|
||||
return model.Repo{FullName: name, URL: "u", Issues: []model.Issue{{Number: 1, UpdatedAt: t0}}}, nil
|
||||
}}
|
||||
c := newCollector(f, "o/a")
|
||||
c.Refresh(context.Background())
|
||||
if v := c.View(); !v.Loaded || len(v.Repos) != 1 || !v.Repos[0].Up || len(v.Repos[0].Issues) != 1 {
|
||||
t.Fatalf("first view: %+v", v)
|
||||
}
|
||||
fail = true
|
||||
c.Refresh(context.Background())
|
||||
v := c.View()
|
||||
r := v.Repos[0]
|
||||
if r.Up || r.Err != "boom" || len(r.Issues) != 1 {
|
||||
t.Fatalf("must keep old data and flag error: %+v", r)
|
||||
}
|
||||
if v.Crit != 1 || r.Health != model.Crit {
|
||||
t.Fatalf("crit=%d health=%v", v.Crit, r.Health)
|
||||
}
|
||||
if s := c.Stats(); s.RepoErrorsTotal != 1 || s.RefreshTotal != 2 {
|
||||
t.Fatalf("stats %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstRefreshFailureStillListsRepo(t *testing.T) {
|
||||
c := newCollector(&fakeFetcher{fn: func(string) (model.Repo, error) { return model.Repo{}, errors.New("nope") }}, "o/a")
|
||||
c.Refresh(context.Background())
|
||||
v := c.View()
|
||||
if len(v.Repos) != 1 || v.Repos[0].Up || v.Repos[0].URL != "https://github.com/o/a" {
|
||||
t.Fatalf("%+v", v.Repos)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitStopsRemainingRepos(t *testing.T) {
|
||||
f := &fakeFetcher{fn: func(name string) (model.Repo, error) {
|
||||
if name == "o/b" {
|
||||
return model.Repo{}, model.ErrRateLimited
|
||||
}
|
||||
return model.Repo{FullName: name}, nil
|
||||
}}
|
||||
c := newCollector(f, "o/a", "o/b", "o/c", "o/d")
|
||||
c.Refresh(context.Background())
|
||||
if len(f.calls) != 2 {
|
||||
t.Fatalf("calls after limit = %v, want only a and b", f.calls)
|
||||
}
|
||||
v := c.View()
|
||||
if len(v.Repos) != 4 {
|
||||
t.Fatalf("all repos must stay listed: %d", len(v.Repos))
|
||||
}
|
||||
// Next cycle starts with a clean limit flag.
|
||||
f.fn = func(name string) (model.Repo, error) { return model.Repo{FullName: name}, nil }
|
||||
f.calls = nil
|
||||
c.Refresh(context.Background())
|
||||
if len(f.calls) != 4 {
|
||||
t.Fatalf("limit flag must reset, calls=%v", f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewSortsWorstFirstAndAlertsBySeverity(t *testing.T) {
|
||||
f := &fakeFetcher{fn: func(name string) (model.Repo, error) {
|
||||
r := model.Repo{FullName: name}
|
||||
switch name {
|
||||
case "o/crit":
|
||||
c := &model.Run{Status: "completed", Conclusion: "failure"}
|
||||
r.Workflows = []model.Workflow{{Name: "CI", Latest: *c, LastDone: c}}
|
||||
case "o/warn":
|
||||
r.PRs = []model.PullRequest{{Number: 1, CreatedAt: t0.Add(-72 * time.Hour)}}
|
||||
}
|
||||
return r, nil
|
||||
}}
|
||||
c := newCollector(f, "o/ok", "o/warn", "o/crit")
|
||||
c.Refresh(context.Background())
|
||||
v := c.View()
|
||||
if v.Repos[0].FullName != "o/crit" || v.Repos[1].FullName != "o/warn" || v.Repos[2].FullName != "o/ok" {
|
||||
t.Fatalf("order: %s %s %s", v.Repos[0].FullName, v.Repos[1].FullName, v.Repos[2].FullName)
|
||||
}
|
||||
if len(v.Alerts) != 2 || v.Alerts[0].Severity != model.Crit || v.Crit != 1 || v.Warn != 1 {
|
||||
t.Fatalf("alerts: %+v", v.Alerts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestRefreshCoalesces(t *testing.T) {
|
||||
c := newCollector(&fakeFetcher{fn: func(n string) (model.Repo, error) { return model.Repo{FullName: n}, nil }}, "o/a")
|
||||
if !c.RequestRefresh() {
|
||||
t.Fatal("first request must queue")
|
||||
}
|
||||
if c.RequestRefresh() {
|
||||
t.Fatal("second request must coalesce while one is queued")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRefreshesOnStartAndStopsOnCancel(t *testing.T) {
|
||||
c := newCollector(&fakeFetcher{fn: func(n string) (model.Repo, error) { return model.Repo{FullName: n}, nil }}, "o/a")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() { c.Run(ctx); close(done) }()
|
||||
deadline := time.After(2 * time.Second)
|
||||
for !c.Stats().Loaded {
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatal("no initial refresh")
|
||||
case <-time.After(5 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Run did not stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeChecks(t *testing.T) {
|
||||
tests := map[string]struct {
|
||||
want string
|
||||
in model.CheckSummary
|
||||
}{
|
||||
"few distinct": {in: model.CheckSummary{Failed: 2, Failing: []string{"build", "lint"}}, want: "2 checks failing: build, lint"},
|
||||
"duplicates collapse and extras count": {
|
||||
in: model.CheckSummary{Failed: 6, Failing: []string{"pr-checks / Tests", "pr-checks / Tests", "pr-checks / Lint", "pr-checks / Scan", "pr-checks / Gosec"}},
|
||||
want: "6 checks failing: Gosec, Lint +2 more",
|
||||
},
|
||||
"mixed prefixes stay intact": {in: model.CheckSummary{Failed: 2, Failing: []string{"a / x", "b / y"}}, want: "2 checks failing: a / x, b / y"},
|
||||
"no names": {in: model.CheckSummary{Failed: 1}, want: "1 checks failing"},
|
||||
}
|
||||
for name, tt := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if got := summarizeChecks(tt.in); got != tt.want {
|
||||
t.Fatalf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeWarningsMergesSharedReason(t *testing.T) {
|
||||
got := summarizeWarnings([]string{
|
||||
"issues: github: HTTP 403: Resource not accessible by integration",
|
||||
"workflows: github: HTTP 403: Resource not accessible by integration",
|
||||
"releases: github: HTTP 500: boom",
|
||||
})
|
||||
want := []string{
|
||||
"issues, workflows: github: HTTP 403: Resource not accessible by integration",
|
||||
"releases: github: HTTP 500: boom",
|
||||
}
|
||||
if len(got) != 2 || got[0] != want[0] || got[1] != want[1] {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package collector
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/ghtracker/internal/config"
|
||||
"github.com/lukaszraczylo/ghtracker/internal/model"
|
||||
)
|
||||
|
||||
// Alert kinds; they double as stable identifiers for dashboards.
|
||||
const (
|
||||
KindRefreshFailed = "refresh_failed"
|
||||
KindDataStale = "data_stale"
|
||||
KindPartialData = "partial_data"
|
||||
KindWorkflowFail = "workflow_failed"
|
||||
KindWorkflowStuck = "workflow_stuck"
|
||||
KindPRWaiting = "pr_waiting"
|
||||
KindPRChecks = "pr_checks_failing"
|
||||
KindIssuesStale = "issues_stale"
|
||||
)
|
||||
|
||||
// Evaluate derives alerts for one repo at time now and stores the worst severity in r.Health.
|
||||
// It runs on every read so age-based alerts advance between refreshes.
|
||||
func Evaluate(r *model.Repo, now time.Time, th config.Thresholds) []model.Alert {
|
||||
var alerts []model.Alert
|
||||
add := func(sev model.Severity, kind, subject, detail, url string, since time.Time) {
|
||||
alerts = append(alerts, model.Alert{Severity: sev, Repo: r.FullName, Kind: kind, Subject: subject, Detail: detail, URL: url, Since: since})
|
||||
r.Health = max(r.Health, sev)
|
||||
}
|
||||
r.Health = model.OK
|
||||
|
||||
if r.Err != "" {
|
||||
add(model.Crit, KindRefreshFailed, "Refresh failed", r.Err, r.URL, r.RefreshedAt)
|
||||
}
|
||||
if th.DataStaleAfter > 0 && !r.RefreshedAt.IsZero() && now.Sub(r.RefreshedAt) > th.DataStaleAfter {
|
||||
add(model.Warn, KindDataStale, "Data is stale", "last refreshed "+Age(now.Sub(r.RefreshedAt))+" ago", r.URL, r.RefreshedAt)
|
||||
}
|
||||
for _, detail := range summarizeWarnings(r.Warnings) {
|
||||
add(model.Warn, KindPartialData, "Incomplete data", detail, r.URL, r.RefreshedAt)
|
||||
}
|
||||
if r.Archived {
|
||||
return alerts
|
||||
}
|
||||
|
||||
for _, wf := range r.Workflows {
|
||||
if wf.LastDone != nil && wf.LastDone.Failed() && !Dormant(wf, now, th) {
|
||||
add(model.Crit, KindWorkflowFail, workflowSubject(wf), fmt.Sprintf("%s on %s (%s)", strings.ReplaceAll(wf.LastDone.Conclusion, "_", " "), wf.LastDone.Branch, wf.LastDone.Event), wf.LastDone.URL, wf.LastDone.CreatedAt)
|
||||
}
|
||||
if wf.Latest.Running() && th.WorkflowStuck > 0 && now.Sub(wf.Latest.CreatedAt) > th.WorkflowStuck {
|
||||
add(model.Warn, KindWorkflowStuck, workflowSubject(wf),
|
||||
fmt.Sprintf("%s for %s", strings.ReplaceAll(wf.Latest.Status, "_", " "), Age(now.Sub(wf.Latest.CreatedAt))),
|
||||
wf.Latest.URL, wf.Latest.CreatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
for _, pr := range r.PRs {
|
||||
if pr.Draft {
|
||||
continue
|
||||
}
|
||||
age := now.Sub(pr.CreatedAt)
|
||||
subject := fmt.Sprintf("#%d %s", pr.Number, pr.Title)
|
||||
switch {
|
||||
case th.PRCritAfter > 0 && age >= th.PRCritAfter:
|
||||
add(model.Crit, KindPRWaiting, subject, "open "+Age(age), pr.URL, pr.CreatedAt)
|
||||
case th.PRWarnAfter > 0 && age >= th.PRWarnAfter:
|
||||
add(model.Warn, KindPRWaiting, subject, "open "+Age(age), pr.URL, pr.CreatedAt)
|
||||
}
|
||||
if pr.Checks.State == model.ChecksFail {
|
||||
add(model.Warn, KindPRChecks, subject, summarizeChecks(pr.Checks), pr.URL, pr.UpdatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
if th.IssueStaleAfter > 0 {
|
||||
var stale int
|
||||
var oldest time.Time
|
||||
for _, is := range r.Issues {
|
||||
if now.Sub(is.UpdatedAt) >= th.IssueStaleAfter {
|
||||
stale++
|
||||
if oldest.IsZero() || is.UpdatedAt.Before(oldest) {
|
||||
oldest = is.UpdatedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
if stale > 0 {
|
||||
add(model.Warn, KindIssuesStale, fmt.Sprintf("%d stale issues", stale),
|
||||
"no activity for "+Age(th.IssueStaleAfter)+" or more",
|
||||
r.URL+"/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-asc", oldest)
|
||||
}
|
||||
}
|
||||
return alerts
|
||||
}
|
||||
|
||||
func workflowSubject(wf model.Workflow) string {
|
||||
if wf.Background {
|
||||
return wf.Name + " (background)"
|
||||
}
|
||||
return wf.Name
|
||||
}
|
||||
|
||||
func sortAlerts(a []model.Alert) {
|
||||
slices.SortStableFunc(a, func(x, y model.Alert) int {
|
||||
if x.Severity != y.Severity {
|
||||
return int(y.Severity) - int(x.Severity)
|
||||
}
|
||||
if c := x.Since.Compare(y.Since); c != 0 {
|
||||
return c
|
||||
}
|
||||
return strings.Compare(x.Repo+x.Subject, y.Repo+y.Subject)
|
||||
})
|
||||
}
|
||||
|
||||
// Age renders a duration at a glance, e.g. "3d", "5h", "12m".
|
||||
func Age(d time.Duration) string {
|
||||
switch {
|
||||
case d < 0:
|
||||
return "0m"
|
||||
case d >= 48*time.Hour:
|
||||
return fmt.Sprintf("%dd", int(d.Hours()/24))
|
||||
case d >= time.Hour:
|
||||
return fmt.Sprintf("%dh", int(d.Hours()))
|
||||
default:
|
||||
return fmt.Sprintf("%dm", int(d.Minutes()))
|
||||
}
|
||||
}
|
||||
|
||||
// Dormant reports whether the workflow has not run for longer than the dormant threshold,
|
||||
// so an old failure no longer signals a current problem.
|
||||
func Dormant(wf model.Workflow, now time.Time, th config.Thresholds) bool {
|
||||
return th.WorkflowDormant > 0 && !wf.Latest.CreatedAt.IsZero() && now.Sub(wf.Latest.CreatedAt) > th.WorkflowDormant
|
||||
}
|
||||
|
||||
const maxNamedChecks = 2
|
||||
|
||||
// summarizeChecks renders failing checks as a count plus the first few distinct names.
|
||||
func summarizeChecks(c model.CheckSummary) string {
|
||||
names := slices.Compact(slices.Sorted(slices.Values(c.Failing)))
|
||||
if prefix, ok := sharedPrefix(names); ok {
|
||||
for i, n := range names {
|
||||
names[i] = strings.TrimPrefix(n, prefix)
|
||||
}
|
||||
}
|
||||
shown := names[:min(len(names), maxNamedChecks)]
|
||||
out := fmt.Sprintf("%d checks failing", c.Failed)
|
||||
if len(shown) > 0 {
|
||||
out += ": " + strings.Join(shown, ", ")
|
||||
}
|
||||
if extra := len(names) - len(shown); extra > 0 {
|
||||
out += fmt.Sprintf(" +%d more", extra)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sharedPrefix returns the common "group / " prefix of check names such as "pr-checks / Tests".
|
||||
func sharedPrefix(names []string) (string, bool) {
|
||||
if len(names) < 2 {
|
||||
return "", false
|
||||
}
|
||||
group, _, ok := strings.Cut(names[0], " / ")
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
prefix := group + " / "
|
||||
for _, n := range names {
|
||||
if !strings.HasPrefix(n, prefix) {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
return prefix, true
|
||||
}
|
||||
|
||||
// summarizeWarnings merges "section: reason" warnings that share a reason into one line.
|
||||
func summarizeWarnings(warnings []string) []string {
|
||||
var reasons []string
|
||||
sections := map[string][]string{}
|
||||
for _, w := range warnings {
|
||||
section, reason, ok := strings.Cut(w, ": ")
|
||||
if !ok {
|
||||
section, reason = "data", w
|
||||
}
|
||||
if _, seen := sections[reason]; !seen {
|
||||
reasons = append(reasons, reason)
|
||||
}
|
||||
sections[reason] = append(sections[reason], section)
|
||||
}
|
||||
out := make([]string, 0, len(reasons))
|
||||
for _, reason := range reasons {
|
||||
out = append(out, strings.Join(sections[reason], ", ")+": "+reason)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// Package config loads and validates the YAML configuration.
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
EnvPrivateKey = "GHTRACKER_PRIVATE_KEY"
|
||||
|
||||
DefaultListen = ":8080"
|
||||
DefaultRefresh = 6 * time.Hour
|
||||
DefaultAPIURL = "https://api.github.com"
|
||||
DefaultPRWarnAfter = 48 * time.Hour
|
||||
DefaultPRCritAfter = 7 * 24 * time.Hour
|
||||
DefaultIssueStale = 30 * 24 * time.Hour
|
||||
DefaultWorkflowStuck = 6 * time.Hour
|
||||
DefaultWorkflowDormant = 30 * 24 * time.Hour
|
||||
DefaultMaxItems = 100
|
||||
DefaultConcurrency = 2
|
||||
MinRefresh = time.Minute
|
||||
dataStaleGrace = 30 * time.Minute
|
||||
defaultDataStaleFactor = 2
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
// ManualRefresh enables POST /refresh and the rescan button; it defaults to on.
|
||||
ManualRefresh *bool `yaml:"manual_refresh"`
|
||||
Listen string `yaml:"listen"`
|
||||
Repos []string `yaml:"repos"`
|
||||
GitHub GitHub `yaml:"github"`
|
||||
Thresholds Thresholds `yaml:"thresholds"`
|
||||
RefreshInterval time.Duration `yaml:"refresh_interval"`
|
||||
MaxItems int `yaml:"max_items"`
|
||||
Concurrency int `yaml:"concurrency"`
|
||||
}
|
||||
|
||||
type GitHub struct {
|
||||
PrivateKeyFile string `yaml:"private_key_file"`
|
||||
APIURL string `yaml:"api_url"`
|
||||
// PrivateKeyPEM comes from the environment, never from the file.
|
||||
PrivateKeyPEM string `yaml:"-"`
|
||||
AppID int64 `yaml:"app_id"`
|
||||
InstallationID int64 `yaml:"installation_id"`
|
||||
}
|
||||
|
||||
// Thresholds are durations; zero selects the default and a negative value disables the check.
|
||||
type Thresholds struct {
|
||||
PRWarnAfter time.Duration `yaml:"pr_warn_after"`
|
||||
PRCritAfter time.Duration `yaml:"pr_crit_after"`
|
||||
IssueStaleAfter time.Duration `yaml:"issue_stale_after"`
|
||||
WorkflowStuck time.Duration `yaml:"workflow_stuck_after"`
|
||||
// WorkflowDormant mutes failures of workflows that have not run for this long.
|
||||
WorkflowDormant time.Duration `yaml:"workflow_dormant_after"`
|
||||
DataStaleAfter time.Duration `yaml:"data_stale_after"`
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
raw, err := os.ReadFile(path) // #nosec G304 -- operator-supplied -config flag
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
var c Config
|
||||
dec := yaml.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.KnownFields(true)
|
||||
if err := dec.Decode(&c); err != nil {
|
||||
return nil, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
return &c, c.finalize(os.Getenv, os.ReadFile)
|
||||
}
|
||||
|
||||
func (c *Config) finalize(getenv func(string) string, readFile func(string) ([]byte, error)) error {
|
||||
if c.ManualRefresh == nil {
|
||||
on := true
|
||||
c.ManualRefresh = &on
|
||||
}
|
||||
if c.Listen == "" {
|
||||
c.Listen = DefaultListen
|
||||
}
|
||||
if c.RefreshInterval == 0 {
|
||||
c.RefreshInterval = DefaultRefresh
|
||||
}
|
||||
if c.RefreshInterval < MinRefresh {
|
||||
return fmt.Errorf("refresh_interval %s is below the %s minimum", c.RefreshInterval, MinRefresh)
|
||||
}
|
||||
if c.MaxItems <= 0 {
|
||||
c.MaxItems = DefaultMaxItems
|
||||
}
|
||||
if c.Concurrency <= 0 {
|
||||
c.Concurrency = DefaultConcurrency
|
||||
}
|
||||
if c.GitHub.APIURL == "" {
|
||||
c.GitHub.APIURL = DefaultAPIURL
|
||||
}
|
||||
c.GitHub.APIURL = strings.TrimRight(c.GitHub.APIURL, "/")
|
||||
|
||||
t := &c.Thresholds
|
||||
setDefault(&t.PRWarnAfter, DefaultPRWarnAfter)
|
||||
setDefault(&t.PRCritAfter, DefaultPRCritAfter)
|
||||
setDefault(&t.IssueStaleAfter, DefaultIssueStale)
|
||||
setDefault(&t.WorkflowStuck, DefaultWorkflowStuck)
|
||||
setDefault(&t.WorkflowDormant, DefaultWorkflowDormant)
|
||||
setDefault(&t.DataStaleAfter, defaultDataStaleFactor*c.RefreshInterval+dataStaleGrace)
|
||||
if t.PRWarnAfter > 0 && t.PRCritAfter > 0 && t.PRCritAfter < t.PRWarnAfter {
|
||||
return errors.New("thresholds.pr_crit_after must not be below pr_warn_after")
|
||||
}
|
||||
|
||||
if c.GitHub.AppID <= 0 {
|
||||
return errors.New("github.app_id is required")
|
||||
}
|
||||
c.GitHub.PrivateKeyPEM = getenv(EnvPrivateKey)
|
||||
if c.GitHub.PrivateKeyPEM == "" {
|
||||
if c.GitHub.PrivateKeyFile == "" {
|
||||
return fmt.Errorf("github.private_key_file or $%s is required", EnvPrivateKey)
|
||||
}
|
||||
pem, err := readFile(c.GitHub.PrivateKeyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read private key: %w", err)
|
||||
}
|
||||
c.GitHub.PrivateKeyPEM = string(pem)
|
||||
}
|
||||
|
||||
repos, err := normalizeRepos(c.Repos)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.Repos = repos
|
||||
return nil
|
||||
}
|
||||
|
||||
func setDefault(d *time.Duration, def time.Duration) {
|
||||
if *d == 0 {
|
||||
*d = def
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeRepos(in []string) ([]string, error) {
|
||||
if len(in) == 0 {
|
||||
return nil, errors.New("repos must list at least one owner/name")
|
||||
}
|
||||
seen := make(map[string]bool, len(in))
|
||||
out := make([]string, 0, len(in))
|
||||
for _, r := range in {
|
||||
r = strings.TrimSpace(r)
|
||||
owner, name, ok := strings.Cut(r, "/")
|
||||
if !ok || owner == "" || name == "" || strings.Contains(name, "/") {
|
||||
return nil, fmt.Errorf("invalid repo %q: want owner/name", r)
|
||||
}
|
||||
key := strings.ToLower(r)
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func noFile(string) ([]byte, error) { return nil, errors.New("no file") }
|
||||
func keyFile(string) ([]byte, error) { return []byte("PEM"), nil }
|
||||
func env(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
|
||||
|
||||
func valid() *Config {
|
||||
return &Config{GitHub: GitHub{AppID: 1, PrivateKeyFile: "k.pem"}, Repos: []string{"a/b"}}
|
||||
}
|
||||
|
||||
func TestFinalizeDefaults(t *testing.T) {
|
||||
c := valid()
|
||||
if err := c.finalize(env(nil), keyFile); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.RefreshInterval != 6*time.Hour || c.Listen != ":8080" || c.Concurrency != 2 || c.MaxItems != 100 {
|
||||
t.Fatalf("defaults wrong: %+v", c)
|
||||
}
|
||||
if c.ManualRefresh == nil || !*c.ManualRefresh {
|
||||
t.Fatal("manual refresh must default to on")
|
||||
}
|
||||
if c.GitHub.APIURL != DefaultAPIURL || c.GitHub.PrivateKeyPEM != "PEM" {
|
||||
t.Fatalf("github defaults wrong: %+v", c.GitHub)
|
||||
}
|
||||
if want := 12*time.Hour + 30*time.Minute; c.Thresholds.DataStaleAfter != want {
|
||||
t.Fatalf("data stale = %s, want %s", c.Thresholds.DataStaleAfter, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalizeEnvKeyWins(t *testing.T) {
|
||||
c := valid()
|
||||
c.GitHub.PrivateKeyFile = ""
|
||||
if err := c.finalize(env(map[string]string{EnvPrivateKey: "ENVPEM"}), noFile); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.GitHub.PrivateKeyPEM != "ENVPEM" {
|
||||
t.Fatalf("got %q", c.GitHub.PrivateKeyPEM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalizeErrors(t *testing.T) {
|
||||
tests := map[string]func(*Config){
|
||||
"no app id": func(c *Config) { c.GitHub.AppID = 0 },
|
||||
"no key": func(c *Config) { c.GitHub.PrivateKeyFile = "" },
|
||||
"unreadable key": func(c *Config) { c.GitHub.PrivateKeyFile = "x" },
|
||||
"no repos": func(c *Config) { c.Repos = nil },
|
||||
"bad repo": func(c *Config) { c.Repos = []string{"justname"} },
|
||||
"bad repo slashes": func(c *Config) { c.Repos = []string{"a/b/c"} },
|
||||
"refresh too small": func(c *Config) { c.RefreshInterval = time.Second },
|
||||
"crit below warn": func(c *Config) {
|
||||
c.Thresholds.PRWarnAfter, c.Thresholds.PRCritAfter = 10*time.Hour, time.Hour
|
||||
},
|
||||
}
|
||||
for name, mutate := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
c := valid()
|
||||
mutate(c)
|
||||
if err := c.finalize(env(nil), noFile); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeReposDedupes(t *testing.T) {
|
||||
got, err := normalizeRepos([]string{"A/b", " a/B ", "c/d"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 || got[0] != "A/b" || got[1] != "c/d" {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNegativeThresholdDisables(t *testing.T) {
|
||||
c := valid()
|
||||
c.Thresholds.PRCritAfter = -1
|
||||
if err := c.finalize(env(nil), keyFile); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.Thresholds.PRCritAfter != -1 {
|
||||
t.Fatalf("negative must be kept, got %s", c.Thresholds.PRCritAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsUnknownKeysAndReadsFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write := func(name, body string) string {
|
||||
p := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
key := write("k.pem", "PEM")
|
||||
good := write("ok.yaml", "refresh_interval: 2h\ngithub:\n app_id: 5\n private_key_file: "+key+"\nrepos: [a/b]\n")
|
||||
c, err := Load(good)
|
||||
if err != nil || c.RefreshInterval != 2*time.Hour || c.GitHub.PrivateKeyPEM != "PEM" {
|
||||
t.Fatalf("c=%+v err=%v", c, err)
|
||||
}
|
||||
typo := write("typo.yaml", "refresh_intervall: 2h\ngithub:\n app_id: 5\n private_key_file: "+key+"\nrepos: [a/b]\n")
|
||||
if _, err := Load(typo); err == nil {
|
||||
t.Fatal("unknown key must be rejected")
|
||||
}
|
||||
if _, err := Load(filepath.Join(dir, "missing.yaml")); err == nil {
|
||||
t.Fatal("missing file must error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualRefreshCanBeDisabled(t *testing.T) {
|
||||
off := false
|
||||
c := valid()
|
||||
c.ManualRefresh = &off
|
||||
if err := c.finalize(env(nil), keyFile); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if *c.ManualRefresh {
|
||||
t.Fatal("explicit false must stay off")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package gh
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
jwtLifetime = 9 * time.Minute // GitHub caps app JWTs at 10 minutes
|
||||
jwtClockSkew = 60 * time.Second
|
||||
tokenSafety = 5 * time.Minute
|
||||
maxAuthBody = 1 << 20
|
||||
installLookup = "/repos/%s/installation"
|
||||
)
|
||||
|
||||
// AppAuth mints GitHub App installation tokens and caches them until shortly before expiry.
|
||||
type AppAuth struct {
|
||||
key *rsa.PrivateKey
|
||||
hc *http.Client
|
||||
now func() time.Time
|
||||
tokens map[int64]cachedToken
|
||||
repoInstalls map[string]int64
|
||||
base string
|
||||
appID int64
|
||||
fixedInstall int64
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type cachedToken struct {
|
||||
expires time.Time
|
||||
value string
|
||||
}
|
||||
|
||||
func NewAppAuth(appID int64, keyPEM, baseURL string, installationID int64, hc *http.Client) (*AppAuth, error) {
|
||||
key, err := ParsePrivateKey([]byte(keyPEM))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &AppAuth{
|
||||
appID: appID,
|
||||
key: key,
|
||||
base: strings.TrimRight(baseURL, "/"),
|
||||
hc: hc,
|
||||
fixedInstall: installationID,
|
||||
now: time.Now,
|
||||
tokens: map[int64]cachedToken{},
|
||||
repoInstalls: map[string]int64{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ParsePrivateKey accepts PKCS#1 and PKCS#8 PEM RSA keys.
|
||||
func ParsePrivateKey(data []byte) (*rsa.PrivateKey, error) {
|
||||
block, _ := pem.Decode(data)
|
||||
if block == nil {
|
||||
return nil, errors.New("private key: no PEM block found")
|
||||
}
|
||||
if k, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
|
||||
return k, nil
|
||||
}
|
||||
k, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("private key: unsupported format: %w", err)
|
||||
}
|
||||
rk, ok := k.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, errors.New("private key: not an RSA key")
|
||||
}
|
||||
return rk, nil
|
||||
}
|
||||
|
||||
func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
|
||||
|
||||
func (a *AppAuth) appJWT() (string, error) {
|
||||
now := a.now()
|
||||
header, _ := json.Marshal(map[string]string{"alg": "RS256", "typ": "JWT"})
|
||||
claims, _ := json.Marshal(map[string]any{
|
||||
"iat": now.Add(-jwtClockSkew).Unix(),
|
||||
"exp": now.Add(jwtLifetime).Unix(),
|
||||
"iss": fmt.Sprint(a.appID),
|
||||
})
|
||||
signing := b64(header) + "." + b64(claims)
|
||||
sum := sha256.Sum256([]byte(signing))
|
||||
sig, err := rsa.SignPKCS1v15(rand.Reader, a.key, crypto.SHA256, sum[:])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("sign app jwt: %w", err)
|
||||
}
|
||||
return signing + "." + b64(sig), nil
|
||||
}
|
||||
|
||||
// Token returns an installation token valid for repo ("owner/name").
|
||||
func (a *AppAuth) Token(ctx context.Context, repo string) (string, error) {
|
||||
id, err := a.installationID(ctx, repo)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
a.mu.Lock()
|
||||
t, ok := a.tokens[id]
|
||||
a.mu.Unlock()
|
||||
if ok && a.now().Before(t.expires.Add(-tokenSafety)) {
|
||||
return t.value, nil
|
||||
}
|
||||
return a.refresh(ctx, id)
|
||||
}
|
||||
|
||||
func (a *AppAuth) installationID(ctx context.Context, repo string) (int64, error) {
|
||||
if a.fixedInstall != 0 {
|
||||
return a.fixedInstall, nil
|
||||
}
|
||||
a.mu.Lock()
|
||||
id, ok := a.repoInstalls[repo]
|
||||
a.mu.Unlock()
|
||||
if ok {
|
||||
return id, nil
|
||||
}
|
||||
var out struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
if err := a.appRequest(ctx, http.MethodGet, fmt.Sprintf(installLookup, repo), &out); err != nil {
|
||||
return 0, fmt.Errorf("find installation for %s: %w", repo, err)
|
||||
}
|
||||
a.mu.Lock()
|
||||
a.repoInstalls[repo] = out.ID
|
||||
a.mu.Unlock()
|
||||
return out.ID, nil
|
||||
}
|
||||
|
||||
func (a *AppAuth) refresh(ctx context.Context, id int64) (string, error) {
|
||||
var out struct {
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := a.appRequest(ctx, http.MethodPost, fmt.Sprintf("/app/installations/%d/access_tokens", id), &out); err != nil {
|
||||
return "", fmt.Errorf("create installation token: %w", err)
|
||||
}
|
||||
a.mu.Lock()
|
||||
a.tokens[id] = cachedToken{value: out.Token, expires: out.ExpiresAt}
|
||||
a.mu.Unlock()
|
||||
return out.Token, nil
|
||||
}
|
||||
|
||||
func (a *AppAuth) appRequest(ctx context.Context, method, path string, out any) error {
|
||||
jwt, err := a.appJWT()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, a.base+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+jwt)
|
||||
req.Header.Set("Accept", acceptHeader)
|
||||
req.Header.Set("X-GitHub-Api-Version", apiVersion)
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
resp, err := a.hc.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxAuthBody))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return &APIError{Status: resp.StatusCode, Message: apiMessage(body)}
|
||||
}
|
||||
return json.Unmarshal(body, out)
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
// Package gh is a small read-only GitHub REST client authenticated as a GitHub App.
|
||||
package gh
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/ghtracker/internal/model"
|
||||
)
|
||||
|
||||
const (
|
||||
acceptHeader = "application/vnd.github+json"
|
||||
apiVersion = "2022-11-28"
|
||||
userAgent = "ghtracker"
|
||||
perPage = 100
|
||||
maxBody = 16 << 20
|
||||
maxRetries = 2
|
||||
// rateReserve is the request budget left untouched so the app never exhausts its hourly quota.
|
||||
rateReserve = 100
|
||||
// secondaryBackoff applies when GitHub signals abuse limits without a Retry-After.
|
||||
secondaryBackoff = time.Minute
|
||||
)
|
||||
|
||||
// APIError is a non-2xx GitHub response.
|
||||
type APIError struct {
|
||||
Message string
|
||||
Status int
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
return fmt.Sprintf("github: HTTP %d: %s", e.Status, e.Message)
|
||||
}
|
||||
|
||||
func apiMessage(body []byte) string {
|
||||
var m struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if json.Unmarshal(body, &m) == nil && m.Message != "" {
|
||||
return m.Message
|
||||
}
|
||||
s := strings.TrimSpace(string(body))
|
||||
if len(s) > 200 {
|
||||
s = s[:200]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func isNotFound(err error) bool {
|
||||
var ae *APIError
|
||||
return errors.As(err, &ae) && ae.Status == http.StatusNotFound
|
||||
}
|
||||
|
||||
// TokenSource yields a bearer token for requests against repo.
|
||||
type TokenSource interface {
|
||||
Token(ctx context.Context, repo string) (string, error)
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
rateReset time.Time
|
||||
blockedUntil time.Time
|
||||
auth TokenSource
|
||||
hc *http.Client
|
||||
// sleep is replaced in tests.
|
||||
sleep func(context.Context, time.Duration) error
|
||||
now func() time.Time
|
||||
cache map[string]cachedResponse
|
||||
base string
|
||||
rateRemaining atomic.Int64
|
||||
maxItems int
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// cachedResponse backs conditional requests: a 304 reply does not count against the primary rate limit.
|
||||
type cachedResponse struct {
|
||||
etag string
|
||||
link string
|
||||
body []byte
|
||||
}
|
||||
|
||||
func NewClient(baseURL string, auth TokenSource, hc *http.Client, maxItems int) *Client {
|
||||
c := &Client{base: strings.TrimRight(baseURL, "/"), hc: hc, auth: auth, maxItems: maxItems, sleep: sleepCtx, now: time.Now, cache: map[string]cachedResponse{}}
|
||||
c.rateRemaining.Store(-1)
|
||||
return c
|
||||
}
|
||||
|
||||
// RateLimitRemaining returns the last reported remaining request budget.
|
||||
func (c *Client) RateLimitRemaining() (int, bool) {
|
||||
v := c.rateRemaining.Load()
|
||||
return int(v), v >= 0
|
||||
}
|
||||
|
||||
func sleepCtx(ctx context.Context, d time.Duration) error {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-t.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// do performs one authenticated GET, retrying transient failures, and returns the body and Link header.
|
||||
func (c *Client) do(ctx context.Context, repo, target string) ([]byte, string, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
if err := c.sleep(ctx, time.Duration(attempt)*time.Second); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
body, link, retry, err := c.once(ctx, repo, target)
|
||||
if err == nil {
|
||||
return body, link, nil
|
||||
}
|
||||
lastErr = err
|
||||
if !retry {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil, "", lastErr
|
||||
}
|
||||
|
||||
func (c *Client) once(ctx context.Context, repo, target string) (body []byte, link string, retry bool, err error) {
|
||||
if gerr := c.guard(); gerr != nil {
|
||||
return nil, "", false, gerr
|
||||
}
|
||||
tok, err := c.auth.Token(ctx, repo)
|
||||
if err != nil {
|
||||
return nil, "", false, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
||||
if err != nil {
|
||||
return nil, "", false, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
req.Header.Set("Accept", acceptHeader)
|
||||
req.Header.Set("X-GitHub-Api-Version", apiVersion)
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
c.mu.Lock()
|
||||
cached, haveCache := c.cache[target]
|
||||
c.mu.Unlock()
|
||||
if haveCache {
|
||||
req.Header.Set("If-None-Match", cached.etag)
|
||||
}
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", ctx.Err() == nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
c.noteRate(resp.Header)
|
||||
if resp.StatusCode == http.StatusNotModified && haveCache {
|
||||
return cached.body, cached.link, false, nil
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
|
||||
if err != nil {
|
||||
return nil, "", true, err
|
||||
}
|
||||
if resp.StatusCode/100 != 2 {
|
||||
ae := &APIError{Status: resp.StatusCode, Message: apiMessage(data)}
|
||||
if c.noteLimited(resp, ae) {
|
||||
return nil, "", false, fmt.Errorf("%w: %v", model.ErrRateLimited, ae)
|
||||
}
|
||||
return nil, "", resp.StatusCode >= 500, ae
|
||||
}
|
||||
link = resp.Header.Get("Link")
|
||||
if etag := resp.Header.Get("ETag"); etag != "" {
|
||||
c.mu.Lock()
|
||||
c.cache[target] = cachedResponse{etag: etag, body: data, link: link}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
return data, link, false, nil
|
||||
}
|
||||
|
||||
// guard refuses requests while a limit block is active or the remaining budget is at the reserve.
|
||||
func (c *Client) guard() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
now := c.now()
|
||||
if now.Before(c.blockedUntil) {
|
||||
return fmt.Errorf("%w: blocked until %s", model.ErrRateLimited, c.blockedUntil.Format(time.RFC3339))
|
||||
}
|
||||
if rem := c.rateRemaining.Load(); rem >= 0 && rem < rateReserve && now.Before(c.rateReset) {
|
||||
return fmt.Errorf("%w: %d requests left, resets %s", model.ErrRateLimited, rem, c.rateReset.Format(time.RFC3339))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) noteRate(h http.Header) {
|
||||
if v, err := strconv.ParseInt(h.Get("X-RateLimit-Remaining"), 10, 64); err == nil {
|
||||
c.rateRemaining.Store(v)
|
||||
}
|
||||
if v, err := strconv.ParseInt(h.Get("X-RateLimit-Reset"), 10, 64); err == nil {
|
||||
c.mu.Lock()
|
||||
c.rateReset = time.Unix(v, 0)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// noteLimited records a block when the response is a primary or secondary rate-limit rejection.
|
||||
func (c *Client) noteLimited(resp *http.Response, ae *APIError) bool {
|
||||
if resp.StatusCode != http.StatusForbidden && resp.StatusCode != http.StatusTooManyRequests {
|
||||
return false
|
||||
}
|
||||
now := c.now()
|
||||
var until time.Time
|
||||
switch secs, err := strconv.Atoi(resp.Header.Get("Retry-After")); {
|
||||
case err == nil:
|
||||
until = now.Add(time.Duration(secs) * time.Second)
|
||||
case resp.Header.Get("X-RateLimit-Remaining") == "0":
|
||||
c.mu.Lock()
|
||||
until = c.rateReset
|
||||
c.mu.Unlock()
|
||||
case strings.Contains(strings.ToLower(ae.Message), "rate limit"):
|
||||
until = now.Add(secondaryBackoff)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.blockedUntil = until
|
||||
c.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *Client) url(path string, q url.Values) string {
|
||||
u := c.base + path
|
||||
if len(q) > 0 {
|
||||
u += "?" + q.Encode()
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func (c *Client) getJSON(ctx context.Context, repo, path string, q url.Values, out any) error {
|
||||
body, _, err := c.do(ctx, repo, c.url(path, q))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(body, out)
|
||||
}
|
||||
|
||||
// listAll follows Link pagination, collecting at most limit items.
|
||||
func listAll[T any](ctx context.Context, c *Client, repo, path string, q url.Values, limit int, unwrap func([]byte) ([]T, error)) ([]T, error) {
|
||||
if q == nil {
|
||||
q = url.Values{}
|
||||
}
|
||||
q.Set("per_page", strconv.Itoa(perPage))
|
||||
next := c.url(path, q)
|
||||
var all []T
|
||||
for next != "" && len(all) < limit {
|
||||
body, link, err := c.do(ctx, repo, next)
|
||||
if err != nil {
|
||||
return all, err
|
||||
}
|
||||
page, err := unwrap(body)
|
||||
if err != nil {
|
||||
return all, err
|
||||
}
|
||||
all = append(all, page...)
|
||||
next = nextLink(link)
|
||||
}
|
||||
if len(all) > limit {
|
||||
all = all[:limit]
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
func unwrapArray[T any](b []byte) ([]T, error) {
|
||||
var out []T
|
||||
return out, json.Unmarshal(b, &out)
|
||||
}
|
||||
|
||||
// nextLink extracts rel="next" from a Link header.
|
||||
func nextLink(h string) string {
|
||||
for part := range strings.SplitSeq(h, ",") {
|
||||
target, params, found := strings.Cut(part, ";")
|
||||
if found && strings.TrimSpace(params) == `rel="next"` {
|
||||
return strings.Trim(strings.TrimSpace(target), "<>")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package gh
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/ghtracker/internal/model"
|
||||
)
|
||||
|
||||
const (
|
||||
maxFailingNames = 5
|
||||
runsPerWorkflow = 5
|
||||
maxWorkflows = 100
|
||||
eventPullRequest = "pull_request"
|
||||
eventPullReqTgt = "pull_request_target"
|
||||
statusStateFail = "failure"
|
||||
statusStateError = "error"
|
||||
statusStatePend = "pending"
|
||||
)
|
||||
|
||||
// backgroundEvents are triggers that run without a person pushing code.
|
||||
var backgroundEvents = []string{"schedule", "dynamic", "workflow_dispatch", "workflow_run", "repository_dispatch"}
|
||||
|
||||
type userRef struct {
|
||||
Login string `json:"login"`
|
||||
}
|
||||
|
||||
type labelRef struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func labelNames(ls []labelRef) []string {
|
||||
out := make([]string, 0, len(ls))
|
||||
for _, l := range ls {
|
||||
out = append(out, l.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FetchRepo gathers the dashboard data for one "owner/name" repository.
|
||||
// A failure of the repository lookup fails the call; other sections degrade into Repo.Warnings.
|
||||
func (c *Client) FetchRepo(ctx context.Context, full string) (model.Repo, error) {
|
||||
var info struct {
|
||||
HTMLURL string `json:"html_url"`
|
||||
Description string `json:"description"`
|
||||
DefaultBranch string `json:"default_branch"`
|
||||
Archived bool `json:"archived"`
|
||||
}
|
||||
if err := c.getJSON(ctx, full, "/repos/"+full, nil, &info); err != nil {
|
||||
return model.Repo{}, fmt.Errorf("repo %s: %w", full, err)
|
||||
}
|
||||
r := model.Repo{
|
||||
FullName: full,
|
||||
URL: info.HTMLURL,
|
||||
ReleasesURL: info.HTMLURL + "/releases",
|
||||
Description: info.Description,
|
||||
DefaultBranch: info.DefaultBranch,
|
||||
Archived: info.Archived,
|
||||
Up: true,
|
||||
}
|
||||
var limited error
|
||||
warn := func(section string, err error) {
|
||||
if errors.Is(err, model.ErrRateLimited) {
|
||||
limited = err
|
||||
}
|
||||
r.Warnings = append(r.Warnings, fmt.Sprintf("%s: %v", section, err))
|
||||
}
|
||||
|
||||
var err error
|
||||
if r.Issues, err = c.openIssues(ctx, full); err != nil {
|
||||
warn("issues", err)
|
||||
}
|
||||
if r.PRs, err = c.openPRs(ctx, full); err != nil {
|
||||
warn("pull requests", err)
|
||||
}
|
||||
if cerr := c.attachChecks(ctx, full, r.PRs); cerr != nil {
|
||||
warn("pr checks", cerr)
|
||||
}
|
||||
if r.Workflows, err = c.workflows(ctx, full, r.DefaultBranch); err != nil {
|
||||
warn("workflows", err)
|
||||
}
|
||||
if r.Release, err = c.latestRelease(ctx, full, r.URL); err != nil {
|
||||
warn("releases", err)
|
||||
}
|
||||
if limited != nil {
|
||||
return model.Repo{}, limited
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (c *Client) openIssues(ctx context.Context, full string) ([]model.Issue, error) {
|
||||
type raw struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Title string `json:"title"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
User userRef `json:"user"`
|
||||
Labels []labelRef `json:"labels"`
|
||||
PullRequest json.RawMessage `json:"pull_request"`
|
||||
Number int `json:"number"`
|
||||
Comments int `json:"comments"`
|
||||
}
|
||||
// The issues endpoint also returns pull requests; they are dropped here and fetched separately.
|
||||
q := url.Values{"state": {"open"}, "sort": {"created"}, "direction": {"asc"}}
|
||||
items, err := listAll(ctx, c, full, "/repos/"+full+"/issues", q, c.maxItems, unwrapArray[raw])
|
||||
out := make([]model.Issue, 0, len(items))
|
||||
for _, i := range items {
|
||||
if len(i.PullRequest) > 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, model.Issue{
|
||||
Number: i.Number, Title: i.Title, URL: i.HTMLURL, Author: i.User.Login,
|
||||
Labels: labelNames(i.Labels), Comments: i.Comments, CreatedAt: i.CreatedAt, UpdatedAt: i.UpdatedAt,
|
||||
})
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *Client) openPRs(ctx context.Context, full string) ([]model.PullRequest, error) {
|
||||
type raw struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Title string `json:"title"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
User userRef `json:"user"`
|
||||
Head struct {
|
||||
SHA string `json:"sha"`
|
||||
} `json:"head"`
|
||||
Labels []labelRef `json:"labels"`
|
||||
Number int `json:"number"`
|
||||
Draft bool `json:"draft"`
|
||||
}
|
||||
q := url.Values{"state": {"open"}, "sort": {"created"}, "direction": {"asc"}}
|
||||
items, err := listAll(ctx, c, full, "/repos/"+full+"/pulls", q, c.maxItems, unwrapArray[raw])
|
||||
out := make([]model.PullRequest, 0, len(items))
|
||||
for _, p := range items {
|
||||
out = append(out, model.PullRequest{
|
||||
Number: p.Number, Title: p.Title, URL: p.HTMLURL, Author: p.User.Login, Draft: p.Draft,
|
||||
Labels: labelNames(p.Labels), HeadSHA: p.Head.SHA, CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt,
|
||||
Checks: model.CheckSummary{State: model.ChecksUnknown},
|
||||
})
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
// attachChecks fills each PR's check summary; it reports the first failure but still tries every PR.
|
||||
func (c *Client) attachChecks(ctx context.Context, full string, prs []model.PullRequest) error {
|
||||
var first error
|
||||
for i := range prs {
|
||||
s, err := c.checkSummary(ctx, full, prs[i].HeadSHA)
|
||||
if err != nil {
|
||||
if first == nil {
|
||||
first = fmt.Errorf("#%d: %w", prs[i].Number, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
prs[i].Checks = s
|
||||
}
|
||||
return first
|
||||
}
|
||||
|
||||
func (c *Client) checkSummary(ctx context.Context, full, sha string) (model.CheckSummary, error) {
|
||||
var runs struct {
|
||||
CheckRuns []struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Conclusion string `json:"conclusion"`
|
||||
} `json:"check_runs"`
|
||||
}
|
||||
base := "/repos/" + full + "/commits/" + sha
|
||||
if err := c.getJSON(ctx, full, base+"/check-runs", url.Values{"per_page": {"100"}}, &runs); err != nil {
|
||||
return model.CheckSummary{}, err
|
||||
}
|
||||
var status struct {
|
||||
Statuses []struct {
|
||||
Context string `json:"context"`
|
||||
State string `json:"state"`
|
||||
} `json:"statuses"`
|
||||
TotalCount int `json:"total_count"`
|
||||
}
|
||||
if err := c.getJSON(ctx, full, base+"/status", nil, &status); err != nil {
|
||||
return model.CheckSummary{}, err
|
||||
}
|
||||
|
||||
var s model.CheckSummary
|
||||
for _, cr := range runs.CheckRuns {
|
||||
s.Total++
|
||||
switch {
|
||||
case cr.Status != "completed":
|
||||
s.Pending++
|
||||
case (model.Run{Status: cr.Status, Conclusion: cr.Conclusion}).Failed():
|
||||
s.Failed++
|
||||
s.Failing = append(s.Failing, cr.Name)
|
||||
}
|
||||
}
|
||||
for _, st := range status.Statuses {
|
||||
s.Total++
|
||||
switch st.State {
|
||||
case statusStateFail, statusStateError:
|
||||
s.Failed++
|
||||
s.Failing = append(s.Failing, st.Context)
|
||||
case statusStatePend:
|
||||
s.Pending++
|
||||
}
|
||||
}
|
||||
if len(s.Failing) > maxFailingNames {
|
||||
s.Failing = s.Failing[:maxFailingNames]
|
||||
}
|
||||
switch {
|
||||
case s.Total == 0:
|
||||
s.State = model.ChecksNone
|
||||
case s.Failed > 0:
|
||||
s.State = model.ChecksFail
|
||||
case s.Pending > 0:
|
||||
s.State = model.ChecksPending
|
||||
default:
|
||||
s.State = model.ChecksPass
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
type rawRun struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Event string `json:"event"`
|
||||
HeadBranch string `json:"head_branch"`
|
||||
Status string `json:"status"`
|
||||
Conclusion string `json:"conclusion"`
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
func (r rawRun) model() model.Run {
|
||||
return model.Run{
|
||||
ID: r.ID, URL: r.HTMLURL, Event: r.Event, Branch: r.HeadBranch, Status: r.Status,
|
||||
Conclusion: r.Conclusion, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// workflows returns, per active workflow, its newest default-branch run and newest completed one.
|
||||
// Pull request runs are excluded because PR checks cover them.
|
||||
func (c *Client) workflows(ctx context.Context, full, branch string) ([]model.Workflow, error) {
|
||||
var list struct {
|
||||
Workflows []struct {
|
||||
Name string `json:"name"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
State string `json:"state"`
|
||||
ID int64 `json:"id"`
|
||||
} `json:"workflows"`
|
||||
}
|
||||
if err := c.getJSON(ctx, full, "/repos/"+full+"/actions/workflows", url.Values{"per_page": {"100"}}, &list); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []model.Workflow
|
||||
var first error
|
||||
for _, w := range list.Workflows {
|
||||
if w.State != "active" || len(out) >= maxWorkflows {
|
||||
continue
|
||||
}
|
||||
var runs struct {
|
||||
WorkflowRuns []rawRun `json:"workflow_runs"`
|
||||
}
|
||||
q := url.Values{
|
||||
"branch": {branch},
|
||||
"exclude_pull_requests": {"true"},
|
||||
"per_page": {fmt.Sprint(runsPerWorkflow)},
|
||||
}
|
||||
path := fmt.Sprintf("/repos/%s/actions/workflows/%d/runs", full, w.ID)
|
||||
if err := c.getJSON(ctx, full, path, q, &runs); err != nil {
|
||||
if first == nil {
|
||||
first = fmt.Errorf("%s: %w", w.Name, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if wf, ok := buildWorkflow(w.Name, w.HTMLURL, runs.WorkflowRuns); ok {
|
||||
out = append(out, wf)
|
||||
}
|
||||
}
|
||||
return out, first
|
||||
}
|
||||
|
||||
func buildWorkflow(name, htmlURL string, runs []rawRun) (model.Workflow, bool) {
|
||||
var kept []model.Run
|
||||
for _, r := range runs {
|
||||
if r.Event == eventPullRequest || r.Event == eventPullReqTgt {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, r.model())
|
||||
}
|
||||
if len(kept) == 0 {
|
||||
return model.Workflow{}, false
|
||||
}
|
||||
slices.SortStableFunc(kept, func(a, b model.Run) int { return b.CreatedAt.Compare(a.CreatedAt) })
|
||||
wf := model.Workflow{
|
||||
Name: name,
|
||||
URL: htmlURL,
|
||||
Latest: kept[0],
|
||||
Background: slices.Contains(backgroundEvents, kept[0].Event),
|
||||
}
|
||||
for i := range kept {
|
||||
if !kept[i].Running() {
|
||||
done := kept[i]
|
||||
wf.LastDone = &done
|
||||
break
|
||||
}
|
||||
}
|
||||
return wf, true
|
||||
}
|
||||
|
||||
func (c *Client) latestRelease(ctx context.Context, full, repoURL string) (*model.Release, error) {
|
||||
var rel struct {
|
||||
PublishedAt time.Time `json:"published_at"`
|
||||
TagName string `json:"tag_name"`
|
||||
Name string `json:"name"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
}
|
||||
err := c.getJSON(ctx, full, "/repos/"+full+"/releases/latest", nil, &rel)
|
||||
if err == nil {
|
||||
return &model.Release{Tag: rel.TagName, Name: rel.Name, URL: rel.HTMLURL, PublishedAt: rel.PublishedAt, Prerelease: rel.Prerelease}, nil
|
||||
}
|
||||
if !isNotFound(err) {
|
||||
return nil, err
|
||||
}
|
||||
// No published release: fall back to the newest tag so unreleased-but-tagged projects show a version.
|
||||
var tags []struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.getJSON(ctx, full, "/repos/"+full+"/tags", url.Values{"per_page": {"1"}}, &tags); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(tags) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &model.Release{
|
||||
Tag: tags[0].Name,
|
||||
URL: repoURL + "/releases/tag/" + url.PathEscape(tags[0].Name),
|
||||
FromTag: true,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
package gh
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/ghtracker/internal/model"
|
||||
)
|
||||
|
||||
func testKey(t *testing.T) (*rsa.PrivateKey, string) {
|
||||
t.Helper()
|
||||
k, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)})
|
||||
return k, string(p)
|
||||
}
|
||||
|
||||
// fakeGitHub serves canned JSON by path and counts hits per path.
|
||||
type fakeGitHub struct {
|
||||
srv *httptest.Server
|
||||
routes map[string]func(w http.ResponseWriter, r *http.Request)
|
||||
hits map[string]*atomic.Int64
|
||||
appKeyPub *rsa.PublicKey
|
||||
t *testing.T
|
||||
installs atomic.Int64
|
||||
tokens atomic.Int64
|
||||
}
|
||||
|
||||
func newFake(t *testing.T) *fakeGitHub {
|
||||
t.Helper()
|
||||
f := &fakeGitHub{t: t, routes: map[string]func(http.ResponseWriter, *http.Request){}, hits: map[string]*atomic.Int64{}}
|
||||
f.srv = httptest.NewServer(http.HandlerFunc(f.serve))
|
||||
t.Cleanup(f.srv.Close)
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fakeGitHub) serve(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/installation"):
|
||||
f.installs.Add(1)
|
||||
f.checkJWT(r)
|
||||
_, _ = w.Write([]byte(`{"id":42}`))
|
||||
return
|
||||
case strings.HasPrefix(r.URL.Path, "/app/installations/"):
|
||||
f.tokens.Add(1)
|
||||
f.checkJWT(r)
|
||||
exp := time.Now().Add(time.Hour).UTC().Format(time.RFC3339)
|
||||
_, _ = fmt.Fprintf(w, `{"token":"inst-token","expires_at":%q}`, exp)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer inst-token" {
|
||||
http.Error(w, `{"message":"Bad credentials"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
h, ok := f.routes[r.URL.Path]
|
||||
if !ok {
|
||||
http.Error(w, `{"message":"Not Found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
c := f.hits[r.URL.Path]
|
||||
if c == nil {
|
||||
c = &atomic.Int64{}
|
||||
f.hits[r.URL.Path] = c
|
||||
}
|
||||
c.Add(1)
|
||||
h(w, r)
|
||||
}
|
||||
|
||||
func (f *fakeGitHub) checkJWT(r *http.Request) {
|
||||
if f.appKeyPub == nil {
|
||||
return
|
||||
}
|
||||
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
parts := strings.Split(tok, ".")
|
||||
if len(parts) != 3 {
|
||||
f.t.Errorf("jwt has %d parts", len(parts))
|
||||
return
|
||||
}
|
||||
sig, _ := base64.RawURLEncoding.DecodeString(parts[2])
|
||||
sum := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
|
||||
if err := rsa.VerifyPKCS1v15(f.appKeyPub, crypto.SHA256, sum[:], sig); err != nil {
|
||||
f.t.Errorf("jwt signature invalid: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeGitHub) json(path, body string) {
|
||||
f.routes[path] = func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) }
|
||||
}
|
||||
|
||||
func (f *fakeGitHub) client(t *testing.T, maxItems int) *Client {
|
||||
t.Helper()
|
||||
key, pemStr := testKey(t)
|
||||
f.appKeyPub = &key.PublicKey
|
||||
auth, err := NewAppAuth(7, pemStr, f.srv.URL, 0, f.srv.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := NewClient(f.srv.URL, auth, f.srv.Client(), maxItems)
|
||||
c.sleep = func(context.Context, time.Duration) error { return nil }
|
||||
return c
|
||||
}
|
||||
|
||||
func TestJWTClaimsAndKeyFormats(t *testing.T) {
|
||||
key, pemStr := testKey(t)
|
||||
a, err := NewAppAuth(99, pemStr, "http://x", 0, http.DefaultClient)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fixed := time.Unix(1_700_000_000, 0)
|
||||
a.now = func() time.Time { return fixed }
|
||||
jwt, err := a.appJWT()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parts := strings.Split(jwt, ".")
|
||||
claimsRaw, _ := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
var claims map[string]any
|
||||
if err := json.Unmarshal(claimsRaw, &claims); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if claims["iss"] != "99" || int64(claims["iat"].(float64)) != fixed.Unix()-60 || int64(claims["exp"].(float64)) != fixed.Unix()+540 {
|
||||
t.Fatalf("claims = %v", claims)
|
||||
}
|
||||
sig, _ := base64.RawURLEncoding.DecodeString(parts[2])
|
||||
sum := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
|
||||
if err := rsa.VerifyPKCS1v15(&key.PublicKey, crypto.SHA256, sum[:], sig); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
der, _ := x509.MarshalPKCS8PrivateKey(key)
|
||||
if _, err := ParsePrivateKey(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})); err != nil {
|
||||
t.Fatalf("pkcs8: %v", err)
|
||||
}
|
||||
if _, err := ParsePrivateKey([]byte("garbage")); err == nil {
|
||||
t.Fatal("garbage key must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenCachedAndInstallationDiscoveredOnce(t *testing.T) {
|
||||
f := newFake(t)
|
||||
c := f.client(t, 10)
|
||||
f.json("/repos/o/r", `{"html_url":"https://github.com/o/r","default_branch":"main"}`)
|
||||
for range 3 {
|
||||
if err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if f.installs.Load() != 1 || f.tokens.Load() != 1 {
|
||||
t.Fatalf("installs=%d tokens=%d, want 1 each", f.installs.Load(), f.tokens.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginationStopsAtLimit(t *testing.T) {
|
||||
f := newFake(t)
|
||||
c := f.client(t, 3)
|
||||
f.routes["/repos/o/r/issues"] = func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("page") == "2" {
|
||||
_, _ = w.Write([]byte(`[{"number":3,"title":"c"},{"number":4,"title":"d"}]`))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Link", fmt.Sprintf(`<%s/repos/o/r/issues?page=2>; rel="next", <x>; rel="last"`, f.srv.URL))
|
||||
_, _ = w.Write([]byte(`[{"number":1,"title":"a"},{"number":2,"title":"b"}]`))
|
||||
}
|
||||
got, err := c.openIssues(context.Background(), "o/r")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("got %d issues, want 3 (limit)", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextLink(t *testing.T) {
|
||||
h := `<https://api/x?page=2>; rel="next", <https://api/x?page=9>; rel="last"`
|
||||
if got := nextLink(h); got != "https://api/x?page=2" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if nextLink(`<https://api/x?page=1>; rel="prev"`) != "" || nextLink("") != "" {
|
||||
t.Fatal("no next link expected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestETagConditionalRequestServedFromCache(t *testing.T) {
|
||||
f := newFake(t)
|
||||
c := f.client(t, 10)
|
||||
var conditional atomic.Int64
|
||||
f.routes["/repos/o/r"] = func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("If-None-Match") == `"v1"` {
|
||||
conditional.Add(1)
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
w.Header().Set("ETag", `"v1"`)
|
||||
_, _ = w.Write([]byte(`{"default_branch":"main"}`))
|
||||
}
|
||||
for range 2 {
|
||||
var out struct {
|
||||
DefaultBranch string `json:"default_branch"`
|
||||
}
|
||||
if err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &out); err != nil || out.DefaultBranch != "main" {
|
||||
t.Fatalf("out=%+v err=%v", out, err)
|
||||
}
|
||||
}
|
||||
if conditional.Load() != 1 {
|
||||
t.Fatalf("conditional requests = %d, want 1", conditional.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetriesServerErrorsThenSucceeds(t *testing.T) {
|
||||
f := newFake(t)
|
||||
c := f.client(t, 10)
|
||||
var n atomic.Int64
|
||||
f.routes["/repos/o/r"] = func(w http.ResponseWriter, _ *http.Request) {
|
||||
if n.Add(1) < 3 {
|
||||
http.Error(w, `{"message":"boom"}`, http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
}
|
||||
if err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n.Load() != 3 {
|
||||
t.Fatalf("attempts = %d", n.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoRetryOnClientError(t *testing.T) {
|
||||
f := newFake(t)
|
||||
c := f.client(t, 10)
|
||||
err := c.getJSON(context.Background(), "o/r", "/missing", nil, &struct{}{})
|
||||
if !isNotFound(err) {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrimaryRateLimitBlocksFurtherRequests(t *testing.T) {
|
||||
f := newFake(t)
|
||||
c := f.client(t, 10)
|
||||
reset := time.Now().Add(30 * time.Minute)
|
||||
f.routes["/repos/o/r"] = func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("X-RateLimit-Remaining", "0")
|
||||
w.Header().Set("X-RateLimit-Reset", fmt.Sprint(reset.Unix()))
|
||||
http.Error(w, `{"message":"API rate limit exceeded"}`, http.StatusForbidden)
|
||||
}
|
||||
err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{})
|
||||
if !errors.Is(err, model.ErrRateLimited) {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
hits := f.hits["/repos/o/r"].Load()
|
||||
err = c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{})
|
||||
if !errors.Is(err, model.ErrRateLimited) || f.hits["/repos/o/r"].Load() != hits {
|
||||
t.Fatalf("second call must be refused locally; err=%v hits=%d->%d", err, hits, f.hits["/repos/o/r"].Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecondaryRateLimitHonoursRetryAfter(t *testing.T) {
|
||||
f := newFake(t)
|
||||
c := f.client(t, 10)
|
||||
now := time.Now()
|
||||
c.now = func() time.Time { return now }
|
||||
f.routes["/repos/o/r"] = func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Retry-After", "120")
|
||||
http.Error(w, `{"message":"secondary rate limit"}`, http.StatusTooManyRequests)
|
||||
}
|
||||
if err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{}); !errors.Is(err, model.ErrRateLimited) {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
c.now = func() time.Time { return now.Add(119 * time.Second) }
|
||||
if err := c.guard(); !errors.Is(err, model.ErrRateLimited) {
|
||||
t.Fatalf("still blocked at 119s, got %v", err)
|
||||
}
|
||||
c.now = func() time.Time { return now.Add(121 * time.Second) }
|
||||
if err := c.guard(); err != nil {
|
||||
t.Fatalf("unblocked at 121s, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLowBudgetStopsBeforeQuotaExhausted(t *testing.T) {
|
||||
f := newFake(t)
|
||||
c := f.client(t, 10)
|
||||
f.routes["/repos/o/r"] = func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("X-RateLimit-Remaining", "50")
|
||||
w.Header().Set("X-RateLimit-Reset", fmt.Sprint(time.Now().Add(time.Hour).Unix()))
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
}
|
||||
if err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{}); !errors.Is(err, model.ErrRateLimited) {
|
||||
t.Fatalf("budget below reserve must refuse, got %v", err)
|
||||
}
|
||||
if rem, ok := c.RateLimitRemaining(); !ok || rem != 50 {
|
||||
t.Fatalf("remaining = %d %v", rem, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlain403IsNotTreatedAsRateLimit(t *testing.T) {
|
||||
f := newFake(t)
|
||||
c := f.client(t, 10)
|
||||
f.routes["/repos/o/r"] = func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, `{"message":"Resource not accessible by integration"}`, http.StatusForbidden)
|
||||
}
|
||||
err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{})
|
||||
var ae *APIError
|
||||
if errors.Is(err, model.ErrRateLimited) || !errors.As(err, &ae) || ae.Status != 403 {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if err := c.guard(); err != nil {
|
||||
t.Fatalf("plain 403 must not block: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func registerRepo(f *fakeGitHub) {
|
||||
f.json("/repos/o/r", `{"html_url":"https://github.com/o/r","description":"d","default_branch":"main"}`)
|
||||
f.json("/repos/o/r/issues", `[
|
||||
{"number":1,"title":"real issue","html_url":"u1","user":{"login":"a"},"labels":[{"name":"bug"}],"comments":2,"created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-02T00:00:00Z"},
|
||||
{"number":2,"title":"a PR","html_url":"u2","pull_request":{"url":"x"}}]`)
|
||||
f.json("/repos/o/r/pulls", `[{"number":2,"title":"a PR","html_url":"u2","user":{"login":"b"},"draft":false,"head":{"sha":"abc"},"created_at":"2026-01-03T00:00:00Z","updated_at":"2026-01-03T00:00:00Z"}]`)
|
||||
f.json("/repos/o/r/commits/abc/check-runs", `{"check_runs":[{"name":"build","status":"completed","conclusion":"failure"},{"name":"lint","status":"completed","conclusion":"success"},{"name":"slow","status":"in_progress"}]}`)
|
||||
f.json("/repos/o/r/commits/abc/status", `{"total_count":1,"statuses":[{"context":"ci/ext","state":"error"}]}`)
|
||||
f.json("/repos/o/r/actions/workflows", `{"workflows":[
|
||||
{"id":1,"name":"CI","html_url":"w1","state":"active"},
|
||||
{"id":2,"name":"Nightly","html_url":"w2","state":"active"},
|
||||
{"id":3,"name":"Old","html_url":"w3","state":"disabled_manually"},
|
||||
{"id":4,"name":"Never ran","html_url":"w4","state":"active"}]}`)
|
||||
f.json("/repos/o/r/actions/workflows/1/runs", `{"workflow_runs":[
|
||||
{"id":11,"html_url":"r11","event":"push","head_branch":"main","status":"in_progress","created_at":"2026-01-05T00:00:00Z"},
|
||||
{"id":10,"html_url":"r10","event":"push","head_branch":"main","status":"completed","conclusion":"success","created_at":"2026-01-04T00:00:00Z"}]}`)
|
||||
f.json("/repos/o/r/actions/workflows/2/runs", `{"workflow_runs":[
|
||||
{"id":20,"html_url":"r20","event":"schedule","head_branch":"main","status":"completed","conclusion":"failure","created_at":"2026-01-05T00:00:00Z"}]}`)
|
||||
f.json("/repos/o/r/actions/workflows/4/runs", `{"workflow_runs":[]}`)
|
||||
f.json("/repos/o/r/releases/latest", `{"tag_name":"v1.2.3","name":"x","html_url":"rel","published_at":"2026-01-01T00:00:00Z"}`)
|
||||
}
|
||||
|
||||
func TestFetchRepoEndToEnd(t *testing.T) {
|
||||
f := newFake(t)
|
||||
c := f.client(t, 50)
|
||||
registerRepo(f)
|
||||
r, err := c.FetchRepo(context.Background(), "o/r")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(r.Warnings) != 0 {
|
||||
t.Fatalf("warnings: %v", r.Warnings)
|
||||
}
|
||||
if len(r.Issues) != 1 || r.Issues[0].Number != 1 || r.Issues[0].Labels[0] != "bug" {
|
||||
t.Fatalf("issues must exclude PRs: %+v", r.Issues)
|
||||
}
|
||||
if len(r.PRs) != 1 || r.PRs[0].Checks.State != model.ChecksFail || r.PRs[0].Checks.Failed != 2 || r.PRs[0].Checks.Pending != 1 {
|
||||
t.Fatalf("pr checks: %+v", r.PRs)
|
||||
}
|
||||
if got := r.PRs[0].Checks.Failing; len(got) != 2 || got[0] != "build" || got[1] != "ci/ext" {
|
||||
t.Fatalf("failing names: %v", got)
|
||||
}
|
||||
if len(r.Workflows) != 2 {
|
||||
t.Fatalf("want CI and Nightly only, got %+v", r.Workflows)
|
||||
}
|
||||
ci, nightly := r.Workflows[0], r.Workflows[1]
|
||||
if !ci.Latest.Running() || ci.LastDone == nil || ci.LastDone.ID != 10 || ci.Background {
|
||||
t.Fatalf("ci: %+v", ci)
|
||||
}
|
||||
if !nightly.Background || nightly.LastDone == nil || !nightly.LastDone.Failed() {
|
||||
t.Fatalf("nightly: %+v", nightly)
|
||||
}
|
||||
if r.Release == nil || r.Release.Tag != "v1.2.3" || r.Release.URL != "rel" || r.ReleasesURL != "https://github.com/o/r/releases" {
|
||||
t.Fatalf("release: %+v %s", r.Release, r.ReleasesURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseFallsBackToTag(t *testing.T) {
|
||||
f := newFake(t)
|
||||
c := f.client(t, 10)
|
||||
f.json("/repos/o/r/tags", `[{"name":"v0.1.0"}]`)
|
||||
rel, err := c.latestRelease(context.Background(), "o/r", "https://github.com/o/r")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !rel.FromTag || rel.Tag != "v0.1.0" || rel.URL != "https://github.com/o/r/releases/tag/v0.1.0" {
|
||||
t.Fatalf("%+v", rel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseNoneAtAll(t *testing.T) {
|
||||
f := newFake(t)
|
||||
c := f.client(t, 10)
|
||||
f.json("/repos/o/r/tags", `[]`)
|
||||
rel, err := c.latestRelease(context.Background(), "o/r", "https://github.com/o/r")
|
||||
if err != nil || rel != nil {
|
||||
t.Fatalf("rel=%+v err=%v", rel, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSectionFailureDegradesToWarning(t *testing.T) {
|
||||
f := newFake(t)
|
||||
c := f.client(t, 10)
|
||||
registerRepo(f)
|
||||
f.routes["/repos/o/r/actions/workflows"] = func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, `{"message":"Resource not accessible by integration"}`, http.StatusForbidden)
|
||||
}
|
||||
r, err := c.FetchRepo(context.Background(), "o/r")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(r.Warnings) != 1 || !strings.Contains(r.Warnings[0], "workflows") || len(r.Issues) != 1 {
|
||||
t.Fatalf("warnings=%v issues=%d", r.Warnings, len(r.Issues))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitInSectionFailsWholeRepo(t *testing.T) {
|
||||
f := newFake(t)
|
||||
c := f.client(t, 10)
|
||||
registerRepo(f)
|
||||
f.routes["/repos/o/r/pulls"] = func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Retry-After", "60")
|
||||
http.Error(w, `{"message":"secondary rate limit"}`, http.StatusForbidden)
|
||||
}
|
||||
if _, err := c.FetchRepo(context.Background(), "o/r"); !errors.Is(err, model.ErrRateLimited) {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWorkflowIgnoresPullRequestRuns(t *testing.T) {
|
||||
runs := []rawRun{
|
||||
{ID: 2, Event: "pull_request", Status: "completed", Conclusion: "failure", CreatedAt: time.Unix(200, 0)},
|
||||
{ID: 1, Event: "push", Status: "completed", Conclusion: "success", CreatedAt: time.Unix(100, 0)},
|
||||
}
|
||||
wf, ok := buildWorkflow("CI", "u", runs)
|
||||
if !ok || wf.Latest.ID != 1 || wf.LastDone.ID != 1 {
|
||||
t.Fatalf("%+v", wf)
|
||||
}
|
||||
if _, ok := buildWorkflow("CI", "u", runs[:1]); ok {
|
||||
t.Fatal("only PR runs must yield no workflow")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIErrorMessageParsing(t *testing.T) {
|
||||
if got := apiMessage([]byte(`{"message":"nope"}`)); got != "nope" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := apiMessage([]byte(strings.Repeat("x", 500))); len(got) != 200 {
|
||||
t.Fatalf("len=%d", len(got))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// Package metrics exposes dashboard state as Prometheus metrics, computed at scrape time
|
||||
// so age-based series keep advancing between refreshes.
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
|
||||
"github.com/lukaszraczylo/ghtracker/internal/collector"
|
||||
"github.com/lukaszraczylo/ghtracker/internal/model"
|
||||
)
|
||||
|
||||
const ns = "ghtracker"
|
||||
|
||||
// Source supplies the state to export.
|
||||
type Source interface {
|
||||
View() collector.View
|
||||
Stats() collector.Stats
|
||||
}
|
||||
|
||||
// RateLimit reports the remaining GitHub request budget; ok is false before the first response.
|
||||
type RateLimit func() (remaining int, ok bool)
|
||||
|
||||
type Collector struct {
|
||||
src Source
|
||||
rate RateLimit
|
||||
d map[string]*prometheus.Desc
|
||||
}
|
||||
|
||||
var (
|
||||
repoLabels = []string{"repo"}
|
||||
prLabels = []string{"repo", "number"}
|
||||
workflowLabels = []string{"repo", "workflow", "background"}
|
||||
)
|
||||
|
||||
var descs = []struct {
|
||||
key, help string
|
||||
labels []string
|
||||
}{
|
||||
{"repo_up", "1 if the last refresh of the repo succeeded.", repoLabels},
|
||||
{"repo_health", "Worst alert severity for the repo: 0 ok, 1 warn, 2 crit.", repoLabels},
|
||||
{"repo_last_refresh_timestamp_seconds", "Unix time of the last successful refresh of the repo.", repoLabels},
|
||||
{"repo_open_issues", "Open issues, excluding pull requests.", repoLabels},
|
||||
{"repo_open_prs", "Open pull requests, including drafts.", repoLabels},
|
||||
{"repo_open_prs_failing_checks", "Open non-draft pull requests whose checks fail.", repoLabels},
|
||||
{"repo_oldest_pr_age_seconds", "Age of the oldest open non-draft pull request; 0 if none.", repoLabels},
|
||||
{"repo_stale_issues", "Open issues without activity for longer than thresholds.issue_stale_after.", repoLabels},
|
||||
{"pr_age_seconds", "Age of an open non-draft pull request.", prLabels},
|
||||
{"pr_checks_failing", "1 if the pull request has failing checks.", prLabels},
|
||||
{"workflow_failing", "1 if the newest completed default-branch run of the workflow failed.", workflowLabels},
|
||||
{"workflow_running", "1 if the workflow has a run in progress or queued.", workflowLabels},
|
||||
{"workflow_last_run_timestamp_seconds", "Unix time the newest default-branch run was created.", workflowLabels},
|
||||
{"release_info", "Latest release or tag; value is always 1.", []string{"repo", "version", "url"}},
|
||||
{"release_published_timestamp_seconds", "Unix time the latest release was published.", repoLabels},
|
||||
{"alerts", "Active alerts by severity.", []string{"severity"}},
|
||||
{"last_refresh_timestamp_seconds", "Unix time the last refresh started.", nil},
|
||||
{"last_refresh_duration_seconds", "Duration of the last refresh.", nil},
|
||||
{"refreshes_total", "Completed refresh cycles.", nil},
|
||||
{"repo_refresh_errors_total", "Repo refresh failures.", nil},
|
||||
{"github_rate_limit_remaining", "Remaining GitHub API requests in the current window.", nil},
|
||||
}
|
||||
|
||||
func New(src Source, rate RateLimit) *Collector {
|
||||
c := &Collector{src: src, rate: rate, d: make(map[string]*prometheus.Desc, len(descs))}
|
||||
for _, d := range descs {
|
||||
c.d[d.key] = prometheus.NewDesc(prometheus.BuildFQName(ns, "", d.key), d.help, d.labels, nil)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Collector) Describe(ch chan<- *prometheus.Desc) {
|
||||
for _, d := range c.d {
|
||||
ch <- d
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Collector) Collect(ch chan<- prometheus.Metric) {
|
||||
g := func(key string, v float64, labels ...string) {
|
||||
ch <- prometheus.MustNewConstMetric(c.d[key], prometheus.GaugeValue, v, labels...)
|
||||
}
|
||||
ctr := func(key string, v float64) {
|
||||
ch <- prometheus.MustNewConstMetric(c.d[key], prometheus.CounterValue, v)
|
||||
}
|
||||
|
||||
v, st := c.src.View(), c.src.Stats()
|
||||
for _, r := range v.Repos {
|
||||
c.collectRepo(g, r, v)
|
||||
}
|
||||
for _, sev := range []model.Severity{model.Crit, model.Warn} {
|
||||
n := v.Crit
|
||||
if sev == model.Warn {
|
||||
n = v.Warn
|
||||
}
|
||||
g("alerts", float64(n), sev.String())
|
||||
}
|
||||
|
||||
g("last_refresh_timestamp_seconds", unix(st.LastRefresh))
|
||||
g("last_refresh_duration_seconds", st.LastDuration.Seconds())
|
||||
ctr("refreshes_total", float64(st.RefreshTotal))
|
||||
ctr("repo_refresh_errors_total", float64(st.RepoErrorsTotal))
|
||||
if c.rate != nil {
|
||||
if rem, ok := c.rate(); ok {
|
||||
g("github_rate_limit_remaining", float64(rem))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Collector) collectRepo(g func(string, float64, ...string), r model.Repo, v collector.View) {
|
||||
g("repo_up", b2f(r.Up), r.FullName)
|
||||
g("repo_health", float64(r.Health), r.FullName)
|
||||
g("repo_last_refresh_timestamp_seconds", unix(r.RefreshedAt), r.FullName)
|
||||
g("repo_open_issues", float64(len(r.Issues)), r.FullName)
|
||||
g("repo_open_prs", float64(len(r.PRs)), r.FullName)
|
||||
|
||||
var failing int
|
||||
var oldest time.Duration
|
||||
for _, pr := range r.PRs {
|
||||
if pr.Draft {
|
||||
continue
|
||||
}
|
||||
age := v.Now.Sub(pr.CreatedAt)
|
||||
oldest = max(oldest, age)
|
||||
num := strconv.Itoa(pr.Number)
|
||||
g("pr_age_seconds", age.Seconds(), r.FullName, num)
|
||||
g("pr_checks_failing", b2f(pr.Checks.State == model.ChecksFail), r.FullName, num)
|
||||
if pr.Checks.State == model.ChecksFail {
|
||||
failing++
|
||||
}
|
||||
}
|
||||
g("repo_open_prs_failing_checks", float64(failing), r.FullName)
|
||||
g("repo_oldest_pr_age_seconds", oldest.Seconds(), r.FullName)
|
||||
|
||||
var stale int
|
||||
if v.Thresholds.IssueStaleAfter > 0 {
|
||||
for _, is := range r.Issues {
|
||||
if v.Now.Sub(is.UpdatedAt) >= v.Thresholds.IssueStaleAfter {
|
||||
stale++
|
||||
}
|
||||
}
|
||||
}
|
||||
g("repo_stale_issues", float64(stale), r.FullName)
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, wf := range r.Workflows {
|
||||
// Two workflows may share a name; duplicate label sets would fail the whole scrape.
|
||||
if seen[wf.Name] {
|
||||
continue
|
||||
}
|
||||
seen[wf.Name] = true
|
||||
bg := strconv.FormatBool(wf.Background)
|
||||
g("workflow_failing", b2f(wf.LastDone != nil && wf.LastDone.Failed() && !collector.Dormant(wf, v.Now, v.Thresholds)), r.FullName, wf.Name, bg)
|
||||
g("workflow_running", b2f(wf.Latest.Running()), r.FullName, wf.Name, bg)
|
||||
g("workflow_last_run_timestamp_seconds", unix(wf.Latest.CreatedAt), r.FullName, wf.Name, bg)
|
||||
}
|
||||
|
||||
if rel := r.Release; rel != nil {
|
||||
g("release_info", 1, r.FullName, rel.Tag, rel.URL)
|
||||
if !rel.PublishedAt.IsZero() {
|
||||
g("release_published_timestamp_seconds", unix(rel.PublishedAt), r.FullName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func b2f(b bool) float64 {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func unix(t time.Time) float64 {
|
||||
if t.IsZero() {
|
||||
return 0
|
||||
}
|
||||
return float64(t.Unix())
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/testutil"
|
||||
|
||||
"github.com/lukaszraczylo/ghtracker/internal/collector"
|
||||
"github.com/lukaszraczylo/ghtracker/internal/config"
|
||||
"github.com/lukaszraczylo/ghtracker/internal/model"
|
||||
)
|
||||
|
||||
var now = time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
type fakeSource struct {
|
||||
st collector.Stats
|
||||
v collector.View
|
||||
}
|
||||
|
||||
func (f fakeSource) View() collector.View { return f.v }
|
||||
func (f fakeSource) Stats() collector.Stats { return f.st }
|
||||
|
||||
func source() fakeSource {
|
||||
failed := &model.Run{Status: "completed", Conclusion: "failure"}
|
||||
repo := model.Repo{
|
||||
FullName: "o/r", Up: true, Health: model.Crit, RefreshedAt: now.Add(-time.Hour),
|
||||
Issues: []model.Issue{{UpdatedAt: now.Add(-40 * 24 * time.Hour)}, {UpdatedAt: now}},
|
||||
PRs: []model.PullRequest{
|
||||
{Number: 7, CreatedAt: now.Add(-50 * time.Hour), Checks: model.CheckSummary{State: model.ChecksFail}},
|
||||
{Number: 8, Draft: true, CreatedAt: now.Add(-900 * time.Hour)},
|
||||
},
|
||||
Workflows: []model.Workflow{
|
||||
{Name: "CI", Latest: *failed, LastDone: failed},
|
||||
{Name: "CI", Latest: *failed, LastDone: failed}, // duplicate name must not break the scrape
|
||||
{Name: "Nightly", Background: true, Latest: model.Run{Status: "in_progress", CreatedAt: now}},
|
||||
},
|
||||
Release: &model.Release{Tag: "v1.0.0", URL: "https://x/rel", PublishedAt: now.Add(-24 * time.Hour)},
|
||||
}
|
||||
return fakeSource{
|
||||
v: collector.View{Now: now, Repos: []model.Repo{repo}, Crit: 1, Warn: 2,
|
||||
Thresholds: config.Thresholds{IssueStaleAfter: 30 * 24 * time.Hour}},
|
||||
st: collector.Stats{LastRefresh: now.Add(-time.Hour), LastDuration: 3 * time.Second, RefreshTotal: 4, RepoErrorsTotal: 1, Loaded: true},
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrapeValuesAndLint(t *testing.T) {
|
||||
reg := prometheus.NewPedanticRegistry()
|
||||
reg.MustRegister(New(source(), func() (int, bool) { return 4321, true }))
|
||||
want := `
|
||||
# HELP ghtracker_repo_up 1 if the last refresh of the repo succeeded.
|
||||
# TYPE ghtracker_repo_up gauge
|
||||
ghtracker_repo_up{repo="o/r"} 1
|
||||
# HELP ghtracker_repo_open_prs_failing_checks Open non-draft pull requests whose checks fail.
|
||||
# TYPE ghtracker_repo_open_prs_failing_checks gauge
|
||||
ghtracker_repo_open_prs_failing_checks{repo="o/r"} 1
|
||||
# HELP ghtracker_repo_oldest_pr_age_seconds Age of the oldest open non-draft pull request; 0 if none.
|
||||
# TYPE ghtracker_repo_oldest_pr_age_seconds gauge
|
||||
ghtracker_repo_oldest_pr_age_seconds{repo="o/r"} 180000
|
||||
# HELP ghtracker_repo_stale_issues Open issues without activity for longer than thresholds.issue_stale_after.
|
||||
# TYPE ghtracker_repo_stale_issues gauge
|
||||
ghtracker_repo_stale_issues{repo="o/r"} 1
|
||||
# HELP ghtracker_github_rate_limit_remaining Remaining GitHub API requests in the current window.
|
||||
# TYPE ghtracker_github_rate_limit_remaining gauge
|
||||
ghtracker_github_rate_limit_remaining 4321
|
||||
`
|
||||
if err := testutil.GatherAndCompare(reg, strings.NewReader(want),
|
||||
"ghtracker_repo_up", "ghtracker_repo_open_prs_failing_checks", "ghtracker_repo_oldest_pr_age_seconds",
|
||||
"ghtracker_repo_stale_issues", "ghtracker_github_rate_limit_remaining"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if probs, err := testutil.GatherAndLint(reg); err != nil || len(probs) != 0 {
|
||||
t.Fatalf("lint: %v %v", probs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraftPRsExcludedFromAgeSeries(t *testing.T) {
|
||||
reg := prometheus.NewPedanticRegistry()
|
||||
reg.MustRegister(New(source(), nil))
|
||||
n, err := testutil.GatherAndCount(reg, "ghtracker_pr_age_seconds")
|
||||
if err != nil || n != 1 {
|
||||
t.Fatalf("pr_age series = %d err=%v, want 1 (draft excluded)", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkflowSeriesDedupedAndLabelled(t *testing.T) {
|
||||
reg := prometheus.NewPedanticRegistry()
|
||||
reg.MustRegister(New(source(), nil))
|
||||
want := `
|
||||
# HELP ghtracker_workflow_failing 1 if the newest completed default-branch run of the workflow failed.
|
||||
# TYPE ghtracker_workflow_failing gauge
|
||||
ghtracker_workflow_failing{background="false",repo="o/r",workflow="CI"} 1
|
||||
ghtracker_workflow_failing{background="true",repo="o/r",workflow="Nightly"} 0
|
||||
`
|
||||
if err := testutil.GatherAndCompare(reg, strings.NewReader(want), "ghtracker_workflow_failing"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseAndCounters(t *testing.T) {
|
||||
reg := prometheus.NewPedanticRegistry()
|
||||
reg.MustRegister(New(source(), nil))
|
||||
want := `
|
||||
# HELP ghtracker_release_info Latest release or tag; value is always 1.
|
||||
# TYPE ghtracker_release_info gauge
|
||||
ghtracker_release_info{repo="o/r",url="https://x/rel",version="v1.0.0"} 1
|
||||
# HELP ghtracker_refreshes_total Completed refresh cycles.
|
||||
# TYPE ghtracker_refreshes_total counter
|
||||
ghtracker_refreshes_total 4
|
||||
# HELP ghtracker_repo_refresh_errors_total Repo refresh failures.
|
||||
# TYPE ghtracker_repo_refresh_errors_total counter
|
||||
ghtracker_repo_refresh_errors_total 1
|
||||
# HELP ghtracker_alerts Active alerts by severity.
|
||||
# TYPE ghtracker_alerts gauge
|
||||
ghtracker_alerts{severity="crit"} 1
|
||||
ghtracker_alerts{severity="warn"} 2
|
||||
`
|
||||
if err := testutil.GatherAndCompare(reg, strings.NewReader(want),
|
||||
"ghtracker_release_info", "ghtracker_refreshes_total", "ghtracker_repo_refresh_errors_total", "ghtracker_alerts"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitOmittedWhenUnknown(t *testing.T) {
|
||||
reg := prometheus.NewPedanticRegistry()
|
||||
reg.MustRegister(New(source(), func() (int, bool) { return 0, false }))
|
||||
n, err := testutil.GatherAndCount(reg, "ghtracker_github_rate_limit_remaining")
|
||||
if err != nil || n != 0 {
|
||||
t.Fatalf("n=%d err=%v", n, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// Package model holds the data shapes shared by the collector, metrics and web layers.
|
||||
package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrRateLimited means GitHub limits were hit or are about to be; callers must stop issuing requests.
|
||||
var ErrRateLimited = errors.New("github rate limit reached")
|
||||
|
||||
type Severity int
|
||||
|
||||
const (
|
||||
OK Severity = iota
|
||||
Warn
|
||||
Crit
|
||||
)
|
||||
|
||||
func (s Severity) String() string {
|
||||
switch s {
|
||||
case Warn:
|
||||
return "warn"
|
||||
case Crit:
|
||||
return "crit"
|
||||
default:
|
||||
return "ok"
|
||||
}
|
||||
}
|
||||
|
||||
type CheckState string
|
||||
|
||||
const (
|
||||
ChecksNone CheckState = "none"
|
||||
ChecksPending CheckState = "pending"
|
||||
ChecksPass CheckState = "success"
|
||||
ChecksFail CheckState = "failure"
|
||||
ChecksUnknown CheckState = "unknown"
|
||||
)
|
||||
|
||||
type Repo struct {
|
||||
RefreshedAt time.Time
|
||||
Release *Release
|
||||
Description string
|
||||
FullName string
|
||||
DefaultBranch string
|
||||
ReleasesURL string
|
||||
// Err is set when the last refresh failed entirely; the other fields hold the last good data.
|
||||
Err string
|
||||
URL string
|
||||
Issues []Issue
|
||||
PRs []PullRequest
|
||||
Workflows []Workflow
|
||||
// Warnings lists sections that failed to load while the rest succeeded.
|
||||
Warnings []string
|
||||
Health Severity
|
||||
Archived bool
|
||||
Up bool
|
||||
}
|
||||
|
||||
type Release struct {
|
||||
PublishedAt time.Time
|
||||
Tag string
|
||||
Name string
|
||||
URL string
|
||||
Prerelease bool
|
||||
// FromTag marks a version taken from tags because the repo has no releases.
|
||||
FromTag bool
|
||||
}
|
||||
|
||||
type Issue struct {
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
Title string
|
||||
URL string
|
||||
Author string
|
||||
Labels []string
|
||||
Number int
|
||||
Comments int
|
||||
}
|
||||
|
||||
type PullRequest struct {
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
Title string
|
||||
URL string
|
||||
Author string
|
||||
HeadSHA string
|
||||
Labels []string
|
||||
Checks CheckSummary
|
||||
Number int
|
||||
Draft bool
|
||||
}
|
||||
|
||||
type CheckSummary struct {
|
||||
State CheckState
|
||||
Failing []string
|
||||
Total int
|
||||
Failed int
|
||||
Pending int
|
||||
}
|
||||
|
||||
type Workflow struct {
|
||||
// LastDone is the newest completed run; nil when no run has completed yet.
|
||||
LastDone *Run
|
||||
Name string
|
||||
URL string
|
||||
Latest Run
|
||||
Background bool
|
||||
}
|
||||
|
||||
type Run struct {
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
URL string
|
||||
Event string
|
||||
Branch string
|
||||
Status string
|
||||
Conclusion string
|
||||
ID int64
|
||||
}
|
||||
|
||||
// Running reports whether the run has not completed.
|
||||
func (r Run) Running() bool { return r.Status != "completed" }
|
||||
|
||||
// Failed reports whether a completed run ended in a failing conclusion.
|
||||
func (r Run) Failed() bool {
|
||||
switch r.Conclusion {
|
||||
case "failure", "timed_out", "startup_failure", "action_required":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type Alert struct {
|
||||
Since time.Time
|
||||
Repo string
|
||||
Kind string
|
||||
Subject string
|
||||
Detail string
|
||||
URL string
|
||||
Severity Severity
|
||||
}
|
||||
|
||||
// Message renders the alert as one line for logs and the JSON API.
|
||||
func (a Alert) Message() string { return a.Subject + ": " + a.Detail }
|
||||
@@ -0,0 +1,174 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/ghtracker/internal/collector"
|
||||
"github.com/lukaszraczylo/ghtracker/internal/model"
|
||||
)
|
||||
|
||||
// apiState is the JSON contract with the UI. Lists of healthy items stay on GitHub;
|
||||
// the UI gets counts, release info and the alerts that explain every non-green state.
|
||||
type apiState struct {
|
||||
Now time.Time `json:"now"`
|
||||
LastRefresh time.Time `json:"lastRefresh"`
|
||||
Repos []apiRepo `json:"repos"`
|
||||
Alerts []apiAlert `json:"alerts"`
|
||||
Thresholds apiThresholds `json:"thresholds"`
|
||||
Counts apiCounts `json:"counts"`
|
||||
IntervalSeconds int64 `json:"intervalSeconds"`
|
||||
Loaded bool `json:"loaded"`
|
||||
Refreshing bool `json:"refreshing"`
|
||||
ManualRefresh bool `json:"manualRefresh"`
|
||||
}
|
||||
|
||||
type apiThresholds struct {
|
||||
PRWarnSeconds int64 `json:"prWarnSeconds"`
|
||||
PRCritSeconds int64 `json:"prCritSeconds"`
|
||||
IssueStaleSecs int64 `json:"issueStaleSeconds"`
|
||||
WorkflowStuck int64 `json:"workflowStuckSeconds"`
|
||||
WorkflowDormant int64 `json:"workflowDormantSeconds"`
|
||||
}
|
||||
|
||||
type apiCounts struct {
|
||||
Crit int `json:"crit"`
|
||||
Warn int `json:"warn"`
|
||||
}
|
||||
|
||||
type apiRepo struct {
|
||||
RefreshedAt time.Time `json:"refreshedAt"`
|
||||
Release *apiRelease `json:"release"`
|
||||
FullName string `json:"fullName"`
|
||||
URL string `json:"url"`
|
||||
ReleasesURL string `json:"releasesUrl"`
|
||||
Description string `json:"description"`
|
||||
Health string `json:"health"`
|
||||
Error string `json:"error"`
|
||||
Workflows apiWorkflows `json:"workflows"`
|
||||
PRs apiPRs `json:"prs"`
|
||||
Issues apiIssues `json:"issues"`
|
||||
Archived bool `json:"archived"`
|
||||
Up bool `json:"up"`
|
||||
}
|
||||
|
||||
type apiWorkflows struct {
|
||||
Total int `json:"total"`
|
||||
Failing int `json:"failing"`
|
||||
Running int `json:"running"`
|
||||
}
|
||||
|
||||
type apiPRs struct {
|
||||
OldestSeconds int64 `json:"oldestSeconds"`
|
||||
Open int `json:"open"`
|
||||
Waiting int `json:"waiting"`
|
||||
FailingChecks int `json:"failingChecks"`
|
||||
}
|
||||
|
||||
type apiIssues struct {
|
||||
Open int `json:"open"`
|
||||
Stale int `json:"stale"`
|
||||
}
|
||||
|
||||
type apiRelease struct {
|
||||
PublishedAt time.Time `json:"publishedAt"`
|
||||
Tag string `json:"tag"`
|
||||
URL string `json:"url"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
FromTag bool `json:"fromTag"`
|
||||
}
|
||||
|
||||
type apiAlert struct {
|
||||
Since time.Time `json:"since"`
|
||||
Repo string `json:"repo"`
|
||||
Kind string `json:"kind"`
|
||||
Severity string `json:"severity"`
|
||||
Subject string `json:"subject"`
|
||||
Detail string `json:"detail"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
func seconds(d time.Duration) int64 { return int64(d / time.Second) }
|
||||
|
||||
func buildState(v collector.View, manualRefresh bool) apiState {
|
||||
st := apiState{
|
||||
Now: v.Now,
|
||||
LastRefresh: v.LastRefresh,
|
||||
IntervalSeconds: seconds(v.Interval),
|
||||
Loaded: v.Loaded,
|
||||
Refreshing: v.Refreshing,
|
||||
ManualRefresh: manualRefresh,
|
||||
Counts: apiCounts{Crit: v.Crit, Warn: v.Warn},
|
||||
Thresholds: apiThresholds{
|
||||
PRWarnSeconds: seconds(v.Thresholds.PRWarnAfter),
|
||||
PRCritSeconds: seconds(v.Thresholds.PRCritAfter),
|
||||
IssueStaleSecs: seconds(v.Thresholds.IssueStaleAfter),
|
||||
WorkflowStuck: seconds(v.Thresholds.WorkflowStuck),
|
||||
WorkflowDormant: seconds(v.Thresholds.WorkflowDormant),
|
||||
},
|
||||
Repos: make([]apiRepo, 0, len(v.Repos)),
|
||||
Alerts: make([]apiAlert, 0, len(v.Alerts)),
|
||||
}
|
||||
for _, r := range v.Repos {
|
||||
ar := apiRepo{
|
||||
FullName: r.FullName, URL: r.URL, ReleasesURL: r.ReleasesURL, Description: r.Description,
|
||||
Health: r.Health.String(), Error: r.Err, Archived: r.Archived, Up: r.Up, RefreshedAt: r.RefreshedAt,
|
||||
Workflows: workflowStats(r, v), PRs: prStats(r, v), Issues: issueStats(r, v),
|
||||
}
|
||||
if rel := r.Release; rel != nil {
|
||||
ar.Release = &apiRelease{Tag: rel.Tag, URL: rel.URL, PublishedAt: rel.PublishedAt, Prerelease: rel.Prerelease, FromTag: rel.FromTag}
|
||||
}
|
||||
st.Repos = append(st.Repos, ar)
|
||||
}
|
||||
for _, a := range v.Alerts {
|
||||
st.Alerts = append(st.Alerts, apiAlert{
|
||||
Severity: a.Severity.String(), Repo: a.Repo, Kind: a.Kind, Subject: a.Subject,
|
||||
Detail: a.Detail, URL: a.URL, Since: a.Since,
|
||||
})
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func workflowStats(r model.Repo, v collector.View) apiWorkflows {
|
||||
out := apiWorkflows{Total: len(r.Workflows)}
|
||||
for _, wf := range r.Workflows {
|
||||
if wf.LastDone != nil && wf.LastDone.Failed() && !collector.Dormant(wf, v.Now, v.Thresholds) {
|
||||
out.Failing++
|
||||
}
|
||||
if wf.Latest.Running() {
|
||||
out.Running++
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// prStats counts non-draft pull requests; drafts never wait on anyone.
|
||||
func prStats(r model.Repo, v collector.View) apiPRs {
|
||||
out := apiPRs{Open: len(r.PRs)}
|
||||
for _, pr := range r.PRs {
|
||||
if pr.Draft {
|
||||
continue
|
||||
}
|
||||
age := v.Now.Sub(pr.CreatedAt)
|
||||
out.OldestSeconds = max(out.OldestSeconds, seconds(age))
|
||||
if v.Thresholds.PRWarnAfter > 0 && age >= v.Thresholds.PRWarnAfter {
|
||||
out.Waiting++
|
||||
}
|
||||
if pr.Checks.State == model.ChecksFail {
|
||||
out.FailingChecks++
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func issueStats(r model.Repo, v collector.View) apiIssues {
|
||||
out := apiIssues{Open: len(r.Issues)}
|
||||
if v.Thresholds.IssueStaleAfter <= 0 {
|
||||
return out
|
||||
}
|
||||
for _, is := range r.Issues {
|
||||
if v.Now.Sub(is.UpdatedAt) >= v.Thresholds.IssueStaleAfter {
|
||||
out.Stale++
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Package web serves the embedded UI, the JSON state, the health probe and metrics.
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/ghtracker/internal/collector"
|
||||
)
|
||||
|
||||
const (
|
||||
minManualRefreshGap = 5 * time.Minute
|
||||
indexFile = "index.html"
|
||||
assetsPrefix = "/assets/"
|
||||
immutableCache = "public, max-age=31536000, immutable"
|
||||
contentSecurity = "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; " +
|
||||
"connect-src 'self'; img-src 'self' data:; font-src 'self'; form-action 'self'; base-uri 'none'"
|
||||
)
|
||||
|
||||
// Source supplies dashboard state and accepts refresh requests.
|
||||
type Source interface {
|
||||
View() collector.View
|
||||
Stats() collector.Stats
|
||||
RequestRefresh() bool
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
lastManual time.Time
|
||||
now func() time.Time
|
||||
src Source
|
||||
ui fs.FS
|
||||
metrics http.Handler
|
||||
log *slog.Logger
|
||||
mu sync.Mutex
|
||||
manualRefresh bool
|
||||
}
|
||||
|
||||
// New builds the server; ui is the built frontend (index.html at its root).
|
||||
func New(src Source, metrics http.Handler, ui fs.FS, manualRefresh bool, log *slog.Logger) *Server {
|
||||
return &Server{src: src, ui: ui, metrics: metrics, manualRefresh: manualRefresh, log: log, now: time.Now}
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /{$}", s.index)
|
||||
mux.HandleFunc("GET /assets/", s.asset)
|
||||
mux.HandleFunc("GET /api/state", s.state)
|
||||
mux.HandleFunc("GET /healthz", s.health)
|
||||
mux.Handle("GET /metrics", s.metrics)
|
||||
if s.manualRefresh {
|
||||
mux.HandleFunc("POST /refresh", s.refresh)
|
||||
}
|
||||
return securityHeaders(mux)
|
||||
}
|
||||
|
||||
func securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("Content-Security-Policy", contentSecurity)
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("Referrer-Policy", "no-referrer")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) index(w http.ResponseWriter, _ *http.Request) {
|
||||
b, err := fs.ReadFile(s.ui, indexFile)
|
||||
if err != nil {
|
||||
http.Error(w, "UI is not built: run `pnpm build` before `go build`", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
_, _ = w.Write(b)
|
||||
}
|
||||
|
||||
// asset serves hashed build output under /assets/ from the embedded filesystem.
|
||||
func (s *Server) asset(w http.ResponseWriter, r *http.Request) {
|
||||
name := path.Clean(strings.TrimPrefix(r.URL.Path, "/"))
|
||||
if !strings.HasPrefix("/"+name, assetsPrefix) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", immutableCache)
|
||||
http.ServeFileFS(w, r, s.ui, name)
|
||||
}
|
||||
|
||||
func (s *Server) state(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if err := json.NewEncoder(w).Encode(buildState(s.src.View(), s.manualRefresh)); err != nil {
|
||||
s.log.Error("encode state", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// health reports 503 until the first refresh finishes, for probes.
|
||||
func (s *Server) health(w http.ResponseWriter, _ *http.Request) {
|
||||
if !s.src.Stats().Loaded {
|
||||
http.Error(w, "loading", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte("ok\n"))
|
||||
}
|
||||
|
||||
func (s *Server) refresh(w http.ResponseWriter, r *http.Request) {
|
||||
// Browsers label cross-site requests; refuse them so another page cannot spend the API budget.
|
||||
if site := r.Header.Get("Sec-Fetch-Site"); site != "" && site != "same-origin" && site != "none" {
|
||||
http.Error(w, "cross-site refresh refused", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
tooSoon := s.now().Sub(s.lastManual) < minManualRefreshGap
|
||||
if !tooSoon {
|
||||
s.lastManual = s.now()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if tooSoon {
|
||||
http.Error(w, "refresh allowed once per "+minManualRefreshGap.String(), http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
s.src.RequestRefresh()
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/ghtracker/internal/collector"
|
||||
"github.com/lukaszraczylo/ghtracker/internal/config"
|
||||
"github.com/lukaszraczylo/ghtracker/internal/model"
|
||||
)
|
||||
|
||||
var now = time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
type fakeSource struct {
|
||||
st collector.Stats
|
||||
v collector.View
|
||||
refreshed int
|
||||
}
|
||||
|
||||
func (f *fakeSource) View() collector.View { return f.v }
|
||||
func (f *fakeSource) Stats() collector.Stats { return f.st }
|
||||
func (f *fakeSource) RequestRefresh() bool { f.refreshed++; return true }
|
||||
|
||||
func testView() collector.View {
|
||||
repo := model.Repo{
|
||||
FullName: "o/r", URL: "https://github.com/o/r", ReleasesURL: "https://github.com/o/r/releases", Up: true,
|
||||
Health: model.Crit, RefreshedAt: now.Add(-time.Hour), Description: "desc",
|
||||
Release: &model.Release{Tag: "v1.2.3", URL: "https://github.com/o/r/releases/tag/v1.2.3", PublishedAt: now.Add(-48 * time.Hour)},
|
||||
PRs: []model.PullRequest{
|
||||
{Number: 5, CreatedAt: now.Add(-72 * time.Hour), Checks: model.CheckSummary{State: model.ChecksFail}},
|
||||
{Number: 6, Draft: true, CreatedAt: now.Add(-900 * time.Hour)},
|
||||
},
|
||||
Issues: []model.Issue{{Number: 9, UpdatedAt: now.Add(-40 * 24 * time.Hour)}, {Number: 10, UpdatedAt: now}},
|
||||
Workflows: []model.Workflow{
|
||||
{Name: "CI", Latest: model.Run{Status: "completed", Conclusion: "failure", CreatedAt: now.Add(-time.Hour)}, LastDone: &model.Run{Status: "completed", Conclusion: "failure", CreatedAt: now.Add(-time.Hour)}},
|
||||
{Name: "Nightly", Latest: model.Run{Status: "in_progress", CreatedAt: now}},
|
||||
},
|
||||
}
|
||||
bare := model.Repo{FullName: "o/bare", URL: "https://github.com/o/bare", ReleasesURL: "https://github.com/o/bare/releases", Err: "boom"}
|
||||
return collector.View{
|
||||
Now: now, LastRefresh: now.Add(-time.Hour), Interval: 6 * time.Hour, Loaded: true, Crit: 1, Warn: 1,
|
||||
Thresholds: config.Thresholds{PRWarnAfter: 48 * time.Hour, PRCritAfter: 168 * time.Hour, IssueStaleAfter: 720 * time.Hour},
|
||||
Repos: []model.Repo{repo, bare},
|
||||
Alerts: []model.Alert{
|
||||
{Severity: model.Crit, Repo: "o/r", Kind: "workflow_failed", Subject: "CI", Detail: "failure on main (push)", URL: "https://github.com/o/r/actions/runs/1", Since: now.Add(-2 * time.Hour)},
|
||||
{Severity: model.Warn, Repo: "o/r", Kind: "pr_waiting", Subject: "#5 fix", Detail: "open 3d", URL: "https://github.com/o/r/pull/5", Since: now.Add(-72 * time.Hour)},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func testUI() fstest.MapFS {
|
||||
return fstest.MapFS{
|
||||
"index.html": {Data: []byte("<!doctype html><title>ui</title>")},
|
||||
"assets/app.js": {Data: []byte("console.log(1)")},
|
||||
"secret.txt": {Data: []byte("nope")},
|
||||
}
|
||||
}
|
||||
|
||||
func newServer(src *fakeSource, ui fstest.MapFS, manual bool) *Server {
|
||||
return New(src, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("metrics")) }),
|
||||
ui, manual, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
}
|
||||
|
||||
func do(h http.Handler, method, path string) *httptest.ResponseRecorder {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(method, path, nil))
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestIndexServesBuiltUIWithSecurityHeaders(t *testing.T) {
|
||||
rec := do(newServer(&fakeSource{}, testUI(), false).Handler(), "GET", "/")
|
||||
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "<title>ui</title>") {
|
||||
t.Fatalf("%d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec.Header().Get("Cache-Control") != "no-cache" || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/html") {
|
||||
t.Fatalf("headers: %v", rec.Header())
|
||||
}
|
||||
csp := rec.Header().Get("Content-Security-Policy")
|
||||
for _, want := range []string{"default-src 'none'", "script-src 'self'", "connect-src 'self'"} {
|
||||
if !strings.Contains(csp, want) {
|
||||
t.Errorf("CSP %q missing %q", csp, want)
|
||||
}
|
||||
}
|
||||
if rec.Header().Get("X-Content-Type-Options") != "nosniff" {
|
||||
t.Fatal("nosniff missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexWithoutBuiltUIIs503(t *testing.T) {
|
||||
rec := do(newServer(&fakeSource{}, fstest.MapFS{}, false).Handler(), "GET", "/")
|
||||
if rec.Code != http.StatusServiceUnavailable || !strings.Contains(rec.Body.String(), "pnpm build") {
|
||||
t.Fatalf("%d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetsCachedAndScopedToAssetsDir(t *testing.T) {
|
||||
h := newServer(&fakeSource{}, testUI(), false).Handler()
|
||||
rec := do(h, "GET", "/assets/app.js")
|
||||
if rec.Code != 200 || rec.Header().Get("Cache-Control") != immutableCache || rec.Body.String() != "console.log(1)" {
|
||||
t.Fatalf("%d %v", rec.Code, rec.Header())
|
||||
}
|
||||
for _, p := range []string{"/assets/missing.js", "/secret.txt", "/assets/../secret.txt", "/nope"} {
|
||||
if rec := do(h, "GET", p); rec.Code != http.StatusNotFound && rec.Code != http.StatusMovedPermanently && rec.Code != http.StatusTemporaryRedirect {
|
||||
t.Errorf("%s -> %d, want 404", p, rec.Code)
|
||||
} else if rec.Body.Len() > 0 && strings.Contains(rec.Body.String(), "nope") {
|
||||
t.Errorf("%s leaked a file outside assets", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateJSONContract(t *testing.T) {
|
||||
src := &fakeSource{v: testView()}
|
||||
rec := do(newServer(src, testUI(), true).Handler(), "GET", "/api/state")
|
||||
if rec.Code != 200 || rec.Header().Get("Content-Type") != "application/json" || rec.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatalf("%d %v", rec.Code, rec.Header())
|
||||
}
|
||||
var got struct {
|
||||
Repos []map[string]any
|
||||
Alerts []map[string]any
|
||||
Counts struct{ Crit, Warn int }
|
||||
Thresholds struct{ PRWarnSeconds, PRCritSeconds int64 }
|
||||
IntervalSeconds int64
|
||||
Loaded bool
|
||||
ManualRefresh bool
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.IntervalSeconds != 21600 || !got.Loaded || !got.ManualRefresh || got.Counts.Crit != 1 || got.Counts.Warn != 1 {
|
||||
t.Fatalf("top level: %+v", got)
|
||||
}
|
||||
if got.Thresholds.PRWarnSeconds != 172800 || got.Thresholds.PRCritSeconds != 604800 {
|
||||
t.Fatalf("thresholds: %+v", got.Thresholds)
|
||||
}
|
||||
r := got.Repos[0]
|
||||
if r["fullName"] != "o/r" || r["health"] != "crit" {
|
||||
t.Fatalf("repo: %v", r)
|
||||
}
|
||||
prs, issues, wf := r["prs"].(map[string]any), r["issues"].(map[string]any), r["workflows"].(map[string]any)
|
||||
if prs["open"] != float64(2) || prs["waiting"] != float64(1) || prs["failingChecks"] != float64(1) || prs["oldestSeconds"] != float64(72*3600) {
|
||||
t.Fatalf("prs: %v", prs)
|
||||
}
|
||||
if issues["open"] != float64(2) || issues["stale"] != float64(1) {
|
||||
t.Fatalf("issues: %v", issues)
|
||||
}
|
||||
if wf["total"] != float64(2) || wf["failing"] != float64(1) || wf["running"] != float64(1) {
|
||||
t.Fatalf("workflows: %v", wf)
|
||||
}
|
||||
if rel := r["release"].(map[string]any); rel["tag"] != "v1.2.3" || rel["url"] == "" {
|
||||
t.Fatalf("release: %v", rel)
|
||||
}
|
||||
if got.Repos[1]["release"] != nil || got.Repos[1]["error"] != "boom" || got.Repos[1]["health"] != "ok" {
|
||||
t.Fatalf("bare repo: %v", got.Repos[1])
|
||||
}
|
||||
if len(got.Alerts) != 2 || got.Alerts[0]["severity"] != "crit" || got.Alerts[0]["subject"] != "CI" {
|
||||
t.Fatalf("alerts: %v", got.Alerts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateJSONEmptyListsAreArrays(t *testing.T) {
|
||||
rec := do(newServer(&fakeSource{}, testUI(), false).Handler(), "GET", "/api/state")
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, `"repos":[]`) || !strings.Contains(body, `"alerts":[]`) {
|
||||
t.Fatalf("lists must encode as [], got %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthzAndMetrics(t *testing.T) {
|
||||
src := &fakeSource{}
|
||||
h := newServer(src, testUI(), false).Handler()
|
||||
if rec := do(h, "GET", "/healthz"); rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("before first refresh: %d", rec.Code)
|
||||
}
|
||||
src.st.Loaded = true
|
||||
if rec := do(h, "GET", "/healthz"); rec.Code != 200 {
|
||||
t.Fatalf("after refresh: %d", rec.Code)
|
||||
}
|
||||
if rec := do(h, "GET", "/metrics"); rec.Body.String() != "metrics" {
|
||||
t.Fatal("metrics handler not mounted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualRefreshDisabledByDefault(t *testing.T) {
|
||||
src := &fakeSource{}
|
||||
rec := do(newServer(src, testUI(), false).Handler(), "POST", "/refresh")
|
||||
if rec.Code == http.StatusAccepted || src.refreshed != 0 {
|
||||
t.Fatalf("refresh must not be routed when disabled: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualRefreshRateLimited(t *testing.T) {
|
||||
src := &fakeSource{}
|
||||
s := newServer(src, testUI(), true)
|
||||
clock := now
|
||||
s.now = func() time.Time { return clock }
|
||||
h := s.Handler()
|
||||
if rec := do(h, "POST", "/refresh"); rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("first: %d", rec.Code)
|
||||
}
|
||||
clock = clock.Add(time.Minute)
|
||||
if rec := do(h, "POST", "/refresh"); rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("second within gap: %d", rec.Code)
|
||||
}
|
||||
clock = clock.Add(5 * time.Minute)
|
||||
if rec := do(h, "POST", "/refresh"); rec.Code != http.StatusAccepted || src.refreshed != 2 {
|
||||
t.Fatalf("after gap: %d refreshed=%d", rec.Code, src.refreshed)
|
||||
}
|
||||
if rec := do(h, "GET", "/refresh"); rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("GET /refresh: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualRefreshRefusesCrossSiteRequests(t *testing.T) {
|
||||
src := &fakeSource{}
|
||||
h := newServer(src, testUI(), true).Handler()
|
||||
for _, site := range []string{"cross-site", "same-site"} {
|
||||
req := httptest.NewRequest("POST", "/refresh", nil)
|
||||
req.Header.Set("Sec-Fetch-Site", site)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden || src.refreshed != 0 {
|
||||
t.Fatalf("%s: %d refreshed=%d", site, rec.Code, src.refreshed)
|
||||
}
|
||||
}
|
||||
req := httptest.NewRequest("POST", "/refresh", nil)
|
||||
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("same-origin: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user