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)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(`<!doctype html><html><head><me
|
||||
<body><h1>What each node runs</h1><table><tr>{{range .Head}}<th>{{.}}</th>{{end}}</tr>
|
||||
{{range .Rows}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>
|
||||
{{if .Groups}}<h2>CANDIDATES</h2><table><tr><th>GROUP</th><th>PULL REQUEST</th><th>HEAD</th><th>MODULES</th></tr>{{range .Groups}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>{{end}}
|
||||
{{if .Asks}}<h2>ASKS</h2><table><tr><th>ASK</th><th>HEADLINE</th><th>WORDS</th><th>PULL REQUESTS</th><th>ANSWER</th></tr>{{range .Asks}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>{{end}}
|
||||
{{if .Fingerprints}}<h2>FINGERPRINTS</h2><table><tr><th>MODULE</th><th>STATE</th><th>INDEXER AGAINST BUILD</th></tr>{{range .Fingerprints}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>{{end}}
|
||||
{{if .Offline}}<h2>OFFLINE</h2><table>{{range .Offline}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>{{end}}
|
||||
{{if .Stuck}}<h2>STUCK</h2><table><tr><th>BUCKET</th><th>KEY</th><th>WAITED FOR</th><th>SINCE</th></tr>{{range .Stuck}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>{{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})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 <module>.build.<id>
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user