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}