generated from mathias/template-go-web
feat(atlas): Phase C increment 1 — live CI runs on the pipeline
The CI stage now shows the pipeline's latest real execution, read live from the in-cluster Gitea Actions API (public read, no token, gitea-http.gitea.svc): "▶ run #N · <state>" coloured by outcome, prepended to the generated job list. LatestRun + RunNode built test-first; cached in the same 30s liveOverlay; falls back cleanly when Gitea is unreachable. assessor-loop ledger / session_log deferred: no live CAD data exists for this repo yet (brain confirms). Gitea run history is the real available trace. Verified: build/vet/lint(0)/test green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
cadatlas "git.d-ma.be/mathias/cad-atlas"
|
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/atlas"
|
||||||
"git.d-ma.be/mathias/cad-atlas/internal/cluster"
|
"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
|
// 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 != "" {
|
if ld.ns != "" {
|
||||||
a.NS = "Tailscale mesh · " + 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")
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
_ = json.NewEncoder(w).Encode(a)
|
_ = json.NewEncoder(w).Encode(a)
|
||||||
})
|
})
|
||||||
@@ -54,6 +63,7 @@ func NewHandler() http.Handler {
|
|||||||
type liveOverlayData struct {
|
type liveOverlayData struct {
|
||||||
hosts []atlas.Host
|
hosts []atlas.Host
|
||||||
ns string
|
ns string
|
||||||
|
run *atlas.Run
|
||||||
}
|
}
|
||||||
|
|
||||||
// live cache: query the cluster at most once per TTL; fall back to the authored
|
// 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
|
d.ns = s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if raw, err := gitea.Runs(); err == nil {
|
||||||
|
if r, err := atlas.LatestRun(raw); err == nil {
|
||||||
|
d.run = &r
|
||||||
|
}
|
||||||
|
}
|
||||||
liveCache = d
|
liveCache = d
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,7 +114,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<h1><b>CAD</b> Atlas · From Signal to Pod</h1>
|
<h1><b>CAD</b> Atlas · From Signal to Pod</h1>
|
||||||
<span class="sub mono">one human gate · everything up- and downstream is agents · <em>v0.6 · live cluster (nodes + namespaces) · single-source /api/atlas.json</em></span>
|
<span class="sub mono">one human gate · everything up- and downstream is agents · <em>v0.7 · Phase C: live CI runs (Gitea Actions) on the pipeline</em></span>
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<button id="replay"><span class="dot"></span> Replay</button>
|
<button id="replay"><span class="dot"></span> Replay</button>
|
||||||
<button id="slowmo">Slow-mo · <span id="slowState">off</span></button>
|
<button id="slowmo">Slow-mo · <span id="slowState">off</span></button>
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
CAD → CI → CD · intent→specify→dispatch · build→test→validate · deploy→ship.
|
CAD → CI → CD · intent→specify→dispatch · build→test→validate · deploy→ship.
|
||||||
Dashed violet = feedback bus (stage 08 → TELOS: deploy outcome scored vs originating goal).
|
Dashed violet = feedback bus (stage 08 → TELOS: deploy outcome scored vs originating goal).
|
||||||
Data served from <code>/api/atlas.json</code> (authored <code>atlas.json</code> + CI stage from the live <code>cd.yml</code> + substrate from the live cluster nodes).
|
Data served from <code>/api/atlas.json</code> (authored <code>atlas.json</code> + CI stage from the live <code>cd.yml</code> + substrate from the live cluster nodes).
|
||||||
Phase C plugs in live reads of <code>assessor-loop</code> ledger · <code>session_log</code> · Gitea run API · Flux events.
|
Phase C: <code>Gitea run API</code> live (latest run on the CI stage). Pending: <code>assessor-loop</code> ledger · <code>session_log</code> · per-job status · Flux events.
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
Reference in New Issue
Block a user