mirror of
https://github.com/lukaszraczylo/ghtracker.git
synced 2026-10-15 01:07:40 +00:00
feat: add GitHub repository dashboard with Prometheus metrics
ghtracker authenticates as a GitHub App and scans a configured list of repositories on a schedule. It serves a dashboard, a JSON state endpoint, a health probe and Prometheus metrics. The dashboard lists only items that need attention: failing workflows, pull requests that wait too long or fail their checks, stale issues and projects that failed to scan. It has search, dynamic filters and a button that rescans on demand. The Vue app is embedded in the Go binary. The client protects the GitHub rate limit with conditional requests, a request reserve and Retry-After handling.
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
// Package config loads and validates the YAML configuration.
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
EnvPrivateKey = "GHTRACKER_PRIVATE_KEY"
|
||||
|
||||
DefaultListen = ":8080"
|
||||
DefaultRefresh = 6 * time.Hour
|
||||
DefaultAPIURL = "https://api.github.com"
|
||||
DefaultPRWarnAfter = 48 * time.Hour
|
||||
DefaultPRCritAfter = 7 * 24 * time.Hour
|
||||
DefaultIssueStale = 30 * 24 * time.Hour
|
||||
DefaultWorkflowStuck = 6 * time.Hour
|
||||
DefaultWorkflowDormant = 30 * 24 * time.Hour
|
||||
DefaultMaxItems = 100
|
||||
DefaultConcurrency = 2
|
||||
MinRefresh = time.Minute
|
||||
dataStaleGrace = 30 * time.Minute
|
||||
defaultDataStaleFactor = 2
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
// ManualRefresh enables POST /refresh and the rescan button; it defaults to on.
|
||||
ManualRefresh *bool `yaml:"manual_refresh"`
|
||||
Listen string `yaml:"listen"`
|
||||
Repos []string `yaml:"repos"`
|
||||
GitHub GitHub `yaml:"github"`
|
||||
Thresholds Thresholds `yaml:"thresholds"`
|
||||
RefreshInterval time.Duration `yaml:"refresh_interval"`
|
||||
MaxItems int `yaml:"max_items"`
|
||||
Concurrency int `yaml:"concurrency"`
|
||||
}
|
||||
|
||||
type GitHub struct {
|
||||
PrivateKeyFile string `yaml:"private_key_file"`
|
||||
APIURL string `yaml:"api_url"`
|
||||
// PrivateKeyPEM comes from the environment, never from the file.
|
||||
PrivateKeyPEM string `yaml:"-"`
|
||||
AppID int64 `yaml:"app_id"`
|
||||
InstallationID int64 `yaml:"installation_id"`
|
||||
}
|
||||
|
||||
// Thresholds are durations; zero selects the default and a negative value disables the check.
|
||||
type Thresholds struct {
|
||||
PRWarnAfter time.Duration `yaml:"pr_warn_after"`
|
||||
PRCritAfter time.Duration `yaml:"pr_crit_after"`
|
||||
IssueStaleAfter time.Duration `yaml:"issue_stale_after"`
|
||||
WorkflowStuck time.Duration `yaml:"workflow_stuck_after"`
|
||||
// WorkflowDormant mutes failures of workflows that have not run for this long.
|
||||
WorkflowDormant time.Duration `yaml:"workflow_dormant_after"`
|
||||
DataStaleAfter time.Duration `yaml:"data_stale_after"`
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
raw, err := os.ReadFile(path) // #nosec G304 -- operator-supplied -config flag
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
var c Config
|
||||
dec := yaml.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.KnownFields(true)
|
||||
if err := dec.Decode(&c); err != nil {
|
||||
return nil, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
return &c, c.finalize(os.Getenv, os.ReadFile)
|
||||
}
|
||||
|
||||
func (c *Config) finalize(getenv func(string) string, readFile func(string) ([]byte, error)) error {
|
||||
if c.ManualRefresh == nil {
|
||||
on := true
|
||||
c.ManualRefresh = &on
|
||||
}
|
||||
if c.Listen == "" {
|
||||
c.Listen = DefaultListen
|
||||
}
|
||||
if c.RefreshInterval == 0 {
|
||||
c.RefreshInterval = DefaultRefresh
|
||||
}
|
||||
if c.RefreshInterval < MinRefresh {
|
||||
return fmt.Errorf("refresh_interval %s is below the %s minimum", c.RefreshInterval, MinRefresh)
|
||||
}
|
||||
if c.MaxItems <= 0 {
|
||||
c.MaxItems = DefaultMaxItems
|
||||
}
|
||||
if c.Concurrency <= 0 {
|
||||
c.Concurrency = DefaultConcurrency
|
||||
}
|
||||
if c.GitHub.APIURL == "" {
|
||||
c.GitHub.APIURL = DefaultAPIURL
|
||||
}
|
||||
c.GitHub.APIURL = strings.TrimRight(c.GitHub.APIURL, "/")
|
||||
|
||||
t := &c.Thresholds
|
||||
setDefault(&t.PRWarnAfter, DefaultPRWarnAfter)
|
||||
setDefault(&t.PRCritAfter, DefaultPRCritAfter)
|
||||
setDefault(&t.IssueStaleAfter, DefaultIssueStale)
|
||||
setDefault(&t.WorkflowStuck, DefaultWorkflowStuck)
|
||||
setDefault(&t.WorkflowDormant, DefaultWorkflowDormant)
|
||||
setDefault(&t.DataStaleAfter, defaultDataStaleFactor*c.RefreshInterval+dataStaleGrace)
|
||||
if t.PRWarnAfter > 0 && t.PRCritAfter > 0 && t.PRCritAfter < t.PRWarnAfter {
|
||||
return errors.New("thresholds.pr_crit_after must not be below pr_warn_after")
|
||||
}
|
||||
|
||||
if c.GitHub.AppID <= 0 {
|
||||
return errors.New("github.app_id is required")
|
||||
}
|
||||
c.GitHub.PrivateKeyPEM = getenv(EnvPrivateKey)
|
||||
if c.GitHub.PrivateKeyPEM == "" {
|
||||
if c.GitHub.PrivateKeyFile == "" {
|
||||
return fmt.Errorf("github.private_key_file or $%s is required", EnvPrivateKey)
|
||||
}
|
||||
pem, err := readFile(c.GitHub.PrivateKeyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read private key: %w", err)
|
||||
}
|
||||
c.GitHub.PrivateKeyPEM = string(pem)
|
||||
}
|
||||
|
||||
repos, err := normalizeRepos(c.Repos)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.Repos = repos
|
||||
return nil
|
||||
}
|
||||
|
||||
func setDefault(d *time.Duration, def time.Duration) {
|
||||
if *d == 0 {
|
||||
*d = def
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeRepos(in []string) ([]string, error) {
|
||||
if len(in) == 0 {
|
||||
return nil, errors.New("repos must list at least one owner/name")
|
||||
}
|
||||
seen := make(map[string]bool, len(in))
|
||||
out := make([]string, 0, len(in))
|
||||
for _, r := range in {
|
||||
r = strings.TrimSpace(r)
|
||||
owner, name, ok := strings.Cut(r, "/")
|
||||
if !ok || owner == "" || name == "" || strings.Contains(name, "/") {
|
||||
return nil, fmt.Errorf("invalid repo %q: want owner/name", r)
|
||||
}
|
||||
key := strings.ToLower(r)
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func noFile(string) ([]byte, error) { return nil, errors.New("no file") }
|
||||
func keyFile(string) ([]byte, error) { return []byte("PEM"), nil }
|
||||
func env(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
|
||||
|
||||
func valid() *Config {
|
||||
return &Config{GitHub: GitHub{AppID: 1, PrivateKeyFile: "k.pem"}, Repos: []string{"a/b"}}
|
||||
}
|
||||
|
||||
func TestFinalizeDefaults(t *testing.T) {
|
||||
c := valid()
|
||||
if err := c.finalize(env(nil), keyFile); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.RefreshInterval != 6*time.Hour || c.Listen != ":8080" || c.Concurrency != 2 || c.MaxItems != 100 {
|
||||
t.Fatalf("defaults wrong: %+v", c)
|
||||
}
|
||||
if c.ManualRefresh == nil || !*c.ManualRefresh {
|
||||
t.Fatal("manual refresh must default to on")
|
||||
}
|
||||
if c.GitHub.APIURL != DefaultAPIURL || c.GitHub.PrivateKeyPEM != "PEM" {
|
||||
t.Fatalf("github defaults wrong: %+v", c.GitHub)
|
||||
}
|
||||
if want := 12*time.Hour + 30*time.Minute; c.Thresholds.DataStaleAfter != want {
|
||||
t.Fatalf("data stale = %s, want %s", c.Thresholds.DataStaleAfter, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalizeEnvKeyWins(t *testing.T) {
|
||||
c := valid()
|
||||
c.GitHub.PrivateKeyFile = ""
|
||||
if err := c.finalize(env(map[string]string{EnvPrivateKey: "ENVPEM"}), noFile); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.GitHub.PrivateKeyPEM != "ENVPEM" {
|
||||
t.Fatalf("got %q", c.GitHub.PrivateKeyPEM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalizeErrors(t *testing.T) {
|
||||
tests := map[string]func(*Config){
|
||||
"no app id": func(c *Config) { c.GitHub.AppID = 0 },
|
||||
"no key": func(c *Config) { c.GitHub.PrivateKeyFile = "" },
|
||||
"unreadable key": func(c *Config) { c.GitHub.PrivateKeyFile = "x" },
|
||||
"no repos": func(c *Config) { c.Repos = nil },
|
||||
"bad repo": func(c *Config) { c.Repos = []string{"justname"} },
|
||||
"bad repo slashes": func(c *Config) { c.Repos = []string{"a/b/c"} },
|
||||
"refresh too small": func(c *Config) { c.RefreshInterval = time.Second },
|
||||
"crit below warn": func(c *Config) {
|
||||
c.Thresholds.PRWarnAfter, c.Thresholds.PRCritAfter = 10*time.Hour, time.Hour
|
||||
},
|
||||
}
|
||||
for name, mutate := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
c := valid()
|
||||
mutate(c)
|
||||
if err := c.finalize(env(nil), noFile); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeReposDedupes(t *testing.T) {
|
||||
got, err := normalizeRepos([]string{"A/b", " a/B ", "c/d"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 || got[0] != "A/b" || got[1] != "c/d" {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNegativeThresholdDisables(t *testing.T) {
|
||||
c := valid()
|
||||
c.Thresholds.PRCritAfter = -1
|
||||
if err := c.finalize(env(nil), keyFile); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.Thresholds.PRCritAfter != -1 {
|
||||
t.Fatalf("negative must be kept, got %s", c.Thresholds.PRCritAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsUnknownKeysAndReadsFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write := func(name, body string) string {
|
||||
p := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
key := write("k.pem", "PEM")
|
||||
good := write("ok.yaml", "refresh_interval: 2h\ngithub:\n app_id: 5\n private_key_file: "+key+"\nrepos: [a/b]\n")
|
||||
c, err := Load(good)
|
||||
if err != nil || c.RefreshInterval != 2*time.Hour || c.GitHub.PrivateKeyPEM != "PEM" {
|
||||
t.Fatalf("c=%+v err=%v", c, err)
|
||||
}
|
||||
typo := write("typo.yaml", "refresh_intervall: 2h\ngithub:\n app_id: 5\n private_key_file: "+key+"\nrepos: [a/b]\n")
|
||||
if _, err := Load(typo); err == nil {
|
||||
t.Fatal("unknown key must be rejected")
|
||||
}
|
||||
if _, err := Load(filepath.Join(dir, "missing.yaml")); err == nil {
|
||||
t.Fatal("missing file must error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualRefreshCanBeDisabled(t *testing.T) {
|
||||
off := false
|
||||
c := valid()
|
||||
c.ManualRefresh = &off
|
||||
if err := c.finalize(env(nil), keyFile); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if *c.ManualRefresh {
|
||||
t.Fatal("explicit false must stay off")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user