main stats.go
Eric Bower  ·  2026-09-15
  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	Event             string                `json:"event,omitempty"`
 15	LastRunAt         string                `json:"last_run_at"`
 16	LastOverallStatus string                `json:"last_overall_status"`
 17	RunsCount         int                   `json:"runs_count"`
 18	AvgWallDurationMs int64                 `json:"avg_wall_duration_ms"`
 19	Tasks             map[string]*TaskStats `json:"tasks"`
 20}
 21
 22// TaskStats tracks duration averages, exit codes, and failure streaks per task.
 23type TaskStats struct {
 24	AvgDurationMs  int64  `json:"avg_duration_ms"`
 25	LastDurationMs int64  `json:"last_duration_ms"`
 26	LastStatus     string `json:"last_status"`
 27	LastExitCode   int    `json:"last_exit_code"`
 28	FailureStreak  int    `json:"failure_streak,omitempty"`
 29	SeenCount      int    `json:"seen_count"`
 30	LastStartedAt  int64  `json:"last_started_at,omitempty"`
 31}
 32
 33// getStatsPath returns the path to stats.json for the specified repository and event.
 34func getStatsPath(repo, event string) string {
 35	if event == "" {
 36		event = "local"
 37	}
 38	cacheDir := os.Getenv("XDG_CACHE_HOME")
 39	if cacheDir == "" {
 40		home, err := os.UserHomeDir()
 41		if err != nil {
 42			cacheDir = "/tmp"
 43		} else {
 44			cacheDir = filepath.Join(home, ".cache")
 45		}
 46	}
 47	return filepath.Join(cacheDir, "pici", repo, event, "stats.json")
 48}
 49
 50// loadRepoStats loads historical stats for a repo and event, or returns an initialized empty struct.
 51func loadRepoStats(repo, event string) (*RepoStats, error) {
 52	if event == "" {
 53		event = "local"
 54	}
 55	path := getStatsPath(repo, event)
 56	data, err := os.ReadFile(path)
 57	if err != nil {
 58		if os.IsNotExist(err) {
 59			return &RepoStats{
 60				Repo:  repo,
 61				Event: event,
 62				Tasks: make(map[string]*TaskStats),
 63			}, nil
 64		}
 65		return nil, err
 66	}
 67
 68	var stats RepoStats
 69	if err := json.Unmarshal(data, &stats); err != nil {
 70		return &RepoStats{
 71			Repo:  repo,
 72			Event: event,
 73			Tasks: make(map[string]*TaskStats),
 74		}, nil
 75	}
 76	if stats.Event == "" {
 77		stats.Event = event
 78	}
 79	if stats.Tasks == nil {
 80		stats.Tasks = make(map[string]*TaskStats)
 81	}
 82	return &stats, nil
 83}
 84
 85// saveRepoStats writes repo stats to stats.json.
 86func saveRepoStats(stats *RepoStats) error {
 87	if stats == nil || stats.Repo == "" {
 88		return nil
 89	}
 90	if stats.Event == "" {
 91		stats.Event = "local"
 92	}
 93	path := getStatsPath(stats.Repo, stats.Event)
 94	if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
 95		return err
 96	}
 97	data, err := json.MarshalIndent(stats, "", "  ")
 98	if err != nil {
 99		return err
100	}
101	return os.WriteFile(path, data, 0644)
102}
103
104// updateTaskStats updates single task statistics using EMA duration (alpha = 0.3).
105func updateTaskStats(stats *RepoStats, name string, actualDur time.Duration, exitCode int, startedAt ...int64) {
106	if stats.Tasks == nil {
107		stats.Tasks = make(map[string]*TaskStats)
108	}
109	task, ok := stats.Tasks[name]
110	if !ok {
111		task = &TaskStats{}
112		stats.Tasks[name] = task
113	}
114
115	actualMs := actualDur.Milliseconds()
116	if task.SeenCount == 0 || task.AvgDurationMs == 0 {
117		task.AvgDurationMs = actualMs
118	} else {
119		// EMA: alpha = 0.3
120		task.AvgDurationMs = int64(math.Round(0.3*float64(actualMs) + 0.7*float64(task.AvgDurationMs)))
121	}
122
123	task.LastDurationMs = actualMs
124	task.LastExitCode = exitCode
125	task.SeenCount++
126	if len(startedAt) > 0 && startedAt[0] > 0 {
127		task.LastStartedAt = startedAt[0]
128	}
129
130	if exitCode == 0 {
131		task.LastStatus = "success"
132		task.FailureStreak = 0
133	} else {
134		task.LastStatus = "failed"
135		task.FailureStreak++
136	}
137}
138
139// updateRepoStats updates overall repository stats after a run.
140func updateRepoStats(stats *RepoStats, wallDur time.Duration, overallStatus string) {
141	stats.RunsCount++
142	stats.LastRunAt = time.Now().UTC().Format(time.RFC3339)
143	stats.LastOverallStatus = overallStatus
144
145	wallMs := wallDur.Milliseconds()
146	if stats.RunsCount == 1 || stats.AvgWallDurationMs == 0 {
147		stats.AvgWallDurationMs = wallMs
148	} else {
149		stats.AvgWallDurationMs = int64(math.Round(0.3*float64(wallMs) + 0.7*float64(stats.AvgWallDurationMs)))
150	}
151}
152
153// computeOutcomeTransition returns "fixed", "still_failing", "new_failure", or "".
154func computeOutcomeTransition(prev *TaskStats, currentExitCode int) string {
155	if prev != nil && (prev.LastStatus == "failed" || prev.LastExitCode != 0) {
156		if currentExitCode == 0 {
157			return "fixed"
158		}
159		return "still_failing"
160	}
161	if currentExitCode != 0 {
162		return "new_failure"
163	}
164	return ""
165}
166
167// pruneStaleTasks removes tasks from stats that were not executed in the current run.
168func pruneStaleTasks(stats *RepoStats, executedTasks map[string]bool) {
169	if stats == nil || stats.Tasks == nil {
170		return
171	}
172	for name := range stats.Tasks {
173		if !executedTasks[name] {
174			delete(stats.Tasks, name)
175		}
176	}
177}