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:
2026-10-02 04:28:52 +01:00
commit 5313b56774
110 changed files with 10967 additions and 0 deletions
+220
View File
@@ -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
}
+314
View File
@@ -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)
}
}
+193
View File
@@ -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
}
+163
View File
@@ -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
}
+128
View File
@@ -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")
}
}
+181
View File
@@ -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)
}
+291
View File
@@ -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 ""
}
+345
View File
@@ -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
}
+461
View File
@@ -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))
}
}
+178
View File
@@ -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())
}
+133
View File
@@ -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)
}
}
+146
View File
@@ -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 }
+174
View File
@@ -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
}
+129
View File
@@ -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)
}
+239
View File
@@ -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)
}
}