Files
cad-atlas/internal/atlas/runs.go
T
mathias 6967d12d1d
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 6s
CD / var-go/oath (push) Has been skipped
CD / Build & Import (push) Successful in 18s
CD / Deploy via GitOps (push) Has been skipped
feat(atlas): weight replay pacing on CI/CD stages by real job durations (#5)
TDD: Job.Seconds() + StageSeconds() derive real CI/CD dwell time from the
latest run's per-job created_at/updated_at (last job in pipeline order =
deploy/stage 07, everything before it = CI/stage 06).

Frontend: weightedSpineDist() redistributes the pixel-time-budget the
06/07 segments already had, splitting it by real CI:CD duration ratio
instead of raw pixel width. Falls back to the exact prior constant-speed
sweep when no run data is available (weights default to segment pixel
length) or when a job is skipped (0 duration) — no behavior change for
stages 00-05/08, which still have no live timing source (same gap as #5's
ledger item).

Verified: real duration values flow through /api/atlas.json (ci_duration_s:
18 observed against a real run), full page screenshot confirms no visual
regression.
2026-07-20 23:22:53 +02:00

189 lines
5.2 KiB
Go

package atlas
import (
"encoding/json"
"fmt"
"time"
)
// Job is one job within a workflow run (a Gitea Actions "task"). Started/
// Finished are best-effort (zero value if the job hasn't completed or the
// timestamp failed to parse).
type Job struct {
Name string
Status string
Conclusion string
Started time.Time
Finished time.Time
}
// State is the effective outcome: conclusion if set, else status.
func (j Job) State() string {
if j.Conclusion != "" {
return j.Conclusion
}
return j.Status
}
// Seconds is how long the job ran, or 0 if either timestamp is missing/invalid.
func (j Job) Seconds() float64 {
if j.Started.IsZero() || j.Finished.Before(j.Started) {
return 0
}
return j.Finished.Sub(j.Started).Seconds()
}
// RunSummary is the newest workflow run and its per-job outcomes.
type RunSummary struct {
Number int
SHA string
Title string
Jobs []Job
}
// State aggregates the jobs: failure if any failed, running if any not yet
// succeeded, else success.
func (s RunSummary) State() string {
return aggregateState(s.Jobs)
}
// RunDot is one run's aggregate outcome for the recent-runs timeline.
type RunDot struct {
Number int `json:"number"`
State string `json:"state"`
}
// RecentRuns parses a Gitea `/actions/tasks` response (per-job, newest first)
// into up to n most-recent runs with their aggregate outcome, newest first.
func RecentRuns(tasksJSON []byte, n int) ([]RunDot, error) {
var resp struct {
Tasks []struct {
RunNumber int `json:"run_number"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
} `json:"workflow_runs"`
}
if err := json.Unmarshal(tasksJSON, &resp); err != nil {
return nil, fmt.Errorf("parse tasks: %w", err)
}
var order []int
jobsByRun := map[int][]Job{}
for _, t := range resp.Tasks {
if _, seen := jobsByRun[t.RunNumber]; !seen {
order = append(order, t.RunNumber)
}
jobsByRun[t.RunNumber] = append(jobsByRun[t.RunNumber], Job{Status: t.Status, Conclusion: t.Conclusion})
}
dots := make([]RunDot, 0, n)
for _, rn := range order {
if len(dots) >= n {
break
}
dots = append(dots, RunDot{Number: rn, State: aggregateState(jobsByRun[rn])})
}
return dots, nil
}
// aggregateState folds per-job outcomes into a run outcome.
func aggregateState(jobs []Job) string {
pending := false
for _, j := range jobs {
switch j.State() {
case "failure", "cancelled", "error":
return "failure"
case "success", "skipped": // completed OK — skipped (e.g. deploy on a tag push) doesn't block
default:
pending = true // running / in_progress / waiting / queued / unknown
}
}
if pending {
return "running"
}
return "success"
}
// LatestRunJobs parses a Gitea `/actions/tasks` response (per-job entries,
// newest first) and returns the newest run with its jobs in pipeline order.
func LatestRunJobs(tasksJSON []byte) (RunSummary, error) {
var resp struct {
Tasks []struct {
RunNumber int `json:"run_number"`
Name string `json:"name"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
SHA string `json:"head_sha"`
Title string `json:"display_title"`
Created string `json:"created_at"`
Updated string `json:"updated_at"`
} `json:"workflow_runs"`
}
if err := json.Unmarshal(tasksJSON, &resp); err != nil {
return RunSummary{}, fmt.Errorf("parse tasks: %w", err)
}
if len(resp.Tasks) == 0 {
return RunSummary{}, fmt.Errorf("no workflow tasks")
}
latest := resp.Tasks[0]
s := RunSummary{Number: latest.RunNumber, SHA: latest.SHA, Title: latest.Title}
for _, t := range resp.Tasks {
if t.RunNumber == latest.RunNumber {
started, _ := time.Parse(time.RFC3339, t.Created)
finished, _ := time.Parse(time.RFC3339, t.Updated)
s.Jobs = append(s.Jobs, Job{
Name: t.Name, Status: t.Status, Conclusion: t.Conclusion,
Started: started, Finished: finished,
})
}
}
// Gitea lists newest (last-finished) first; reverse to pipeline order.
for i, j := 0, len(s.Jobs)-1; i < j; i, j = i+1, j-1 {
s.Jobs[i], s.Jobs[j] = s.Jobs[j], s.Jobs[i]
}
return s, nil
}
// RunNodes renders a run as stage nodes: a summary node followed by one node
// per job, each coloured by outcome.
func RunNodes(s RunSummary) []Node {
sha := s.SHA
if len(sha) > 7 {
sha = sha[:7]
}
nodes := []Node{{
Title: fmt.Sprintf("▶ run #%d · %s", s.Number, s.State()),
Desc: s.Title,
Pill: statePill(s.State()),
Tags: []string{"live · Gitea Actions", sha},
}}
for _, j := range s.Jobs {
nodes = append(nodes, Node{Title: j.Name, Pill: statePill(j.State())})
}
return nodes
}
// StageSeconds splits a run's real job durations across the two live-timed
// stages: the last job in pipeline order is the deploy (stage 07), everything
// before it is CI (stage 06). Returns 0, 0 if there are no jobs.
func StageSeconds(s RunSummary) (ci, cd float64) {
if len(s.Jobs) == 0 {
return 0, 0
}
last := len(s.Jobs) - 1
for _, j := range s.Jobs[:last] {
ci += j.Seconds()
}
cd = s.Jobs[last].Seconds()
return ci, cd
}
func statePill(state string) string {
switch state {
case "success":
return "var(--green)"
case "failure", "cancelled", "error":
return "var(--coral)"
}
return "var(--amber)"
}