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
+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)
}
}