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)" }