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":"🏛️ 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"]}
|
{"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":[
|
"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":"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"]},
|
{"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"`
|
Tags []string `json:"tags,omitempty"`
|
||||||
Risk bool `json:"risk,omitempty"`
|
Risk bool `json:"risk,omitempty"`
|
||||||
Gate bool `json:"gate,omitempty"`
|
Gate bool `json:"gate,omitempty"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stage is one column of the pipeline. PlainTitle/Plain are the plain-language
|
// Stage is one column of the pipeline. PlainTitle/Plain are the plain-language
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package gitea
|
package gitea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -21,9 +22,37 @@ func base() string {
|
|||||||
|
|
||||||
// Runs returns the raw /actions/tasks JSON for mathias/cad-atlas (newest first).
|
// Runs returns the raw /actions/tasks JSON for mathias/cad-atlas (newest first).
|
||||||
func Runs() ([]byte, error) {
|
func Runs() ([]byte, error) {
|
||||||
url := base() + "/api/v1/repos/mathias/cad-atlas/actions/tasks?limit=50"
|
return get(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
|
|
||||||
|
// 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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -33,7 +62,7 @@ func Runs() ([]byte, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
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
|
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 {
|
if ld.deploy != nil || ld.flux != nil {
|
||||||
var live []atlas.Node
|
var live []atlas.Node
|
||||||
if ld.deploy != nil {
|
if ld.deploy != nil {
|
||||||
@@ -84,6 +91,7 @@ type liveOverlayData struct {
|
|||||||
deploy *atlas.Deploy
|
deploy *atlas.Deploy
|
||||||
flux *atlas.Flux
|
flux *atlas.Flux
|
||||||
timeline []atlas.RunDot
|
timeline []atlas.RunDot
|
||||||
|
issues []atlas.Node
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -123,6 +131,11 @@ func liveOverlay() liveOverlayData {
|
|||||||
d.timeline = dots
|
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 raw, err := cluster.Deployment(); err == nil {
|
||||||
if dep, err := atlas.DeployState(raw); err == nil {
|
if dep, err := atlas.DeployState(raw); err == nil {
|
||||||
d.deploy = &dep
|
d.deploy = &dep
|
||||||
|
|||||||
@@ -111,8 +111,8 @@
|
|||||||
.stage .no{color:var(--dim);font-size:11px;letter-spacing:2px}
|
.stage .no{color:var(--dim);font-size:11px;letter-spacing:2px}
|
||||||
.stage h2{font-size:17px;margin:6px 0 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}
|
.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);
|
.node{display:block;border:1px solid var(--line);border-radius:11px;background:var(--panel);
|
||||||
padding:12px 13px;margin-top:12px;position:relative;
|
padding:12px 13px;margin-top:12px;position:relative;color:inherit;text-decoration:none;
|
||||||
transition:border-color .25s,box-shadow .25s,transform .25s}
|
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 .t{font-weight:600;margin-bottom:3px;display:flex;align-items:center;gap:7px}
|
||||||
.node .d{color:var(--dim);font-size:12px}
|
.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.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>`;
|
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);
|
st.innerHTML=h;track.appendChild(st);stageEls.push(st);
|
||||||
// stacked-layout transition row (shown on mobile where the SVG spine is hidden)
|
// stacked-layout transition row (shown on mobile where the SVG spine is hidden)
|
||||||
|
|||||||
Reference in New Issue
Block a user