generated from mathias/template-go-web
A strip of the last 12 runs (aggregate pass/fail/running per run) below the substrate ribbon, coloured, newest-first, run # on hover. Parsed from the same Gitea /actions/tasks fetch (RecentRuns, test-first; State refactored to share the aggregate). No new RBAC. Hidden when no live data. Verified: build/vet/lint(0)/test green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
154 lines
4.0 KiB
Go
154 lines
4.0 KiB
Go
package atlas
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
)
|
|
|
|
// Job is one job within a workflow run (a Gitea Actions "task").
|
|
type Job struct {
|
|
Name string
|
|
Status string
|
|
Conclusion string
|
|
}
|
|
|
|
// State is the effective outcome: conclusion if set, else status.
|
|
func (j Job) State() string {
|
|
if j.Conclusion != "" {
|
|
return j.Conclusion
|
|
}
|
|
return j.Status
|
|
}
|
|
|
|
// 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 {
|
|
allSucceeded := true
|
|
for _, j := range jobs {
|
|
switch j.State() {
|
|
case "failure", "cancelled", "error":
|
|
return "failure"
|
|
case "success":
|
|
default:
|
|
allSucceeded = false
|
|
}
|
|
}
|
|
if allSucceeded {
|
|
return "success"
|
|
}
|
|
return "running"
|
|
}
|
|
|
|
// 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"`
|
|
} `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 {
|
|
s.Jobs = append(s.Jobs, Job{Name: t.Name, Status: t.Status, Conclusion: t.Conclusion})
|
|
}
|
|
}
|
|
// 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
|
|
}
|
|
|
|
func statePill(state string) string {
|
|
switch state {
|
|
case "success":
|
|
return "var(--green)"
|
|
case "failure", "cancelled", "error":
|
|
return "var(--coral)"
|
|
}
|
|
return "var(--amber)"
|
|
}
|