This repository has been archived on 2026-09-06. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
cad-atlas/internal/web/handler.go
T
mathiasandClaude Opus 5 097d8747c6
CD / Deploy via GitOps (push) Successful in 0s
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 6s
CD / var-go/oath (push) Skipped
CD / Build & Import (push) Successful in 5s
feat(atlas): surface the user's own Gitea repos on stage 05 (#10)
Adds the repos panel from #4's "Not yet shipped" list, following the shipped
issues slice (b2761a4) structurally rather than inventing a second approach:

- internal/atlas/repos.go — RepoNodes(), a pure parser over an already-decoded
  /user/repos response. Archived repos are dropped: the atlas shows where work
  happens, and an archived repo is by definition somewhere it does not.
- internal/gitea/client.go — MyRepos(), same PAT and same contract as MyIssues:
  errors when GITEA_TOKEN is unset so the caller skips the overlay rather than
  issuing an unauthenticated request. That unset path is the one running in
  prod today (#4), so it is tested, not assumed.
- internal/web/handler.go — wired into liveOverlay().

Stage 05 is the right home — repos are what the execution stage acts on — but
#11 had just claimed its `generate` key for stuck-badges, and a stage could
only name one source. So `generate` now accepts either spelling:

    "generate": "ci-jobs"
    "generate": ["stuck-badges", "gitea-repos"]

The bare string stays valid and marshals back as a bare string, so no authored
stage is rewritten and the frontend contract is unchanged. Stages 04 and 08
already share stuck-badges between them; this is the same growth one level in.

The overlay merge also moves out of the handler into atlas.PrependGenerated,
which is unit-testable where the inline loop was not — it sat behind a network
call. It covers the issues path too, and fixes a latent aliasing bug there: the
old `append(liveNodes, authored...)` wrote into the live slice's backing array,
so two stages sharing a key would corrupt each other. That shape is no longer
hypothetical now that stuck-badges spans three stages.

Verified end-to-end both ways: without GITEA_TOKEN the atlas returns 200 with
stage 05's authored nodes intact; with a real token stage 05 serves both
overlays at once — stuck badges, then eight live repos, then the authored
nodes — and a repo with no language emits one tag rather than an empty pill.

task check green (golangci-lint 0 issues, vet, -race).

Closes #10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 22:01:48 +02:00

223 lines
6.6 KiB
Go

package web
import (
"context"
_ "embed"
"encoding/json"
"net/http"
"os"
"strings"
"sync"
"time"
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"
"git.d-ma.be/mathias/cad-atlas/internal/version"
"git.d-ma.be/mathias/cad/adapters/gitearead"
cadapp "git.d-ma.be/mathias/cad/app"
caddomain "git.d-ma.be/mathias/cad/domain"
)
// atlasHTML is the Phase-A/B static shell. It fetches /api/atlas.json at load
// and renders from that data (no inline arrays), so the content is sourced.
//
//go:embed static/cad-atlas.html
var atlasHTML []byte
// NewHandler serves the CAD Atlas: the shell at "/", and the sourced data at
// "/api/atlas.json" (authored data + CI stage generated from the real cd.yml +
// substrate overlaid from the live cluster when reachable).
func NewHandler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(atlasHTML)
})
mux.HandleFunc("/api/atlas.json", func(w http.ResponseWriter, r *http.Request) {
a, err := atlas.Default(cadatlas.CDWorkflow)
if err != nil {
http.Error(w, "atlas build failed", http.StatusInternalServerError)
return
}
a.Version = version.Value
ld := liveOverlay()
if len(ld.hosts) > 0 {
a.Substrate = atlas.MergeSubstrate(a.Substrate, ld.hosts)
}
if ld.ns != "" {
a.NS = "Tailscale mesh · " + ld.ns
}
a.Timeline = ld.timeline
if ld.run != nil {
nodes := atlas.RunNodes(*ld.run)
for i := range a.Stages {
if a.Stages[i].Generate.Has("ci-jobs") {
a.Stages[i].Nodes = nodes
}
}
a.CIDurationS, a.CDDurationS = atlas.StageSeconds(*ld.run)
}
atlas.PrependGenerated(&a, "gitea-issues", ld.issues)
atlas.PrependGenerated(&a, "gitea-repos", ld.repos)
// cad-atlas#11: overlay mathias/cad#14's cad-stuck findings as
// per-stage badges. Read-only — ApplyStuckOverlay only adds Nodes
// with a link out; it never writes anything back to Gitea.
a.Stages = atlas.ApplyStuckOverlay(a.Stages, ld.stuck)
if ld.deploy != nil || ld.flux != nil {
var live []atlas.Node
if ld.deploy != nil {
live = append(live, atlas.DeployNode(*ld.deploy))
}
if ld.flux != nil {
live = append(live, atlas.FluxNode(*ld.flux))
}
for i := range a.Stages {
if a.Stages[i].Generate.Has("deploy-state") {
a.Stages[i].Nodes = append(live, a.Stages[i].Nodes...)
}
}
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(a)
})
return mux
}
// liveOverlay holds cluster-sourced substrate facts (node specs + namespace line).
type liveOverlayData struct {
hosts []atlas.Host
ns string
run *atlas.RunSummary
deploy *atlas.Deploy
flux *atlas.Flux
timeline []atlas.RunDot
issues []atlas.Node
repos []atlas.Node
stuck []caddomain.StuckFinding
}
// stuckRepos is the estate cad-stuck queries — mirrors mathias/dispatch's
// dispatch-repos.txt scope. Override with CAD_ATLAS_STUCK_REPOS (comma
// separated "owner/name" entries) so this doesn't need a redeploy every time
// the estate's repo list changes; cad-atlas has no filesystem access to the
// canonical dispatch-repos.txt at runtime.
var defaultStuckRepos = []string{
"mathias/dispatch-sandbox", "mathias/cobalt-dingo", "mathias/parallax",
"mathias/cad-atlas", "mathias/brain-gardener", "mathias/hyperguild", "mathias/brain",
}
func stuckRepos() []string {
if v := os.Getenv("CAD_ATLAS_STUCK_REPOS"); v != "" {
var repos []string
for _, r := range strings.Split(v, ",") {
if r = strings.TrimSpace(r); r != "" {
repos = append(repos, r)
}
}
return repos
}
return defaultStuckRepos
}
// live cache: query the cluster at most once per TTL; fall back to the authored
// substrate/ns (zero values) whenever the cluster is unreachable.
var (
liveMu sync.Mutex
liveCache liveOverlayData
liveAt time.Time
)
const liveTTL = 30 * time.Second
func liveOverlay() liveOverlayData {
liveMu.Lock()
defer liveMu.Unlock()
if !liveAt.IsZero() && time.Since(liveAt) < liveTTL {
return liveCache
}
liveAt = time.Now()
var d liveOverlayData
if raw, err := cluster.Nodes(); err == nil {
if hosts, err := atlas.HostsFromNodes(raw); err == nil {
d.hosts = hosts
}
}
if raw, err := cluster.Namespaces(); err == nil {
if s, err := atlas.NamespaceSummary(raw); err == nil {
d.ns = s
}
}
if raw, err := gitea.Runs(); err == nil {
if r, err := atlas.LatestRunJobs(raw); err == nil {
d.run = &r
}
if dots, err := atlas.RecentRuns(raw, 12); err == nil {
d.timeline = dots
}
}
if raw, err := gitea.MyIssues(); err == nil {
if nodes, err := atlas.IssueNodes(raw); err == nil {
d.issues = nodes
}
}
if raw, err := gitea.MyRepos(); err == nil {
if nodes, err := atlas.RepoNodes(raw); err == nil {
d.repos = nodes
}
}
if raw, err := cluster.Deployment(); err == nil {
if dep, err := atlas.DeployState(raw); err == nil {
d.deploy = &dep
}
}
if raw, err := cluster.FluxKustomization(); err == nil {
if f, err := atlas.FluxStatus(raw); err == nil {
d.flux = &f
}
}
d.stuck = fetchStuck()
liveCache = d
return d
}
// fetchStuck re-derives cad#14's stuck worklist fresh on every liveOverlay
// call (no cache beyond the shared liveTTL) — the same "re-derive, never
// recall" discipline cad-stuck and cad-babysit both hold to. Read-only:
// gitearead.Client has no write method in its package. Degrades to nil
// (no badges rendered) on any error or when GITEA_TOKEN is unset, exactly
// like every other liveOverlay source — a forge outage costs a missing
// overlay, never a broken page.
func fetchStuck() []caddomain.StuckFinding {
token := os.Getenv("GITEA_TOKEN")
if token == "" {
return nil
}
base := os.Getenv("GITEA_BASE")
if base == "" || strings.Contains(base, ".svc.cluster.local") {
// The in-cluster Gitea service (internal/gitea's default) has no
// public HTTPS endpoint for gitearead's client to call with a
// bearer token in the same way; cad-stuck's own default (the public
// git.d-ma.be) is the correct base here regardless of what
// GITEA_BASE is set to for the read-only in-cluster fetchers above.
base = "https://git.d-ma.be"
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
client := gitearead.New(base, token)
cfg := cadapp.DefaultStuckConfig()
cfg.BaseURL = base
findings, _, err := cadapp.RunStuckQuery(ctx, client, stuckRepos(), cfg)
if err != nil {
return nil
}
return findings
}