generated from mathias/template-go-web
feat(gitea): surface mathias's own open Gitea issues on stage 03 (#4)
TDD: IssueNodes parses /repos/issues/search into stage nodes (title, repo tag, clickable html_url). gitea.MyIssues() reads GITEA_TOKEN and skips gracefully when unset, mirroring the existing liveOverlay fallback pattern. Single-operator homelab, not per-visitor OAuth: a static read-only PAT gates on "cleared Authentik forward-auth", not per-user token exchange — see #4 discussion. GITEA_TOKEN provisioning in infra (ExternalSecret) is a separate follow-up; without it the overlay is inert (no crash, just no live nodes), so this ships safely ahead of that wiring. Nodes with a url now render as clickable <a class="node"> instead of <div class="node">.
This commit is contained in:
@@ -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"]},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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+=`<div class="risk mono"><span class="lo">LOW · auto</span><span class="md">MED · ntfy gate</span><span class="hi">HIGH · blocked</span></div>`;
|
||||
}
|
||||
if(n.gate)inner+=`<div class="gatebtns mono"><div class="g ok">✓ approve</div><div class="g no">✕ reject</div></div>`;
|
||||
h+=`<div class="node ${n.cls||''}">${inner}</div>`;
|
||||
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}</${tag}>`;
|
||||
});
|
||||
st.innerHTML=h;track.appendChild(st);stageEls.push(st);
|
||||
// stacked-layout transition row (shown on mobile where the SVG spine is hidden)
|
||||
|
||||
Reference in New Issue
Block a user