main stats.go
Eric Bower  ·  2026-08-16
  1package main
  2
  3import (
  4	"encoding/json"
  5	"math"
  6	"os"
  7	"path/filepath"
  8	"time"
  9)
 10
 11// RepoStats tracks historical run durations and outcomes across runs.
 12type RepoStats struct {
 13	Repo              string                `json:"repo"`
 14	LastRunAt         string                `json:"last_run_at"`
 15	LastOverallStatus string                `json:"last_overall_status"`
 16	RunsCount         int                   `json:"runs_count"`
 17	AvgWallDurationMs int64                 `json:"avg_wall_duration_ms"`
 18	Tasks             map[string]*TaskStats `json:"tasks"`
 19}
 20
 21// TaskStats tracks duration averages, exit codes, and failure streaks per task.
 22type TaskStats struct {
 23	AvgDurationMs  int64  `json:"avg_duration_ms"`
 24	LastDurationMs int64  `json:"last_duration_ms"`
 25	LastStatus     string `json:"last_status"`
 26	LastExitCode   int    `json:"last_exit_code"`
 27	FailureStreak  int    `json:"failure_streak,omitempty"`
 28	SeenCount      int    `json:"seen_count"`
 29}
 30
 31// getStatsPath returns the path to stats.json for the specified repository.
 32func getStatsPath(repo string) string {
 33	cacheDir := os.Getenv("XDG_CACHE_HOME")
 34	if cacheDir == "" {
 35		home, err := os.UserHomeDir()
 36		if err != nil {
 37			cacheDir = "/tmp"
 38		} else {
 39			cacheDir = filepath.Join(home, ".cache")
 40		}
 41	}
 42	return filepath.Join(cacheDir, "pici", repo, "stats.json")
 43}
 44
 45// loadRepoStats loads historical stats for a repo, or returns an initialized empty struct.
 46func loadRepoStats(repo string) (*RepoStats, error) {
 47	path := getStatsPath(repo)
 48	data, err := os.ReadFile(path)
 49	if err != nil {
 50		if os.IsNotExist(err) {
 51			return &RepoStats{
 52				Repo:  repo,
 53				Tasks: make(map[string]*TaskStats),
 54			}, nil
 55		}
 56		return nil, err
 57	}
 58
 59	var stats RepoStats
 60	if err := json.Unmarshal(data, &stats); err != nil {
 61		return &RepoStats{
 62			Repo:  repo,
 63			Tasks: make(map[string]*TaskStats),
 64		}, nil
 65	}
 66	if stats.Tasks == nil {
 67		stats.Tasks = make(map[string]*TaskStats)
 68	}
 69	return &stats, nil
 70}
 71
 72// saveRepoStats writes repo stats to stats.json.
 73func saveRepoStats(stats *RepoStats) error {
 74	if stats == nil || stats.Repo == "" {
 75		return nil
 76	}
 77	path := getStatsPath(stats.Repo)
 78	if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
 79		return err
 80	}
 81	data, err := json.MarshalIndent(stats, "", "  ")
 82	if err != nil {
 83		return err
 84	}
 85	return os.WriteFile(path, data, 0644)
 86}
 87
 88// updateTaskStats updates single task statistics using EMA duration (alpha = 0.3).
 89func updateTaskStats(stats *RepoStats, name string, actualDur time.Duration, exitCode int) {
 90	if stats.Tasks == nil {
 91		stats.Tasks = make(map[string]*TaskStats)
 92	}
 93	task, ok := stats.Tasks[name]
 94	if !ok {
 95		task = &TaskStats{}
 96		stats.Tasks[name] = task
 97	}
 98
 99	actualMs := actualDur.Milliseconds()
100	if task.SeenCount == 0 || task.AvgDurationMs == 0 {
101		task.AvgDurationMs = actualMs
102	} else {
103		// EMA: alpha = 0.3
104		task.AvgDurationMs = int64(math.Round(0.3*float64(actualMs) + 0.7*float64(task.AvgDurationMs)))
105	}
106
107	task.LastDurationMs = actualMs
108	task.LastExitCode = exitCode
109	task.SeenCount++
110
111	if exitCode == 0 {
112		task.LastStatus = "success"
113		task.FailureStreak = 0
114	} else {
115		task.LastStatus = "failed"
116		task.FailureStreak++
117	}
118}
119
120// updateRepoStats updates overall repository stats after a run.
121func updateRepoStats(stats *RepoStats, wallDur time.Duration, overallStatus string) {
122	stats.RunsCount++
123	stats.LastRunAt = time.Now().UTC().Format(time.RFC3339)
124	stats.LastOverallStatus = overallStatus
125
126	wallMs := wallDur.Milliseconds()
127	if stats.RunsCount == 1 || stats.AvgWallDurationMs == 0 {
128		stats.AvgWallDurationMs = wallMs
129	} else {
130		stats.AvgWallDurationMs = int64(math.Round(0.3*float64(wallMs) + 0.7*float64(stats.AvgWallDurationMs)))
131	}
132}
133
134// computeOutcomeTransition returns "fixed", "still_failing", "new_failure", or "".
135func computeOutcomeTransition(prev *TaskStats, currentExitCode int) string {
136	if prev != nil && (prev.LastStatus == "failed" || prev.LastExitCode != 0) {
137		if currentExitCode == 0 {
138			return "fixed"
139		}
140		return "still_failing"
141	}
142	if currentExitCode != 0 {
143		return "new_failure"
144	}
145	return ""
146}