diff --git a/modules/mesh-delivery-checker/internal/check/case.go b/modules/mesh-delivery-checker/internal/check/case.go index 2e1a6373..936e282b 100644 --- a/modules/mesh-delivery-checker/internal/check/case.go +++ b/modules/mesh-delivery-checker/internal/check/case.go @@ -47,7 +47,9 @@ type Case struct { warnings map[string]string // seriesOf are, by pull request, the changes of a package series it makes (series.go), named in its status line. seriesOf map[string][]string - notes []string + // seriesChanges are, by pull request, the same changes as structures (series.go), named in the verdict and its event. + seriesChanges map[string][]delivery.SeriesChange + notes []string // asks are the ids of the asks the check put on the build seats' streams, in order: a try names them (Try.Asks). asks []string // Watch, when set, hears each ask the check puts, as it is put (step nil), and each value its step key takes while diff --git a/modules/mesh-delivery-checker/internal/check/checker_test.go b/modules/mesh-delivery-checker/internal/check/checker_test.go index 5e85a0cf..60e203af 100644 --- a/modules/mesh-delivery-checker/internal/check/checker_test.go +++ b/modules/mesh-delivery-checker/internal/check/checker_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "git.novox.be/novox/mesh-contracts/go/seats/meshdeliverychecker" "git.novox.be/novox/mesh-sdk/go/delivery" ) @@ -430,6 +431,7 @@ func TestTheStatusIsPendingWhileTheCheckRunsAndEndsSuccessOrFailure(t *testing.T raw, _, _, _ := r.owed.Get(ctx, key) o, _ := delivery.DecodeCheckerOwed(raw) seen = append(seen, o.State) + c.series(repoA+"#7", []string{"web/pkg series 0.1 → 0.2"}, []delivery.SeriesChange{{Module: "web", Artifact: "pkg", From: "0.1", To: "0.2"}}) return Outcome{Verdict: delivery.Verdict{State: delivery.VerdictFail, Failed: delivery.FailedFirst{Test: "m/a.TestX"}}, OutOfStep: map[string][]string{repoA + "#7": {"go/seats/x/contract.go"}}}, nil }} @@ -450,7 +452,9 @@ func TestTheStatusIsPendingWhileTheCheckRunsAndEndsSuccessOrFailure(t *testing.T // DLV-C2 (design 26 *Out of step*): the check's end says one verdict event for the pull request, naming its out-of-step // files; the running status says none. if len(says.said) != 1 || says.said[0].Repository != repoA || says.said[0].Pull != 7 || says.said[0].Head != head1 || - says.said[0].Verdict != delivery.VerdictFail || strings.Join(says.said[0].OutOfStep, ",") != "go/seats/x/contract.go" { + says.said[0].Verdict != delivery.VerdictFail || strings.Join(says.said[0].OutOfStep, ",") != "go/seats/x/contract.go" || + // The join (A2j finding 2): and its series changes, as the seat's event names them. + len(says.said[0].Series) != 1 || says.said[0].Series[0] != (meshdeliverychecker.VerdictHolderEventSeriesItem{Module: "web", Artifact: "pkg", From: "0.1", To: "0.2"}) { t.Errorf("verdict events %+v", says.said) } } diff --git a/modules/mesh-delivery-checker/internal/check/examiner_test.go b/modules/mesh-delivery-checker/internal/check/examiner_test.go index cfd92e1d..658d8945 100644 --- a/modules/mesh-delivery-checker/internal/check/examiner_test.go +++ b/modules/mesh-delivery-checker/internal/check/examiner_test.go @@ -1151,6 +1151,14 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) { if line := Line(v, cs, cs.Pulls[0], nil, nil, nil); row.seriesWords != "" && !strings.Contains(line, row.seriesWords) || row.seriesWords == "" && strings.Contains(line, "series") { t.Errorf("%s: the line %q, want it to name %q", row.name, line, row.seriesWords) } + // The join (A2j finding 2): the verdict names each series change as {module, artifact, from, to}. + if want := []delivery.SeriesChange(nil); row.series == "0.2" { + if want = []delivery.SeriesChange{{Module: "web", Artifact: "pkg", From: "0.1", To: "0.2"}}; !reflect.DeepEqual(v.Series, want) { + t.Errorf("%s: the verdict's series %+v, want %+v", row.name, v.Series, want) + } + } else if len(v.Series) != 0 { + t.Errorf("%s: the verdict's series %+v, want none", row.name, v.Series) + } if len(judged) != 1 || judged[0].Fetch["repository"] != DeliveryRepository || judged[0].Fetch["commit"] != trunk || judged[0].Package != JudgePackage || judged[0].Dir != JudgeDir { t.Fatalf("%s: judged %+v (verdict %+v)", row.name, judged, v) diff --git a/modules/mesh-delivery-checker/internal/check/judge.go b/modules/mesh-delivery-checker/internal/check/judge.go index 7ec487ca..00a14638 100644 --- a/modules/mesh-delivery-checker/internal/check/judge.go +++ b/modules/mesh-delivery-checker/internal/check/judge.go @@ -327,6 +327,7 @@ func (st *state) composed(ctx context.Context, v *delivery.Verdict) { // outcome fills the verdict's levels, times, machines and composition from what ran. func (st *state) outcome(v delivery.Verdict) Outcome { + v.Series = st.c.allSeries() // the series changes, as structures (A2j finding 2) if v.State == delivery.VerdictPass || len(v.Levels) == 0 { v.Levels = map[string]delivery.LevelResult{} for level, a := range st.levels { diff --git a/modules/mesh-delivery-checker/internal/check/series.go b/modules/mesh-delivery-checker/internal/check/series.go index a2e00760..6574b700 100644 --- a/modules/mesh-delivery-checker/internal/check/series.go +++ b/modules/mesh-delivery-checker/internal/check/series.go @@ -7,6 +7,7 @@ import ( "strings" "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" ) // A change of a package series is named in the verdict, so the review of the change sees it (design 32 *A package, its @@ -18,6 +19,7 @@ import ( func (st *state) seriesChanges(ctx context.Context) { for _, p := range st.c.Pulls { var words []string + var changes []delivery.SeriesChange for _, module := range p.Modules { was, now, ok, err := st.manifestPair(ctx, p, module) if err != nil { @@ -25,11 +27,15 @@ func (st *state) seriesChanges(ctx context.Context) { continue } if ok { - words = append(words, seriesMoved(module, was, now)...) + moved := seriesMoved(module, was, now) + changes = append(changes, moved...) + for _, c := range moved { + words = append(words, seriesLine(c)) + } } } if len(words) > 0 { - st.c.series(pullKey(p), words) + st.c.series(pullKey(p), words, changes) } } } @@ -65,8 +71,14 @@ func (st *state) manifestPair(ctx context.Context, p Pull, module string) (was, return was, now, true, nil } -// seriesMoved are the artifacts of module whose series the head's manifest changes from the trunk's. -func seriesMoved(module string, was, now catalogue.Manifest) []string { +// seriesLine is a series change as the status line names it: `/ series → `. +func seriesLine(c delivery.SeriesChange) string { + return fmt.Sprintf("%s/%s series %s → %s", c.Module, c.Artifact, c.From, c.To) +} + +// seriesMoved are the artifacts of module whose series the head's manifest changes from the trunk's, by artifact. An +// artifact new on the head is no series change (the lead's ruling): it has no series on the trunk. +func seriesMoved(module string, was, now catalogue.Manifest) []delivery.SeriesChange { if was.Build == nil || now.Build == nil { return nil } @@ -74,24 +86,50 @@ func seriesMoved(module string, was, now catalogue.Manifest) []string { for _, a := range was.Build.Artifacts { before[a.Name] = a.SeriesOf() } - var out []string + var out []delivery.SeriesChange for _, a := range now.Build.Artifacts { if old, ok := before[a.Name]; ok && old != a.SeriesOf() { - out = append(out, fmt.Sprintf("%s/%s series %s → %s", module, a.Name, old, a.SeriesOf())) + out = append(out, delivery.SeriesChange{Module: module, Artifact: a.Name, From: old, To: a.SeriesOf()}) } } - slices.Sort(out) + slices.SortFunc(out, func(a, b delivery.SeriesChange) int { return strings.Compare(a.Artifact, b.Artifact) }) return out } -// series keeps, of a pull request (pullKey), the words of the series changes it makes. -func (c *Case) series(key string, words []string) { +// series keeps, of a pull request (pullKey), the words of the series changes it makes and the changes themselves. +func (c *Case) series(key string, words []string, changes []delivery.SeriesChange) { c.notesMu.Lock() defer c.notesMu.Unlock() if c.seriesOf == nil { - c.seriesOf = map[string][]string{} + c.seriesOf, c.seriesChanges = map[string][]string{}, map[string][]delivery.SeriesChange{} } - c.seriesOf[key] = words + c.seriesOf[key], c.seriesChanges[key] = words, changes +} + +// SeriesChanges are the series changes a pull request (pullKey) makes, as the verdict and its event name them. +func (c *Case) SeriesChanges(key string) []delivery.SeriesChange { + c.notesMu.Lock() + defer c.notesMu.Unlock() + return slices.Clone(c.seriesChanges[key]) +} + +// allSeries are the series changes of every pull request of the case, each module's artifact once, by module and +// artifact: what the verdict names (delivery.Verdict.Series). +func (c *Case) allSeries() []delivery.SeriesChange { + var out []delivery.SeriesChange + seen := map[string]bool{} + for _, p := range c.Pulls { + for _, s := range c.SeriesChanges(pullKey(p)) { + if k := s.Module + "/" + s.Artifact; !seen[k] { + seen[k] = true + out = append(out, s) + } + } + } + slices.SortFunc(out, func(a, b delivery.SeriesChange) int { + return strings.Compare(a.Module+"/"+a.Artifact, b.Module+"/"+b.Artifact) + }) + return out } // Series are the series changes a pull request (pullKey) makes, as the check named them; none when it makes none. diff --git a/modules/mesh-delivery-checker/internal/check/verdictevent.go b/modules/mesh-delivery-checker/internal/check/verdictevent.go index 6f840200..12434b3e 100644 --- a/modules/mesh-delivery-checker/internal/check/verdictevent.go +++ b/modules/mesh-delivery-checker/internal/check/verdictevent.go @@ -28,8 +28,11 @@ func (ch *Checker) sayVerdict(ctx context.Context, cs *Case, v delivery.Verdict, return } for _, p := range cs.Pulls { - e := VerdictEvent{Repository: p.Repository, Pull: int64(p.Number), Head: p.Head, Verdict: v.State, - OutOfStep: out.OutOfStep[pullKey(Pull{Repository: p.Repository, Number: p.Number})]} + key := pullKey(Pull{Repository: p.Repository, Number: p.Number}) + e := VerdictEvent{Repository: p.Repository, Pull: int64(p.Number), Head: p.Head, Verdict: v.State, OutOfStep: out.OutOfStep[key]} + for _, s := range cs.SeriesChanges(key) { + e.Series = append(e.Series, meshdeliverychecker.VerdictHolderEventSeriesItem{Module: s.Module, Artifact: s.Artifact, From: s.From, To: s.To}) + } if err := ch.Says.Say(ctx, e); err != nil { ch.logf("%s: the verdict of %s#%d was written, and its event could not be said: %v", cs.Key, p.Repository, p.Number, err) } diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/owners_test.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/owners_test.go index 4ce88e98..7a14849f 100644 --- a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/owners_test.go +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/owners_test.go @@ -36,6 +36,15 @@ func (s source) Get(key string) (*views.Entry, bool, error) { return &views.Entry{Value: json.RawMessage(v), Revision: uint64(len(key))}, true, nil } +// kept is a fake owner state that keeps its keys' history, as views.KeptSource does: what the owner's read tool answers a +// `history` from. +type kept struct { + source + history map[string][]views.Revision +} + +func (k kept) History(key string) ([]views.Revision, error) { return k.history[key], nil } + // ownersAnswerTheToolsAsBefore is DLV-V1: every view tool over the indexer's and the assigner's states answers through // the owners' read tools (the real owner side, views.Read, over a fake state) what it answered reading the states. func ownersAnswerTheToolsAsBefore(t *testing.T) { @@ -55,6 +64,17 @@ func ownersAnswerTheToolsAsBefore(t *testing.T) { delivery.IndexerModule + "." + delivery.PackagesState: {pkgKey: `{"v":1,"module":"git.novox.be/novox/mesh-sdk/go","commit":"c1"}`}, delivery.IndexerModule + "." + graph.State: {}, } + // The kept history of the indexer's modules and heard and the assigner's wants (each declared with more than one + // revision in its owner's manifest), which indexer_module_history, indexer_heard and view_tiers' times read. + at := time.Date(2026, 11, 4, 9, 0, 0, 0, time.UTC) + const heard1 = `{"v":1,"subject":"s","sequence":41,"repository":"novox/mesh-catalog","commit":"9cdac09a"}` + states[delivery.IndexerModule+".heard"] = source{"forge": heard1} + histories := map[string]map[string][]views.Revision{ + delivery.IndexerModule + ".modules": {"a": {{Revision: 7, At: at, Op: "put", Value: json.RawMessage(withNone)}, + {Revision: 9, At: at.Add(time.Minute), Op: "put", Value: json.RawMessage(withFP2)}}}, + delivery.IndexerModule + ".heard": {"forge": {{Revision: 3, At: at, Op: "put", Value: json.RawMessage(heard1)}}}, + delivery.AssignerModule + ".wants": {"want.ace.zsh": {{Revision: 12, At: at, Op: "put", Value: json.RawMessage(`{"fp":"` + fp2A + `"}`)}}}, + } // The direct reading, as the tools read the states before (the reads the manifest no longer has). st := func(name string) view.StateRead { return fake(states[name]).reader().State } direct := view.Reader{State: st(delivery.IndexerModule + ".modules"), CandidatesState: st(delivery.IndexerModule + ".candidates"), @@ -82,6 +102,14 @@ func ownersAnswerTheToolsAsBefore(t *testing.T) { all, _ := args["values"].(bool) after, _ := args["after"].(string) owner, state, _ := strings.Cut(name, ".") + if h, _ := args["history"].(bool); h { + // The join (J4): the owner's read tool answers a key's kept history. + a, err := views.ReadHistory(owner, state, kept{states[name], histories[name]}, k) + if err != nil { + return nil, err + } + return json.Marshal(a) + } a, err := views.Read(owner, state, states[name], nil, k, all, after, time.Unix(0, 0)) if err != nil { return nil, err @@ -118,14 +146,23 @@ func ownersAnswerTheToolsAsBefore(t *testing.T) { t.Errorf("%s %v through the owner's read tool = %s, %v; reading the state it was %s, %v", row.tool, row.args, gj, gerr, wj, werr) } } + // A key's history is asked of the owner's read tool and answered from its kept values (A2j finding 1, J4). + out, err := run(t, owned, view.ModuleHistoryTool, map[string]any{"module": "a"}) + if h, _ := out.(view.ModuleHistory); err != nil || len(h.Values) != 2 || h.Values[0].Revision != 9 || h.Values[1].FP2 != "" || !h.Values[1].At.Equal(at) { + t.Errorf("indexer_module_history a through the indexer's read tool = %+v, %v", out, err) + } + out, err = run(t, owned, view.HeardTool, map[string]any{}) + if rows, _ := out.(view.HeardAnswer); err != nil || len(rows.Heard) != 1 || rows.Heard[0].Revision != 3 || string(rows.Heard[0].Heard) != heard1 { + t.Errorf("indexer_heard through the indexer's read tool = %+v, %v", out, err) + } + // view_tiers' Since of a want is its first kept revision's time, read through the assigner's read tool. + if revs, err := owned.WantsState.History("want.ace.zsh"); err != nil || len(revs) != 1 || !revs[0].At.Equal(at) || revs[0].Revision != 12 { + t.Errorf("the want's history through the assigner's read tool = %+v, %v", revs, err) + } // Each owner state the tools read was asked of its owner's read tool. for tool, name := range tools { if name != delivery.IndexerModule+"."+graph.State && asked[tool] == 0 { t.Errorf("%s was never asked", tool) } } - // A history the owner's read tool does not answer is said, never answered as none. - if _, err := run(t, owned, view.ModuleHistoryTool, map[string]any{"module": "a"}); err == nil || !strings.Contains(err.Error(), "no history argument") { - t.Errorf("indexer_module_history with an owner answering no history: %v", err) - } }