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
+101
View File
@@ -0,0 +1,101 @@
// Command ghtracker serves a dashboard and Prometheus metrics for a list of GitHub repositories.
package main
import (
"context"
"embed"
"errors"
"flag"
"fmt"
"io/fs"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/lukaszraczylo/ghtracker/internal/collector"
"github.com/lukaszraczylo/ghtracker/internal/config"
"github.com/lukaszraczylo/ghtracker/internal/gh"
"github.com/lukaszraczylo/ghtracker/internal/metrics"
"github.com/lukaszraczylo/ghtracker/internal/web"
)
// distFS holds the built UI; dist/.gitkeep keeps the pattern valid before the first UI build.
//
//go:embed all:dist
var distFS embed.FS
// version is set at release time with -ldflags "-X main.version=...".
var version = "dev"
const (
httpTimeout = 30 * time.Second
shutdownTimeout = 10 * time.Second
headerTimeout = 10 * time.Second
)
func main() {
cfgPath := flag.String("config", "config.yaml", "path to the YAML config file")
showVersion := flag.Bool("version", false, "print the version and exit")
flag.Parse()
if *showVersion {
fmt.Println(version)
return
}
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := run(ctx, *cfgPath, log); err != nil {
stop()
log.Error("fatal", "err", err)
os.Exit(1)
}
}
func run(ctx context.Context, cfgPath string, log *slog.Logger) error {
cfg, err := config.Load(cfgPath)
if err != nil {
return err
}
hc := &http.Client{Timeout: httpTimeout}
auth, err := gh.NewAppAuth(cfg.GitHub.AppID, cfg.GitHub.PrivateKeyPEM, cfg.GitHub.APIURL, cfg.GitHub.InstallationID, hc)
if err != nil {
return err
}
client := gh.NewClient(cfg.GitHub.APIURL, auth, hc, cfg.MaxItems)
coll := collector.New(client, cfg, log)
reg := prometheus.NewRegistry()
reg.MustRegister(collectors.NewGoCollector(), collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
metrics.New(coll, client.RateLimitRemaining))
ui, err := fs.Sub(distFS, "dist")
if err != nil {
return err
}
srv := web.New(coll, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}), ui, *cfg.ManualRefresh, log)
go coll.Run(ctx)
hs := &http.Server{Addr: cfg.Listen, Handler: srv.Handler(), ReadHeaderTimeout: headerTimeout}
errc := make(chan error, 1)
go func() { errc <- hs.ListenAndServe() }()
log.Info("listening", "version", version, "addr", cfg.Listen, "repos", len(cfg.Repos), "refresh", cfg.RefreshInterval)
select {
case err := <-errc:
return err
case <-ctx.Done():
}
sctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
if err := hs.Shutdown(sctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}