Commit 25ab3ff
Eric Bower
·
2026-08-30 09:45:36 -0400 EDT
parent e54894f
feat: cache prev run based on PICI_EVENT
5 files changed,
+108,
-36
M
main.go
+6,
-7
| ... | ... | @@ -1041,11 +1041,11 @@ See: https://github.com/picosh/pici | |
| 1041 | 1041 | ||
| 1042 | 1042 | // waitAndReport polls the job's sessions until all complete, prints live | |
| 1043 | 1043 | // interactive progress to stdout, updates stats.json, and outputs a final summary. | |
| 1044 | - | func waitAndReport(cfg *Cfg, log *slog.Logger, name, jobID, eventType string) error { | |
| 1044 | + | func waitAndReport(cfg *Cfg, log *slog.Logger, name, jobID, domain, eventType string) error { | |
| 1045 | 1045 | cwd, _ := os.Getwd() | |
| 1046 | 1046 | branch := detectGitBranch(cwd) | |
| 1047 | 1047 | commit := detectGitCommit(cwd) | |
| 1048 | - | return waitAndReportUI(cfg, log, name, jobID, eventType, branch, commit) | |
| 1048 | + | return waitAndReportUI(cfg, log, name, jobID, domain, eventType, branch, commit) | |
| 1049 | 1049 | } | |
| 1050 | 1050 | ||
| 1051 | 1051 | func writeSummaryMarkdown(filePath string, repoName, jobID string, jobSessions []SessionInfo, known map[string]*sessionState, debugActive bool, debugTopic string) error { |
| ... | ... | @@ -2467,7 +2467,7 @@ func runLocal(cfg *Cfg, dest string) error { | |
| 2467 | 2467 | ||
| 2468 | 2468 | // Wait for completion & print live progress | |
| 2469 | 2469 | var waitErr error | |
| 2470 | - | if err := waitAndReport(cfg, log, eventData.Name, jobID, "local"); err != nil { | |
| 2470 | + | if err := waitAndReport(cfg, log, eventData.Name, jobID, "local", eventData.Type); err != nil { | |
| 2471 | 2471 | waitErr = err | |
| 2472 | 2472 | } | |
| 2473 | 2473 |
| ... | ... | @@ -2528,10 +2528,9 @@ func writePublishedSentinel(artifactDir, repoName, jobID, status string, exitCod | |
| 2528 | 2528 | return os.WriteFile(sentinel, publishedJSON, 0644) | |
| 2529 | 2529 | } | |
| 2530 | 2530 | ||
| 2531 | - | func stageLocalArtifacts(cfg *Cfg, log *slog.Logger, repoName, jobID, domainOrEvent string) error { | |
| 2532 | - | domain := domainOrEvent | |
| 2533 | - | if domain != "local" && domain != "ci" { | |
| 2534 | - | domain = getDomain(domainOrEvent) | |
| 2531 | + | func stageLocalArtifacts(cfg *Cfg, log *slog.Logger, repoName, jobID, domain string) error { | |
| 2532 | + | if domain == "" { | |
| 2533 | + | domain = "local" | |
| 2535 | 2534 | } | |
| 2536 | 2535 | prefix := fmt.Sprintf("%s.%s.%s.", domain, repoName, jobID) | |
| 2537 | 2536 |
+3,
-3
| ... | ... | @@ -953,7 +953,7 @@ func TestWaitAndReport_Cancellation(t *testing.T) { | |
| 953 | 953 | cancel() | |
| 954 | 954 | }() | |
| 955 | 955 | ||
| 956 | - | err := waitAndReport(cfg, nil, "testrepo", jobID, "local") | |
| 956 | + | err := waitAndReport(cfg, nil, "testrepo", jobID, "local", "local") | |
| 957 | 957 | if err == nil { | |
| 958 | 958 | t.Error("expected error when waitAndReport is cancelled, got nil") | |
| 959 | 959 | } |
| ... | ... | @@ -1158,7 +1158,7 @@ func TestWaitAndReport_ExitCodeFailure(t *testing.T) { | |
| 1158 | 1158 | t.Fatalf("failed to start zmx session: %v", err) | |
| 1159 | 1159 | } | |
| 1160 | 1160 | ||
| 1161 | - | err := waitAndReport(cfg, nil, "testrepo", jobID, "local") | |
| 1161 | + | err := waitAndReport(cfg, nil, "testrepo", jobID, "local", "local") | |
| 1162 | 1162 | if err == nil { | |
| 1163 | 1163 | t.Fatal("expected error from waitAndReport for failed job, got nil") | |
| 1164 | 1164 | } |
| ... | ... | @@ -1196,7 +1196,7 @@ func TestWaitAndReport_Success(t *testing.T) { | |
| 1196 | 1196 | t.Fatalf("failed to start zmx session: %v", err) | |
| 1197 | 1197 | } | |
| 1198 | 1198 | ||
| 1199 | - | err := waitAndReport(cfg, nil, "testrepo", jobID, "local") | |
| 1199 | + | err := waitAndReport(cfg, nil, "testrepo", jobID, "local", "local") | |
| 1200 | 1200 | if err != nil { | |
| 1201 | 1201 | t.Fatalf("expected nil error for successful job, got %v", err) | |
| 1202 | 1202 | } |
+7,
-5
| ... | ... | @@ -755,14 +755,16 @@ func isTerminal(w io.Writer) bool { | |
| 755 | 755 | } | |
| 756 | 756 | ||
| 757 | 757 | // waitAndReportUI executes the rich interactive reporting loop, updating stats.json. | |
| 758 | - | func waitAndReportUI(cfg *Cfg, log *slog.Logger, repoName, jobID, domainOrEvent, branch, commit string) error { | |
| 759 | - | domain := domainOrEvent | |
| 760 | - | if domain != "local" && domain != "ci" { | |
| 761 | - | domain = getDomain(domainOrEvent) | |
| 758 | + | func waitAndReportUI(cfg *Cfg, log *slog.Logger, repoName, jobID, domain, eventType, branch, commit string) error { | |
| 759 | + | if domain == "" { | |
| 760 | + | domain = "local" | |
| 761 | + | } | |
| 762 | + | if eventType == "" { | |
| 763 | + | eventType = "local" | |
| 762 | 764 | } | |
| 763 | 765 | prefix := fmt.Sprintf("%s.%s.%s.", domain, repoName, jobID) | |
| 764 | 766 | ||
| 765 | - | stats, _ := loadRepoStats(repoName) | |
| 767 | + | stats, _ := loadRepoStats(repoName, eventType) | |
| 766 | 768 | isTTY := isTerminal(os.Stdout) | |
| 767 | 769 | ||
| 768 | 770 | ctrl := newInteractiveController(cfg.Ctx, os.Stdout, repoName, branch, commit, jobID, stats, isTTY) |
M
stats.go
+22,
-7
| ... | ... | @@ -11,6 +11,7 @@ import ( | |
| 11 | 11 | // RepoStats tracks historical run durations and outcomes across runs. | |
| 12 | 12 | type RepoStats struct { | |
| 13 | 13 | Repo string `json:"repo"` | |
| 14 | + | Event string `json:"event,omitempty"` | |
| 14 | 15 | LastRunAt string `json:"last_run_at"` | |
| 15 | 16 | LastOverallStatus string `json:"last_overall_status"` | |
| 16 | 17 | RunsCount int `json:"runs_count"` |
| ... | ... | @@ -29,8 +30,11 @@ type TaskStats struct { | |
| 29 | 30 | LastStartedAt int64 `json:"last_started_at,omitempty"` | |
| 30 | 31 | } | |
| 31 | 32 | ||
| 32 | - | // getStatsPath returns the path to stats.json for the specified repository. | |
| 33 | - | func getStatsPath(repo string) string { | |
| 33 | + | // getStatsPath returns the path to stats.json for the specified repository and event. | |
| 34 | + | func getStatsPath(repo, event string) string { | |
| 35 | + | if event == "" { | |
| 36 | + | event = "local" | |
| 37 | + | } | |
| 34 | 38 | cacheDir := os.Getenv("XDG_CACHE_HOME") | |
| 35 | 39 | if cacheDir == "" { | |
| 36 | 40 | home, err := os.UserHomeDir() |
| ... | ... | @@ -40,17 +44,21 @@ func getStatsPath(repo string) string { | |
| 40 | 44 | cacheDir = filepath.Join(home, ".cache") | |
| 41 | 45 | } | |
| 42 | 46 | } | |
| 43 | - | return filepath.Join(cacheDir, "pici", repo, "stats.json") | |
| 47 | + | return filepath.Join(cacheDir, "pici", repo, event, "stats.json") | |
| 44 | 48 | } | |
| 45 | 49 | ||
| 46 | - | // loadRepoStats loads historical stats for a repo, or returns an initialized empty struct. | |
| 47 | - | func loadRepoStats(repo string) (*RepoStats, error) { | |
| 48 | - | path := getStatsPath(repo) | |
| 50 | + | // loadRepoStats loads historical stats for a repo and event, or returns an initialized empty struct. | |
| 51 | + | func loadRepoStats(repo, event string) (*RepoStats, error) { | |
| 52 | + | if event == "" { | |
| 53 | + | event = "local" | |
| 54 | + | } | |
| 55 | + | path := getStatsPath(repo, event) | |
| 49 | 56 | data, err := os.ReadFile(path) | |
| 50 | 57 | if err != nil { | |
| 51 | 58 | if os.IsNotExist(err) { | |
| 52 | 59 | return &RepoStats{ | |
| 53 | 60 | Repo: repo, | |
| 61 | + | Event: event, | |
| 54 | 62 | Tasks: make(map[string]*TaskStats), | |
| 55 | 63 | }, nil | |
| 56 | 64 | } |
| ... | ... | @@ -61,9 +69,13 @@ func loadRepoStats(repo string) (*RepoStats, error) { | |
| 61 | 69 | if err := json.Unmarshal(data, &stats); err != nil { | |
| 62 | 70 | return &RepoStats{ | |
| 63 | 71 | Repo: repo, | |
| 72 | + | Event: event, | |
| 64 | 73 | Tasks: make(map[string]*TaskStats), | |
| 65 | 74 | }, nil | |
| 66 | 75 | } | |
| 76 | + | if stats.Event == "" { | |
| 77 | + | stats.Event = event | |
| 78 | + | } | |
| 67 | 79 | if stats.Tasks == nil { | |
| 68 | 80 | stats.Tasks = make(map[string]*TaskStats) | |
| 69 | 81 | } |
| ... | ... | @@ -75,7 +87,10 @@ func saveRepoStats(stats *RepoStats) error { | |
| 75 | 87 | if stats == nil || stats.Repo == "" { | |
| 76 | 88 | return nil | |
| 77 | 89 | } | |
| 78 | - | path := getStatsPath(stats.Repo) | |
| 90 | + | if stats.Event == "" { | |
| 91 | + | stats.Event = "local" | |
| 92 | + | } | |
| 93 | + | path := getStatsPath(stats.Repo, stats.Event) | |
| 79 | 94 | if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { | |
| 80 | 95 | return err | |
| 81 | 96 | } |
+70,
-14
| ... | ... | @@ -84,15 +84,28 @@ func TestStats_StorageAndPath(t *testing.T) { | |
| 84 | 84 | t.Setenv("XDG_CACHE_HOME", tempCache) | |
| 85 | 85 | ||
| 86 | 86 | repo := "my-project" | |
| 87 | - | expectedPath := filepath.Join(tempCache, "pici", repo, "stats.json") | |
| 88 | - | path := getStatsPath(repo) | |
| 89 | - | if path != expectedPath { | |
| 90 | - | t.Errorf("expected stats path %s, got %s", expectedPath, path) | |
| 87 | + | expectedLocalPath := filepath.Join(tempCache, "pici", repo, "local", "stats.json") | |
| 88 | + | pathLocal := getStatsPath(repo, "local") | |
| 89 | + | if pathLocal != expectedLocalPath { | |
| 90 | + | t.Errorf("expected stats path %s, got %s", expectedLocalPath, pathLocal) | |
| 91 | 91 | } | |
| 92 | 92 | ||
| 93 | - | // Test Save & Load | |
| 94 | - | stats := &RepoStats{ | |
| 93 | + | // Default event should be "local" | |
| 94 | + | pathDefault := getStatsPath(repo, "") | |
| 95 | + | if pathDefault != expectedLocalPath { | |
| 96 | + | t.Errorf("expected default stats path %s, got %s", expectedLocalPath, pathDefault) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | expectedTagPath := filepath.Join(tempCache, "pici", repo, "git.tag", "stats.json") | |
| 100 | + | pathTag := getStatsPath(repo, "git.tag") | |
| 101 | + | if pathTag != expectedTagPath { | |
| 102 | + | t.Errorf("expected stats path %s, got %s", expectedTagPath, pathTag) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // Test Save & Load for "local" event | |
| 106 | + | statsLocal := &RepoStats{ | |
| 95 | 107 | Repo: repo, | |
| 108 | + | Event: "local", | |
| 96 | 109 | LastRunAt: time.Now().UTC().Format(time.RFC3339), | |
| 97 | 110 | LastOverallStatus: "success", | |
| 98 | 111 | RunsCount: 5, |
| ... | ... | @@ -109,19 +122,62 @@ func TestStats_StorageAndPath(t *testing.T) { | |
| 109 | 122 | }, | |
| 110 | 123 | } | |
| 111 | 124 | ||
| 112 | - | if err := saveRepoStats(stats); err != nil { | |
| 113 | - | t.Fatalf("failed to save stats: %v", err) | |
| 125 | + | if err := saveRepoStats(statsLocal); err != nil { | |
| 126 | + | t.Fatalf("failed to save local stats: %v", err) | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | // Test Save & Load for "git.tag" event | |
| 130 | + | statsTag := &RepoStats{ | |
| 131 | + | Repo: repo, | |
| 132 | + | Event: "git.tag", | |
| 133 | + | LastRunAt: time.Now().UTC().Format(time.RFC3339), | |
| 134 | + | LastOverallStatus: "failed", | |
| 135 | + | RunsCount: 1, | |
| 136 | + | AvgWallDurationMs: 60000, | |
| 137 | + | Tasks: map[string]*TaskStats{ | |
| 138 | + | "deploy": { | |
| 139 | + | AvgDurationMs: 30000, | |
| 140 | + | LastDurationMs: 30000, | |
| 141 | + | LastStatus: "failed", | |
| 142 | + | LastExitCode: 1, | |
| 143 | + | SeenCount: 1, | |
| 144 | + | LastStartedAt: 1700000100, | |
| 145 | + | }, | |
| 146 | + | }, | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | if err := saveRepoStats(statsTag); err != nil { | |
| 150 | + | t.Fatalf("failed to save tag stats: %v", err) | |
| 114 | 151 | } | |
| 115 | 152 | ||
| 116 | - | loaded, err := loadRepoStats(repo) | |
| 153 | + | // Verify local stats | |
| 154 | + | loadedLocal, err := loadRepoStats(repo, "local") | |
| 117 | 155 | if err != nil { | |
| 118 | - | t.Fatalf("failed to load stats: %v", err) | |
| 156 | + | t.Fatalf("failed to load local stats: %v", err) | |
| 157 | + | } | |
| 158 | + | if loadedLocal.Repo != repo || loadedLocal.Event != "local" || loadedLocal.RunsCount != 5 || loadedLocal.AvgWallDurationMs != 12000 { | |
| 159 | + | t.Errorf("loaded local stats mismatch: %+v", loadedLocal) | |
| 160 | + | } | |
| 161 | + | if task, ok := loadedLocal.Tasks["build"]; !ok || task.AvgDurationMs != 6000 || task.LastStartedAt != 1700000000 { | |
| 162 | + | t.Errorf("loaded local task stats mismatch: %+v", task) | |
| 163 | + | } | |
| 164 | + | if _, ok := loadedLocal.Tasks["deploy"]; ok { | |
| 165 | + | t.Errorf("expected deploy task NOT to be in local stats") | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // Verify tag stats | |
| 169 | + | loadedTag, err := loadRepoStats(repo, "git.tag") | |
| 170 | + | if err != nil { | |
| 171 | + | t.Fatalf("failed to load tag stats: %v", err) | |
| 172 | + | } | |
| 173 | + | if loadedTag.Repo != repo || loadedTag.Event != "git.tag" || loadedTag.RunsCount != 1 || loadedTag.AvgWallDurationMs != 60000 { | |
| 174 | + | t.Errorf("loaded tag stats mismatch: %+v", loadedTag) | |
| 119 | 175 | } | |
| 120 | - | if loaded.Repo != repo || loaded.RunsCount != 5 || loaded.AvgWallDurationMs != 12000 { | |
| 121 | - | t.Errorf("loaded stats mismatch: %+v", loaded) | |
| 176 | + | if task, ok := loadedTag.Tasks["deploy"]; !ok || task.AvgDurationMs != 30000 || task.LastStartedAt != 1700000100 { | |
| 177 | + | t.Errorf("loaded tag task stats mismatch: %+v", task) | |
| 122 | 178 | } | |
| 123 | - | if task, ok := loaded.Tasks["build"]; !ok || task.AvgDurationMs != 6000 || task.LastStartedAt != 1700000000 { | |
| 124 | - | t.Errorf("loaded task stats mismatch: %+v", task) | |
| 179 | + | if _, ok := loadedTag.Tasks["build"]; ok { | |
| 180 | + | t.Errorf("expected build task NOT to be in tag stats") | |
| 125 | 181 | } | |
| 126 | 182 | } | |
| 127 | 183 |