diff --git a/internal/atlas/atlas.json b/internal/atlas/atlas.json index 350097e..a1e9bdb 100644 --- a/internal/atlas/atlas.json +++ b/internal/atlas/atlas.json @@ -23,7 +23,7 @@ {"t":"🏛️ LLM Council","plain_t":"AI review panel","plain":"For hard calls, several AI models answer independently, anonymously critique each other, and a \"chair\" model synthesises one verdict — reducing any single model's bias.","cls":"council","pill":"var(--violet)","d":"fan-out → anonymous cross-review → chairman synth. glm-4.7-flash · qwen36-35b · gemma4-31b (chair).","tags":["hard strategic Q","chat.d-ma.be"]}, {"t":"Autoresearch Council","plain_t":"Research review panel","plain":"A parallel version of the same review that vets research findings before they're allowed through.","cls":"council","pill":"var(--violet)","d":"Sibling pipe — ratifies research before the gate.","tags":["proposed: → standalone svc"]} ]}, - {"no":"STAGE 03","short":"Write order","title":"Spec → Gitea issue","plain_title":"Write the work order","plain":"The decision is turned into a precise, self-contained work order an AI agent can execute unsupervised — with a pass/fail definition of done, a risk rating, and a tamper-proof seal.","path":"agent-ready contract", + {"no":"STAGE 03","short":"Write order","title":"Spec → Gitea issue","plain_title":"Write the work order","plain":"The decision is turned into a precise, self-contained work order an AI agent can execute unsupervised — with a pass/fail definition of done, a risk rating, and a tamper-proof seal.","path":"agent-ready contract","generate":"gitea-issues", "trans_label":"Sealed & agent-ready","trans":"Advances to the gate only when the spec is a complete contract: a pass/fail test, a risk tier, a regulatory note, no open human dependencies, one embedded Oath, and a valid cryptographic signature. A malformed or unsigned order fails closed and never reaches the gate.","nodes":[ {"t":"Contract enforced","plain_t":"The work-order rules","plain":"The work order must have a clear pass/fail test, a risk rating, a regulatory-risk note, and no unfinished human dependencies before it counts as agent-ready.","d":"Binary ISC · declared risk tier · reg-risk assessment · no open human deps.","tags":["LOW / MED / HIGH"]}, {"t":"Admission controller","plain_t":"Tamper-proof seal","plain":"The work order is cryptographically signed when created, so any later tampering is detectable and the eventual change can be checked against it.","d":"Ed25519-sign issue body at creation (#36). Verify sig + PR alignment at infra boundary.","tags":["chain of custody"]}, diff --git a/internal/atlas/issues.go b/internal/atlas/issues.go new file mode 100644 index 0000000..654032e --- /dev/null +++ b/internal/atlas/issues.go @@ -0,0 +1,32 @@ +package atlas + +import ( + "encoding/json" + "fmt" +) + +// IssueNodes parses a Gitea `/repos/issues/search` response (the +// authenticated user's own open issues, newest first) into one node per +// issue, linking out to the issue. +func IssueNodes(searchJSON []byte) ([]Node, error) { + var issues []struct { + Number int `json:"number"` + Title string `json:"title"` + HTMLURL string `json:"html_url"` + Repository struct { + FullName string `json:"full_name"` + } `json:"repository"` + } + if err := json.Unmarshal(searchJSON, &issues); err != nil { + return nil, fmt.Errorf("parse issues: %w", err) + } + nodes := make([]Node, 0, len(issues)) + for _, i := range issues { + nodes = append(nodes, Node{ + Title: fmt.Sprintf("#%d %s", i.Number, i.Title), + Tags: []string{"live · Gitea", i.Repository.FullName}, + URL: i.HTMLURL, + }) + } + return nodes, nil +} diff --git a/internal/atlas/issues_test.go b/internal/atlas/issues_test.go new file mode 100644 index 0000000..914c4c2 --- /dev/null +++ b/internal/atlas/issues_test.go @@ -0,0 +1,41 @@ +package atlas_test + +import ( + "testing" + + "git.d-ma.be/mathias/cad-atlas/internal/atlas" +) + +func TestIssueNodes_OneNodePerIssueWithRepoTagAndURL(t *testing.T) { + search := []byte(`[ + {"number":212,"title":"segment-embedder: add smoke test","html_url":"https://git.d-ma.be/mathias/infra/issues/212","repository":{"full_name":"mathias/infra"}}, + {"number":8,"title":"Write cad-atlas's own vargo-gate candidate","html_url":"https://git.d-ma.be/mathias/cad-atlas/issues/8","repository":{"full_name":"mathias/cad-atlas"}} + ]`) + + nodes, err := atlas.IssueNodes(search) + if err != nil { + t.Fatalf("IssueNodes: %v", err) + } + if len(nodes) != 2 { + t.Fatalf("want 2 nodes, got %d", len(nodes)) + } + if nodes[0].Title != "#212 segment-embedder: add smoke test" { + t.Fatalf("title = %q", nodes[0].Title) + } + if nodes[0].URL != "https://git.d-ma.be/mathias/infra/issues/212" { + t.Fatalf("url = %q", nodes[0].URL) + } + if len(nodes[0].Tags) != 2 || nodes[0].Tags[0] != "live · Gitea" || nodes[0].Tags[1] != "mathias/infra" { + t.Fatalf("tags = %v", nodes[0].Tags) + } +} + +func TestIssueNodes_EmptyListReturnsEmptyNotNil(t *testing.T) { + nodes, err := atlas.IssueNodes([]byte(`[]`)) + if err != nil { + t.Fatalf("IssueNodes: %v", err) + } + if nodes == nil || len(nodes) != 0 { + t.Fatalf("nodes = %+v, want empty non-nil slice", nodes) + } +} diff --git a/internal/atlas/model.go b/internal/atlas/model.go index f3981ea..e18964e 100644 --- a/internal/atlas/model.go +++ b/internal/atlas/model.go @@ -23,6 +23,7 @@ type Node struct { Tags []string `json:"tags,omitempty"` Risk bool `json:"risk,omitempty"` Gate bool `json:"gate,omitempty"` + URL string `json:"url,omitempty"` } // Stage is one column of the pipeline. PlainTitle/Plain are the plain-language diff --git a/internal/gitea/client.go b/internal/gitea/client.go index f077b14..125e299 100644 --- a/internal/gitea/client.go +++ b/internal/gitea/client.go @@ -4,6 +4,7 @@ package gitea import ( + "context" "fmt" "io" "net/http" @@ -21,9 +22,37 @@ func base() string { // 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=50" - client := &http.Client{Timeout: 5 * time.Second} - resp, err := client.Get(url) //nolint:noctx // short-lived, timeout on the client + return get(base()+"/api/v1/repos/mathias/cad-atlas/actions/tasks?limit=50", "") +} + +// MyIssues returns the raw /repos/issues/search JSON for the token owner's +// own open issues across every repo they can see. Requires GITEA_TOKEN — a +// read-only PAT for the mathias account (this is a single-operator homelab, +// not per-visitor OAuth: anyone who clears Authentik forward-auth sees +// Mathias's own data). Returns an error if GITEA_TOKEN is unset, so callers +// can skip the overlay gracefully. +func MyIssues() ([]byte, error) { + token := os.Getenv("GITEA_TOKEN") + if token == "" { + return nil, fmt.Errorf("GITEA_TOKEN not set") + } + url := base() + "/api/v1/repos/issues/search?state=open&created=true&type=issues&limit=8" + return get(url, token) +} + +// get performs a short-lived GET, optionally with a bearer token, and returns +// the response body. +func get(url, token string) ([]byte, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + if token != "" { + req.Header.Set("Authorization", "token "+token) + } + resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } @@ -33,7 +62,7 @@ func Runs() ([]byte, error) { return nil, err } if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("gitea runs: %s", resp.Status) + return nil, fmt.Errorf("gitea: %s", resp.Status) } return body, nil } diff --git a/internal/web/handler.go b/internal/web/handler.go index 2cd0263..9742512 100644 --- a/internal/web/handler.go +++ b/internal/web/handler.go @@ -56,6 +56,13 @@ func NewHandler() http.Handler { } } } + if ld.issues != nil { + for i := range a.Stages { + if a.Stages[i].Generate == "gitea-issues" { + a.Stages[i].Nodes = append(ld.issues, a.Stages[i].Nodes...) + } + } + } if ld.deploy != nil || ld.flux != nil { var live []atlas.Node if ld.deploy != nil { @@ -84,6 +91,7 @@ type liveOverlayData struct { deploy *atlas.Deploy flux *atlas.Flux timeline []atlas.RunDot + issues []atlas.Node } // live cache: query the cluster at most once per TTL; fall back to the authored @@ -123,6 +131,11 @@ func liveOverlay() liveOverlayData { d.timeline = dots } } + if raw, err := gitea.MyIssues(); err == nil { + if nodes, err := atlas.IssueNodes(raw); err == nil { + d.issues = nodes + } + } if raw, err := cluster.Deployment(); err == nil { if dep, err := atlas.DeployState(raw); err == nil { d.deploy = &dep diff --git a/internal/web/static/cad-atlas.html b/internal/web/static/cad-atlas.html index 51e0cf7..c049bd0 100644 --- a/internal/web/static/cad-atlas.html +++ b/internal/web/static/cad-atlas.html @@ -111,8 +111,8 @@ .stage .no{color:var(--dim);font-size:11px;letter-spacing:2px} .stage h2{font-size:17px;margin:6px 0 2px} .stage .path{color:var(--mono);font-size:11.5px;margin-bottom:6px;min-height:16px} - .node{border:1px solid var(--line);border-radius:11px;background:var(--panel); - padding:12px 13px;margin-top:12px;position:relative; + .node{display:block;border:1px solid var(--line);border-radius:11px;background:var(--panel); + padding:12px 13px;margin-top:12px;position:relative;color:inherit;text-decoration:none; transition:border-color .25s,box-shadow .25s,transform .25s} .node .t{font-weight:600;margin-bottom:3px;display:flex;align-items:center;gap:7px} .node .d{color:var(--dim);font-size:12px} @@ -246,7 +246,9 @@ function renderAtlas(){ if(n.risk)inner+=`
LOW · autoMED · ntfy gateHIGH · blocked
`; } if(n.gate)inner+=`
✓ approve
✕ reject
`; - h+=`
${inner}
`; + const tag = n.url ? 'a' : 'div'; + const link = n.url ? ` href="${n.url}" target="_blank" rel="noopener"` : ''; + h+=`<${tag} class="node ${n.cls||''}"${link}>${inner}`; }); st.innerHTML=h;track.appendChild(st);stageEls.push(st); // stacked-layout transition row (shown on mobile where the SVG spine is hidden)