diff --git a/internal/atlas/runs.go b/internal/atlas/runs.go new file mode 100644 index 0000000..b07931c --- /dev/null +++ b/internal/atlas/runs.go @@ -0,0 +1,60 @@ +package atlas + +import ( + "encoding/json" + "fmt" +) + +// Run is one Gitea Actions workflow run — a real execution of the pipeline. +type Run struct { + Number int `json:"run_number"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + SHA string `json:"head_sha"` + Title string `json:"display_title"` + URL string `json:"url"` +} + +// State is the effective outcome: the conclusion if set, else the status. +func (r Run) State() string { + if r.Conclusion != "" { + return r.Conclusion + } + return r.Status +} + +// RunNode renders a run as a stage node, coloured by outcome +// (success=green, failure/cancelled=coral, otherwise amber/in-progress). +func RunNode(r Run) Node { + pill := "var(--amber)" + switch r.State() { + case "success": + pill = "var(--green)" + case "failure", "cancelled": + pill = "var(--coral)" + } + sha := r.SHA + if len(sha) > 7 { + sha = sha[:7] + } + return Node{ + Title: fmt.Sprintf("▶ run #%d · %s", r.Number, r.State()), + Desc: r.Title, + Pill: pill, + Tags: []string{"live · Gitea Actions", sha}, + } +} + +// LatestRun parses a Gitea `/actions/tasks` response and returns the newest run. +func LatestRun(tasksJSON []byte) (Run, error) { + var resp struct { + Runs []Run `json:"workflow_runs"` + } + if err := json.Unmarshal(tasksJSON, &resp); err != nil { + return Run{}, fmt.Errorf("parse runs: %w", err) + } + if len(resp.Runs) == 0 { + return Run{}, fmt.Errorf("no workflow runs") + } + return resp.Runs[0], nil +} diff --git a/internal/atlas/runs_test.go b/internal/atlas/runs_test.go new file mode 100644 index 0000000..7b7a622 --- /dev/null +++ b/internal/atlas/runs_test.go @@ -0,0 +1,70 @@ +package atlas_test + +import ( + "testing" + + "git.d-ma.be/mathias/cad-atlas/internal/atlas" +) + +func TestLatestRun_ReturnsNewestRun(t *testing.T) { + tasks := []byte(`{"total_count":52,"workflow_runs":[ + {"run_number":27,"status":"success","conclusion":null,"head_sha":"633ba153f26","display_title":"Phase B tail","url":"https://git.d-ma.be/mathias/cad-atlas/actions/runs/27"}, + {"run_number":26,"status":"failure","head_sha":"aaa"} + ]}`) + + r, err := atlas.LatestRun(tasks) + if err != nil { + t.Fatalf("LatestRun: %v", err) + } + if r.Number != 27 || r.SHA != "633ba153f26" || r.Title != "Phase B tail" { + t.Fatalf("run = %+v", r) + } + if r.State() != "success" { + t.Fatalf("State = %q, want success", r.State()) + } +} + +func TestLatestRun_ConclusionWinsOverStatus(t *testing.T) { + tasks := []byte(`{"workflow_runs":[{"run_number":9,"status":"completed","conclusion":"failure"}]}`) + r, err := atlas.LatestRun(tasks) + if err != nil { + t.Fatalf("LatestRun: %v", err) + } + if r.State() != "failure" { + t.Fatalf("State = %q, want failure", r.State()) + } +} + +func TestRunNode_ColoursAndLabelsByOutcome(t *testing.T) { + green := atlas.RunNode(atlas.Run{Number: 27, Status: "success", SHA: "633ba153f26abc", Title: "Phase B tail"}) + if green.Pill != "var(--green)" { + t.Fatalf("success pill = %q, want var(--green)", green.Pill) + } + if green.Title != "▶ run #27 · success" { + t.Fatalf("title = %q", green.Title) + } + if green.Desc != "Phase B tail" { + t.Fatalf("desc = %q", green.Desc) + } + // short sha appears as a tag + found := false + for _, tag := range green.Tags { + if tag == "633ba15" { + found = true + } + } + if !found { + t.Fatalf("short sha tag missing: %v", green.Tags) + } + + red := atlas.RunNode(atlas.Run{Number: 5, Status: "completed", Conclusion: "failure"}) + if red.Pill != "var(--coral)" { + t.Fatalf("failure pill = %q, want var(--coral)", red.Pill) + } +} + +func TestLatestRun_ErrorsWhenNoRuns(t *testing.T) { + if _, err := atlas.LatestRun([]byte(`{"workflow_runs":[]}`)); err == nil { + t.Fatal("expected error on empty runs, got nil") + } +} diff --git a/internal/gitea/client.go b/internal/gitea/client.go new file mode 100644 index 0000000..cf446d9 --- /dev/null +++ b/internal/gitea/client.go @@ -0,0 +1,39 @@ +// Package gitea reads the repo's own Gitea Actions run history from the +// in-cluster Gitea service (public read — no token), so the atlas can show the +// pipeline's live executions. +package gitea + +import ( + "fmt" + "io" + "net/http" + "os" + "time" +) + +// base is the in-cluster Gitea service by default; override with GITEA_BASE. +func base() string { + if b := os.Getenv("GITEA_BASE"); b != "" { + return b + } + return "http://gitea-http.gitea.svc.cluster.local:3000" +} + +// Runs returns the raw /actions/tasks JSON for mathias/cad-atlas (newest first). +func Runs() ([]byte, error) { + url := base() + "/api/v1/repos/mathias/cad-atlas/actions/tasks?limit=1" + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Get(url) //nolint:noctx // short-lived, timeout on the client + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("gitea runs: %s", resp.Status) + } + return body, nil +} diff --git a/internal/web/handler.go b/internal/web/handler.go index 09722ef..1e42521 100644 --- a/internal/web/handler.go +++ b/internal/web/handler.go @@ -10,6 +10,7 @@ import ( cadatlas "git.d-ma.be/mathias/cad-atlas" "git.d-ma.be/mathias/cad-atlas/internal/atlas" "git.d-ma.be/mathias/cad-atlas/internal/cluster" + "git.d-ma.be/mathias/cad-atlas/internal/gitea" ) // atlasHTML is the Phase-A/B static shell. It fetches /api/atlas.json at load @@ -44,6 +45,14 @@ func NewHandler() http.Handler { if ld.ns != "" { a.NS = "Tailscale mesh · " + ld.ns } + if ld.run != nil { + node := atlas.RunNode(*ld.run) + for i := range a.Stages { + if a.Stages[i].Generate == "ci-jobs" { + a.Stages[i].Nodes = append([]atlas.Node{node}, a.Stages[i].Nodes...) + } + } + } w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(a) }) @@ -54,6 +63,7 @@ func NewHandler() http.Handler { type liveOverlayData struct { hosts []atlas.Host ns string + run *atlas.Run } // live cache: query the cluster at most once per TTL; fall back to the authored @@ -85,6 +95,11 @@ func liveOverlay() liveOverlayData { d.ns = s } } + if raw, err := gitea.Runs(); err == nil { + if r, err := atlas.LatestRun(raw); err == nil { + d.run = &r + } + } liveCache = d return d } diff --git a/internal/web/static/cad-atlas.html b/internal/web/static/cad-atlas.html index baee860..c1ac6f6 100644 --- a/internal/web/static/cad-atlas.html +++ b/internal/web/static/cad-atlas.html @@ -114,7 +114,7 @@
/api/atlas.json (authored atlas.json + CI stage from the live cd.yml + substrate from the live cluster nodes).
- Phase C plugs in live reads of assessor-loop ledger · session_log · Gitea run API · Flux events.
+ Phase C: Gitea run API live (latest run on the CI stage). Pending: assessor-loop ledger · session_log · per-job status · Flux events.