From 34f585106f46ab88c535ccc9db93ff88f05031d5 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Tue, 20 Oct 2026 00:34:28 +0000 Subject: [PATCH] Show the open asks in the delivery view: the gate's asks with no answer, in the table, the page and the command's JSON (issue 1345) --- .../cmd/mesh-delivery-cli/main_test.go | 58 ++++++++ .../mesh-delivery-view/integration_test.go | 91 ++++++++++++ .../cmd/mesh-delivery-view/main.go | 2 +- modules/mesh-delivery-view/go.mod | 2 +- modules/mesh-delivery-view/go.sum | 2 + .../internal/view/asks_test.go | 132 ++++++++++++++++++ .../mesh-delivery-view/internal/view/serve.go | 19 ++- .../mesh-delivery-view/internal/view/view.go | 71 +++++++++- modules/mesh-delivery-view/manifest_test.go | 2 +- modules/mesh-delivery-view/module.json | 2 + 10 files changed, 375 insertions(+), 6 deletions(-) create mode 100644 modules/mesh-delivery-view/internal/view/asks_test.go diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-cli/main_test.go b/modules/mesh-delivery-view/cmd/mesh-delivery-cli/main_test.go index 7105cad..19ff059 100644 --- a/modules/mesh-delivery-view/cmd/mesh-delivery-cli/main_test.go +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-cli/main_test.go @@ -8,7 +8,9 @@ import ( "path/filepath" "strings" "testing" + "time" + "git.novox.be/novox/mesh-sdk/go/asks" "git.novox.be/novox/mesh-sdk/go/delivery" "mesh-delivery-view/internal/view" @@ -109,3 +111,59 @@ func mustHash(t *testing.T, h delivery.Head) string { } return s } + +// TestPrintsTheOpenAsksInTheTableAndTheJSON: the command shows what waits for the operator's Approve (1345/AC2). The ask is +// the gate's, as mesh-sdk go/delivery EncodeAsk writes it under the key MergeAskKey gives (as internal/view asks_test.go). +func TestPrintsTheOpenAsksInTheTableAndTheJSON(t *testing.T) { // 1345/AC2 + m := view.New() + heads := []delivery.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: "0123456789abcdef0123456789abcdef01234567"}} + key, err := delivery.MergeAskKey("feat/x", heads) + if err != nil { + t.Fatal(err) + } + ask := asks.Ask{ID: delivery.AskID(key), Headline: "Merge into main: mesh-delivery#12", Explanation: "Merges one pull request.", Who: asks.Operator, + Expires: time.Now().Add(time.Hour), OnExpiry: "the merge stays held", + Options: []asks.Option{{ID: "approve", Label: "Approve", Does: "merges", Level: asks.Approve, Binds: "sha256:00"}}} + value, err := delivery.EncodeAsk(delivery.Ask{Group: "feat/x", Heads: heads, Ask: ask}) + if err != nil { + t.Fatal(err) + } + m.Apply(view.Entry{Bucket: delivery.AsksBucket, Key: key, Value: value}) + dir, err := os.MkdirTemp(os.Getenv("TMPDIR"), "c") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(dir) }) + l, err := view.ListenSocket(filepath.Join(dir, "cli.sock")) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + go view.ServeSocket(ctx, l, m) + t.Cleanup(cancel) + socket := filepath.Join(dir, "cli.sock") + var out, errOut bytes.Buffer + if code := run(nil, socket, &out, &errOut); code != 0 { + t.Fatalf("exit %d: %s", code, errOut.String()) + } + for _, want := range []string{"ASKS", "Merge into main: mesh-delivery#12", "Merges one pull request.", "answer on the phone"} { + if !strings.Contains(out.String(), want) { + t.Errorf("the table lacks %q:\n%s", want, out.String()) + } + } + out.Reset() + if code := run([]string{"--json"}, socket, &out, &errOut); code != 0 { + t.Fatalf("exit %d: %s", code, errOut.String()) + } + var got struct { + Asks []struct { + Key string `json:"key"` + Headline string `json:"headline"` + How string `json:"how"` + } `json:"asks"` + } + if err := json.Unmarshal(out.Bytes(), &got); err != nil || len(got.Asks) != 1 || got.Asks[0].Key != key || + got.Asks[0].Headline != "Merge into main: mesh-delivery#12" || got.Asks[0].How != "answer on the phone" { + t.Errorf("json = %s (%v)", out.String(), err) + } +} diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view/integration_test.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view/integration_test.go index 2b98ddd..09b17f5 100644 --- a/modules/mesh-delivery-view/cmd/mesh-delivery-view/integration_test.go +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view/integration_test.go @@ -15,6 +15,7 @@ import ( "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" + "git.novox.be/novox/mesh-sdk/go/asks" "git.novox.be/novox/mesh-sdk/go/delivery" "mesh-delivery-view/internal/view" @@ -299,3 +300,93 @@ func TestProcessWholeShowsTheIndexersBucketsOnceTheyExist(t *testing.T) { // 124 } t.Fatalf("the snapshot never held the indexer's keys; last: %+v", last) } + +// TestProcessWholeShowsTheOpenAsksAndDropsTheAnsweredOnes starts the program whole before the gate's asks bucket and the +// teller's answers bucket exist, then creates them: an ask appears in the snapshot, and leaves it once the teller has an +// answer under its key (1345/AC1 against a real bus; the retry is the view's own, every 5 s). +func TestProcessWholeShowsTheOpenAsksAndDropsTheAnsweredOnes(t *testing.T) { // 1345/AC1 + srv, err := server.NewServer(&server.Options{Host: "127.0.0.1", Port: -1, JetStream: true, StoreDir: t.TempDir(), NoLog: true, NoSigs: true}) + if err != nil { + t.Fatal(err) + } + go srv.Start() + if !srv.ReadyForConnections(10 * time.Second) { + t.Fatal("the bus did not come up") + } + t.Cleanup(srv.Shutdown) + nc, err := nats.Connect(srv.ClientURL()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(nc.Close) + js, err := jetstream.New(nc) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + dir, err := os.MkdirTemp(os.Getenv("TMPDIR"), "i") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(dir) }) + broker := filepath.Join(dir, "broker") + cred, _ := json.Marshal(credential{URL: srv.ClientURL(), Node: "n", Module: "mesh-delivery-view", User: "mesh-delivery-view", Password: "x"}) + if err := os.WriteFile(broker, cred, 0o600); err != nil { + t.Fatal(err) + } + socket := filepath.Join(dir, "run", "cli.sock") + env := map[string]string{"MESH_BROKER_FILE": broker, "MESH_DELIVERY_SOCKET": socket, "MESH_DELIVERY_WEB": "127.0.0.1:0"} + runCtx, stop := context.WithCancel(ctx) + done := make(chan error, 1) + go func() { done <- run(runCtx, func(k string) string { return env[k] }, io.Discard) }() + t.Cleanup(func() { stop(); <-done }) + + gate, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: delivery.AsksBucket, History: delivery.AsksHistory}) + if err != nil { + t.Fatal(err) + } + teller, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: delivery.AnswersBucket, History: delivery.AnswersHistory}) + if err != nil { + t.Fatal(err) + } + heads := []delivery.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: "0123456789abcdef0123456789abcdef01234567"}} + key, err := delivery.MergeAskKey("feat/x", heads) + if err != nil { + t.Fatal(err) + } + ask := asks.Ask{ID: delivery.AskID(key), Headline: "Merge into main: mesh-delivery#12", Explanation: "Merges one pull request.", Who: asks.Operator, + Expires: time.Now().Add(time.Hour), OnExpiry: "the merge stays held", + Options: []asks.Option{{ID: "approve", Label: "Approve", Does: "merges", Level: asks.Approve, Binds: "sha256:00"}}} + value, err := delivery.EncodeAsk(delivery.Ask{Group: "feat/x", Heads: heads, Ask: ask}) + if err != nil { + t.Fatal(err) + } + if _, err := gate.Put(ctx, key, value); err != nil { + t.Fatal(err) + } + wait := func(check func(view.Snapshot) bool, what string) { + t.Helper() + var last view.Snapshot + for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); time.Sleep(100 * time.Millisecond) { + if s, err := view.Ask(socket); err == nil { + last = s + if check(s) { + return + } + } + } + t.Fatalf("%s; last: %+v", what, last) + } + wait(func(s view.Snapshot) bool { + return len(s.Asks) == 1 && s.Asks[0].Key == key && s.Asks[0].How == "answer on the phone" + }, "the open ask never showed") + answer, err := delivery.EncodeAnswer(delivery.Answer{Option: "approve", Warrant: "w1", Words: "the operator, via telegram, chose Approve"}) + if err != nil { + t.Fatal(err) + } + if _, err := teller.Put(ctx, key, answer); err != nil { + t.Fatal(err) + } + wait(func(s view.Snapshot) bool { return len(s.Asks) == 0 }, "the answered ask never left the list") +} diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view/main.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view/main.go index 9a307f8..4f3cf6c 100644 --- a/modules/mesh-delivery-view/cmd/mesh-delivery-view/main.go +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view/main.go @@ -136,7 +136,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro if err != nil { return err } - for _, bucket := range []string{delivery.HaveBucket, view.AssignmentsBucket, view.BuildsBucket, delivery.PresenceBucket, delivery.StuckBucket, delivery.CandidatesBucket, delivery.ModulesBucket} { + for _, bucket := range []string{delivery.HaveBucket, view.AssignmentsBucket, view.BuildsBucket, delivery.PresenceBucket, delivery.StuckBucket, delivery.CandidatesBucket, delivery.ModulesBucket, delivery.AsksBucket, delivery.AnswersBucket} { go follow(ctx, js, bucket, model, logger) } select { diff --git a/modules/mesh-delivery-view/go.mod b/modules/mesh-delivery-view/go.mod index 1570291..37a8ead 100644 --- a/modules/mesh-delivery-view/go.mod +++ b/modules/mesh-delivery-view/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-view go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019223929-58373d65b47a + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-view/go.sum b/modules/mesh-delivery-view/go.sum index 9000f11..e7d24d6 100644 --- a/modules/mesh-delivery-view/go.sum +++ b/modules/mesh-delivery-view/go.sum @@ -2,6 +2,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019203925-84a764372337 h1:cZyFnvDi git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019203925-84a764372337/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019223929-58373d65b47a h1:Vjk2Kq+g/Q86N8CCHq3OatJIJ0JSy6Kh980ekGxNLmE= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019223929-58373d65b47a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69 h1:kvtV/SPsdRVUL/BZ3xxGS6U8YrXy/t7TRBjVrD6VLw8= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-view/internal/view/asks_test.go b/modules/mesh-delivery-view/internal/view/asks_test.go new file mode 100644 index 0000000..b155085 --- /dev/null +++ b/modules/mesh-delivery-view/internal/view/asks_test.go @@ -0,0 +1,132 @@ +package view + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/asks" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +const askHeadA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +// askEntry is a key of the gate's asks as the gate writes it (mesh-sdk go/delivery EncodeAsk, MergeAskKey): the router's ask +// whole, under the id its key gives. +func askEntry(t *testing.T, group, headline, words string) Entry { + t.Helper() + heads := []delivery.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: askHeadA}} + key, err := delivery.MergeAskKey(group, heads) + if err != nil { + t.Fatal(err) + } + a := asks.Ask{ID: delivery.AskID(key), Headline: headline, Explanation: words, Who: asks.Operator, Expires: t0.Add(23 * time.Hour), OnExpiry: "the merge stays held", + Options: []asks.Option{{ID: "approve", Label: "Approve", Does: "merges", Level: asks.Approve, Binds: "sha256:00"}}} + v, err := delivery.EncodeAsk(delivery.Ask{Group: group, Heads: heads, Ask: a}) + if err != nil { + t.Fatal(err) + } + return Entry{Bucket: delivery.AsksBucket, Key: key, Value: v, At: t0} +} + +func answerEntry(t *testing.T, key string) Entry { + t.Helper() + v, err := delivery.EncodeAnswer(delivery.Answer{Option: "approve", Warrant: "w1", Words: "the operator, via telegram, chose Approve"}) + if err != nil { + t.Fatal(err) + } + return Entry{Bucket: delivery.AnswersBucket, Key: key, Value: v, At: t0} +} + +func TestAnAskWithNoAnswerIsListedAndAnAnsweredOneIsNot(t *testing.T) { // 1345/AC1 + m := New() + e := askEntry(t, "feat/x", "Merge into main: mesh-delivery#12", "Merges one pull request.\nReview: approved.") + fill(m, e) + s := m.Snapshot() + if len(s.Asks) != 1 { + t.Fatalf("asks = %+v, want the one open ask", s.Asks) + } + a := s.Asks[0] + if a.Key != e.Key || a.Group != "feat/x" || a.Headline != "Merge into main: mesh-delivery#12" || !strings.Contains(a.Words, "Merges one pull request.") || + a.How != "answer on the phone" || len(a.Pulls) != 1 || !strings.Contains(a.Pulls[0], "novox/mesh-delivery#12") || !strings.Contains(a.Pulls[0], askHeadA[:12]) { + t.Errorf("ask = %+v", a) + } + fill(m, answerEntry(t, e.Key)) + if got := m.Snapshot().Asks; len(got) != 0 { + t.Errorf("an answered ask is listed: %+v", got) + } + // An answer taken away leaves the ask open again; an ask taken away leaves the list. + fill(m, Entry{Bucket: delivery.AnswersBucket, Key: e.Key, Deleted: true}) + if got := m.Snapshot().Asks; len(got) != 1 { + t.Errorf("asks after the answer was taken away = %+v", got) + } + fill(m, Entry{Bucket: delivery.AsksBucket, Key: e.Key, Deleted: true}) + if got := m.Snapshot().Asks; len(got) != 0 { + t.Errorf("a withdrawn ask is listed: %+v", got) + } +} + +func TestAnAnswerHeardBeforeItsAskStillHidesTheAsk(t *testing.T) { + m := New() + e := askEntry(t, "feat/x", "Merge", "Merges.") + fill(m, answerEntry(t, e.Key), e) + if got := m.Snapshot().Asks; len(got) != 0 { + t.Errorf("asks = %+v", got) + } +} + +func TestAnUnreadableAskIsNamedAndNotListed(t *testing.T) { // never dropped silently + m := New() + fill(m, Entry{Bucket: delivery.AsksBucket, Key: "merge.g.0123456789ab", Value: []byte("{"), At: t0}) + s := m.Snapshot() + if len(s.Asks) != 0 || len(s.Unreadable) != 1 || s.Unreadable[0].Bucket != delivery.AsksBucket { + t.Errorf("snapshot = %+v", s) + } +} + +func TestTableAndPageShowTheAsks(t *testing.T) { // 1345/AC2 + m := New() + fill(m, askEntry(t, "feat/x", "Merge into main: mesh-delivery#12", "Merges one pull request.")) + var out bytes.Buffer + Table(&out, m.Snapshot()) + for _, want := range []string{"ASKS", "Merge into main: mesh-delivery#12", "Merges one pull request.", "answer on the phone", "novox/mesh-delivery#12"} { + if !strings.Contains(out.String(), want) { + t.Errorf("the table lacks %q:\n%s", want, out.String()) + } + } + rec := httptest.NewRecorder() + Web(m).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + for _, want := range []string{"ASKS", "Merge into main: mesh-delivery#12", "Merges one pull request.", "answer on the phone"} { + if !strings.Contains(rec.Body.String(), want) { + t.Errorf("the page lacks %q:\n%s", want, rec.Body.String()) + } + } + // No ask, no section. + out.Reset() + Table(&out, New().Snapshot()) + if strings.Contains(out.String(), "ASKS") { + t.Errorf("the table shows an ASKS section with no ask:\n%s", out.String()) + } +} + +func TestTheSnapshotsJSONCarriesTheAsks(t *testing.T) { // 1345/AC2 + m := New() + fill(m, askEntry(t, "feat/x", "Merge into main: mesh-delivery#12", "Merges one pull request.")) + raw, err := json.Marshal(m.Snapshot()) + if err != nil { + t.Fatal(err) + } + var back struct { + Asks []struct { + Headline string `json:"headline"` + How string `json:"how"` + } `json:"asks"` + } + if err := json.Unmarshal(raw, &back); err != nil || len(back.Asks) != 1 || back.Asks[0].How != "answer on the phone" { + t.Errorf("json %s: %+v %v", raw, back, err) + } +} diff --git a/modules/mesh-delivery-view/internal/view/serve.go b/modules/mesh-delivery-view/internal/view/serve.go index 593a36c..73fa01e 100644 --- a/modules/mesh-delivery-view/internal/view/serve.go +++ b/modules/mesh-delivery-view/internal/view/serve.go @@ -152,6 +152,15 @@ func fingerprintText(s Snapshot) [][]string { return out } +// askText is one line per open ask: its key, headline, words on one line, the pull requests it covers and how to answer. +func askText(s Snapshot) [][]string { + var out [][]string + for _, a := range s.Asks { + out = append(out, []string{a.Key, a.Headline, strings.Join(strings.Fields(strings.ReplaceAll(a.Words, "\n", " / ")), " "), strings.Join(a.Pulls, ", "), a.How}) + } + return out +} + var head = []string{"NODE", "MODULE", "ASSIGNED", "HAVE", "PHASE", "HEALTH", "SINCE", "DETAIL", "WAITS"} // Table prints the snapshot as a table. @@ -184,6 +193,13 @@ func Table(w io.Writer, s Snapshot) { row(cells) } } + if a := askText(s); len(a) > 0 { + fmt.Fprintln(tw) + row([]string{"ASKS", "HEADLINE", "WORDS", "PULL REQUESTS", "ANSWER"}) + for _, cells := range a { + row(cells) + } + } if f := fingerprintText(s); len(f) > 0 { fmt.Fprintln(tw) row([]string{"MODULE", "FINGERPRINT", "INDEXER AGAINST BUILD"}) @@ -227,6 +243,7 @@ var page = template.Must(template.New("p").Parse(`

What each node runs

{{range .Head}}{{end}} {{range .Rows}}{{range .}}{{end}}{{end}}
{{.}}
{{.}}
{{if .Groups}}

CANDIDATES

{{range .Groups}}{{range .}}{{end}}{{end}}
GROUPPULL REQUESTHEADMODULES
{{.}}
{{end}} +{{if .Asks}}

ASKS

{{range .Asks}}{{range .}}{{end}}{{end}}
ASKHEADLINEWORDSPULL REQUESTSANSWER
{{.}}
{{end}} {{if .Fingerprints}}

FINGERPRINTS

{{range .Fingerprints}}{{range .}}{{end}}{{end}}
MODULESTATEINDEXER AGAINST BUILD
{{.}}
{{end}} {{if .Offline}}

OFFLINE

{{range .Offline}}{{range .}}{{end}}{{end}}
{{.}}
{{end}} {{if .Stuck}}

STUCK

{{range .Stuck}}{{range .}}{{end}}{{end}}
BUCKETKEYWAITED FORSINCE
{{.}}
{{end}} @@ -260,6 +277,6 @@ func Web(m *Model) http.Handler { for _, u := range snap.Unreadable { bad = append(bad, []string{u.Bucket, u.Key, u.Error}) } - page.Execute(w, map[string]any{"Head": head, "Rows": rows, "Groups": groupText(snap), "Fingerprints": fingerprintText(snap), "Offline": off, "Stuck": stuck, "Unreadable": bad}) + page.Execute(w, map[string]any{"Head": head, "Rows": rows, "Groups": groupText(snap), "Asks": askText(snap), "Fingerprints": fingerprintText(snap), "Offline": off, "Stuck": stuck, "Unreadable": bad}) }) } diff --git a/modules/mesh-delivery-view/internal/view/view.go b/modules/mesh-delivery-view/internal/view/view.go index 7ab0ec4..6caaf3c 100644 --- a/modules/mesh-delivery-view/internal/view/view.go +++ b/modules/mesh-delivery-view/internal/view/view.go @@ -3,6 +3,7 @@ package view import ( "encoding/json" + "fmt" "sort" "strings" "sync" @@ -61,8 +62,25 @@ type Snapshot struct { Groups []GroupRow `json:"groups"` // Fingerprints is every module the indexer fingerprinted at a trunk merge, beside its newest successful trunk build's. Fingerprints []FingerprintRow `json:"fingerprints"` + // Asks is every ask the gate holds that has no answer yet: what waits for the operator's Approve. + Asks []AskRow `json:"asks"` } +// AskRow is an open ask (novox/hq ADR 0437 §10): the gate's ask with no answer under its key in the teller's answers. +type AskRow struct { + Key string `json:"key"` + Group string `json:"group"` + Headline string `json:"headline"` + Words string `json:"words"` + Pulls []string `json:"pulls"` + Expires time.Time `json:"expires,omitzero"` + How string `json:"how"` +} + +// AnswerOnThePhone is what an open ask says about answering it: the view only shows, and the Approve is answered on the +// operator's phone (ADR 0437 §10: answering in the view is release 5's). +const AnswerOnThePhone = "answer on the phone" + // GroupRow is a candidate group (novox/hq ADR 0437 §5): the branch its pull requests share and the pull requests. type GroupRow struct { Group string `json:"group"` @@ -129,6 +147,8 @@ type Model struct { builds map[string]buildEntry // by .build. groups map[string]groupEntry // the indexer's candidates, by group modules map[string]moduleEntry // the indexer's modules, by module + asks map[string]askRecord // the gate's asks, by key + answers map[string]bool // the keys the teller has an answer under newest map[string]string // module -> id haves map[string]haveEntry nodes map[string]nodeEntry @@ -194,6 +214,11 @@ type moduleEntry struct { err string } +type askRecord struct { + value delivery.Ask + err string +} + type haveEntry struct { have delivery.Have err string @@ -208,7 +233,7 @@ type nodeEntry struct { // New is an empty model. func New() *Model { - return &Model{assigns: map[string]assignment{}, builds: map[string]buildEntry{}, groups: map[string]groupEntry{}, modules: map[string]moduleEntry{}, newest: map[string]string{}, + return &Model{assigns: map[string]assignment{}, builds: map[string]buildEntry{}, groups: map[string]groupEntry{}, modules: map[string]moduleEntry{}, asks: map[string]askRecord{}, answers: map[string]bool{}, newest: map[string]string{}, haves: map[string]haveEntry{}, nodes: map[string]nodeEntry{}, presence: map[string]presenceEntry{}, stuck: map[string]stuckEntry{}} } @@ -277,6 +302,20 @@ func (m *Model) Apply(e Entry) { } mod, err := delivery.DecodeModule(e.Value) m.modules[e.Key] = moduleEntry{value: mod, err: errText(err)} + case delivery.AsksBucket: + if e.Deleted { + delete(m.asks, e.Key) + return + } + a, err := delivery.DecodeAsk(e.Value) + m.asks[e.Key] = askRecord{value: a, err: errText(err)} + case delivery.AnswersBucket: + // An answer under the ask's key, whatever it says, ends the ask here: the gate acts on it, and the view only shows. + if e.Deleted { + delete(m.answers, e.Key) + return + } + m.answers[e.Key] = true case BuildsBucket: module, rest, ok := strings.Cut(e.Key, ".") if !ok { @@ -414,6 +453,11 @@ func (m *Model) Snapshot() Snapshot { unreadable = append(unreadable, UnreadableRow{Bucket: delivery.ModulesBucket, Key: key, Error: mod.err}) } } + for key, a := range m.asks { + if a.err != "" { + unreadable = append(unreadable, UnreadableRow{Bucket: delivery.AsksBucket, Key: key, Error: a.err}) + } + } sort.Slice(offline, func(i, j int) bool { a, b := offline[i], offline[j] return a.Kind+a.Node+a.Part+a.Instance < b.Kind+b.Node+b.Part+b.Instance @@ -425,7 +469,7 @@ func (m *Model) Snapshot() Snapshot { return unreadable[i].Bucket+"."+unreadable[i].Key < unreadable[j].Bucket+"."+unreadable[j].Key }) return Snapshot{Rows: rows, Nodes: nodes, Offline: offline, Stuck: stuck, Unreadable: unreadable, - Groups: m.groupRows(), Fingerprints: m.fingerprintRows()} + Groups: m.groupRows(), Fingerprints: m.fingerprintRows(), Asks: m.askRows()} } // groupRows are the readable candidate groups, ordered by group, their pull requests by repository and number. @@ -524,3 +568,26 @@ const ( StatusAssigned = "assigned" StatusLeftBehind = "left behind" ) + +// askRows are the readable asks with no answer under their key, ordered by key: each with its headline, its words, the +// pull requests it covers (repository, number, head shortened to 12 digits) and how to answer. +func (m *Model) askRows() []AskRow { + out := []AskRow{} + for key, a := range m.asks { + if a.err != "" || m.answers[key] { + continue + } + row := AskRow{Key: key, Group: a.value.Group, Headline: a.value.Ask.Headline, Words: a.value.Ask.Explanation, + Expires: a.value.Ask.Expires, How: AnswerOnThePhone, Pulls: []string{}} + for _, h := range a.value.Heads { + head := h.Head + if len(head) > 12 { + head = head[:12] + } + row.Pulls = append(row.Pulls, fmt.Sprintf("%s#%d @%s", h.Repository, h.Number, head)) + } + out = append(out, row) + } + sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key }) + return out +} diff --git a/modules/mesh-delivery-view/manifest_test.go b/modules/mesh-delivery-view/manifest_test.go index 1a751e3..f3bc40e 100644 --- a/modules/mesh-delivery-view/manifest_test.go +++ b/modules/mesh-delivery-view/manifest_test.go @@ -74,7 +74,7 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7 if m.Module != "mesh-delivery-view" { t.Errorf("module = %q", m.Module) } - want := []string{"mesh-controller.assignments", "mesh-controller.builds", "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.modules", "mesh-delivery-watchman.presence", "mesh-delivery-watchman.stuck", "node-engine.have"} // 1168/AC1, 1241/AC4 + want := []string{"mesh-controller.assignments", "mesh-controller.builds", "mesh-delivery-gate.asks", "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.modules", "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-delivery-watchman.stuck", "node-engine.have"} // 1168/AC1, 1241/AC4, 1345/AC3 got := slices.Clone(m.Reads) slices.Sort(got) if !reflect.DeepEqual(got, want) { diff --git a/modules/mesh-delivery-view/module.json b/modules/mesh-delivery-view/module.json index f7a16ea..eb33058 100644 --- a/modules/mesh-delivery-view/module.json +++ b/modules/mesh-delivery-view/module.json @@ -6,8 +6,10 @@ "reads": [ "mesh-controller.assignments", "mesh-controller.builds", + "mesh-delivery-gate.asks", "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.modules", + "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-delivery-watchman.stuck", "node-engine.have"