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}