diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/integration_test.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/integration_test.go index 6e804bf2..0c4b668f 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/integration_test.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/integration_test.go @@ -27,8 +27,8 @@ import ( "git.novox.be/novox/mesh-contracts/go/seats/meshdeliverychecker" "git.novox.be/novox/mesh-contracts/go/seats/npmpackageregistry" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" - "git.novox.be/novox/mesh-sdk/go/announce" "git.novox.be/novox/mesh-delivery/sdk/firstview/firstviewtest" + "git.novox.be/novox/mesh-sdk/go/announce" "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/process" diff --git a/modules/mesh-delivery-checker/internal/check/case.go b/modules/mesh-delivery-checker/internal/check/case.go index 3f80136f..2e1a6373 100644 --- a/modules/mesh-delivery-checker/internal/check/case.go +++ b/modules/mesh-delivery-checker/internal/check/case.go @@ -45,6 +45,8 @@ type Case struct { notesMu sync.Mutex // warnings are, by pull request, what part of its check did not run (Case.warn). 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 // 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 diff --git a/modules/mesh-delivery-checker/internal/check/examiner_test.go b/modules/mesh-delivery-checker/internal/check/examiner_test.go index b237c86f..ecda3707 100644 --- a/modules/mesh-delivery-checker/internal/check/examiner_test.go +++ b/modules/mesh-delivery-checker/internal/check/examiner_test.go @@ -1035,8 +1035,12 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) { // mail's tool while mail awaits web's database: both left out, so neither is refused (ADR 0614 §3, §6). cycle string trunkCycle bool + // series is the series of web's package the change raises it to, and the words its status line names (DLV-C4). + series, seriesWords string }{ {name: "accepted"}, + {name: "a package series raised", series: "0.2", seriesWords: "web/pkg series 0.1 → 0.2"}, + {name: "a package series kept", series: "0.1"}, {name: "an untouched manifest's problem the trunk had", untouched: "mail says no credential form", trunkMail: "mail says no credential form"}, {name: "a problem the change causes in an untouched manifest", untouched: "mail reads a state web no longer keeps", fails: "refuses the manifest of mail: mail reads a state web no longer keeps"}, @@ -1077,6 +1081,13 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) { } b.ex.Read = fixedManifests{strings.Repeat("a", 64): mail, strings.Repeat("b", 64): trunkWeb, web: changed} } + if row.series != "" { + pkg := func(series string) *catalogue.Build { + return &catalogue.Build{Artifacts: []catalogue.Artifact{{Name: "pkg", Kind: catalogue.ArtifactPackage, Language: "go", Series: series}}} + } + b.ex.Read = fixedManifests{strings.Repeat("a", 64): {Module: "mail"}, strings.Repeat("b", 64): {Module: "web", Build: pkg("")}, + web: {Module: "web", Build: pkg(row.series)}} + } var judged []delivery.ComposeJob answer := b.asks.Answer b.asks.Answer = func(a delivery.StepAsk) delivery.Step { @@ -1094,7 +1105,11 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) { p := pullOf(repoA, 7, head1) p.Modules = []string{"web"} p.Fps = map[string]delivery.CandidateModule{"web": {FP2: "src2:" + strings.Repeat("f", 64), Manifest: web}} - v := b.judge(t, caseOf(t, p)).Verdict + cs := caseOf(t, p) + v := b.judge(t, cs).Verdict + 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) + } 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 d3db5775..70ebb497 100644 --- a/modules/mesh-delivery-checker/internal/check/judge.go +++ b/modules/mesh-delivery-checker/internal/check/judge.go @@ -269,6 +269,7 @@ func (j *Examiner) Judge(ctx context.Context, c *Case) (Outcome, error) { // Every member and level runs to its end, and every manifest the change touches is judged by the indexer's judge (ADR // 0578 §2), also after a failure: the verdict names every failure, not only the first (the operator, 2026-11-02). tests := st.judgeAll(ctx) + st.seriesChanges(ctx) // DLV-C4: named in the verdict's line, whatever the verdict if v := settled(joinFailures(tests, st.judgeManifests(ctx), st.refuseCycle(ctx))); v != nil { return st.finish(ctx, *v), nil } diff --git a/modules/mesh-delivery-checker/internal/check/line.go b/modules/mesh-delivery-checker/internal/check/line.go index 153b5b61..dc81ac58 100644 --- a/modules/mesh-delivery-checker/internal/check/line.go +++ b/modules/mesh-delivery-checker/internal/check/line.go @@ -117,18 +117,20 @@ type pieces struct { carried string // warning says that a part of the check did not run, for instance that an older build module could not refuse a compiled program; never dropped. warning string + // series names each change of a package series the pull request makes (design 32, DLV-C4); never dropped. + series string } // pullKey names a pull request of a case: `#`. func pullKey(p Pull) string { return fmt.Sprintf("%s#%d", p.Repository, p.Number) } func (p pieces) join(drop int) string { - parts := []string{p.head, p.commits, p.carried, p.warning, p.counts, p.judged} + parts := []string{p.head, p.commits, p.carried, p.warning, p.counts, p.judged, p.series} switch { case drop < 3 && p.times != "": parts = append(parts, p.times) case drop == 3 && p.shortTimes != "": - parts = []string{p.head, p.commits, p.carried, p.warning, p.shortCounts, p.judged, p.shortTimes} + parts = []string{p.head, p.commits, p.carried, p.warning, p.shortCounts, p.judged, p.series, p.shortTimes} } if p.over != "" { parts = append(parts, p.over) @@ -244,6 +246,7 @@ func carriedWords(from map[string]string, v delivery.Verdict, cs *Case, p Pull) func parts(v delivery.Verdict, cs *Case, p Pull, builds []BuildNote, slowest []string, judged map[string]string) pieces { var pc pieces pc.judged = judged[pullKey(p)] + pc.series = seriesWords(cs, p) if cs != nil { pc.warning = cs.Warning(pullKey(p)) } @@ -356,7 +359,7 @@ func Line(v delivery.Verdict, cs *Case, p Pull, builds []BuildNote, slowest []st pc := parts(v, cs, p, builds, slowest, judged) if v.State == delivery.VerdictFail { // The head gives way first to the commits and what is never dropped: its count and "and N more" stay whole. - kept := joinSet(pc.commits, pc.carried, pc.warning, pc.judged) + kept := joinSet(pc.commits, pc.carried, pc.warning, pc.judged, pc.series) pc.head = failedHead(v, MaxLine-utf8.RuneCountInString(" | "+kept)) } for drop := 0; drop <= 4; drop++ { @@ -364,13 +367,13 @@ func Line(v delivery.Verdict, cs *Case, p Pull, builds []BuildNote, slowest []st return s } } - if pc.judged != "" { + if pc.judged != "" || pc.series != "" { for _, counts := range []string{pc.shortCounts, ""} { - if s := joinSet(pc.head, pc.commits, counts, pc.judged); utf8.RuneCountInString(s) <= MaxLine { + if s := joinSet(pc.head, pc.commits, counts, pc.judged, pc.series); utf8.RuneCountInString(s) <= MaxLine { return s } } - rest := " | " + joinSet(pc.commits, pc.judged) + rest := " | " + joinSet(pc.commits, pc.judged, pc.series) room := MaxLine - utf8.RuneCountInString(rest) - 1 if head := []rune(pc.head); room > 0 && len(head) > room { return string(head[:room]) + "…" + rest @@ -378,7 +381,7 @@ func Line(v delivery.Verdict, cs *Case, p Pull, builds []BuildNote, slowest []st } if v.State == delivery.VerdictFail { // A failure's head, sized above, and the commits fit: the counts and the composition are in the report. - if s := joinSet(pc.head, pc.commits, pc.carried, pc.warning, pc.judged); utf8.RuneCountInString(s) <= MaxLine { + if s := joinSet(pc.head, pc.commits, pc.carried, pc.warning, pc.judged, pc.series); utf8.RuneCountInString(s) <= MaxLine { return s } } diff --git a/modules/mesh-delivery-checker/internal/check/series.go b/modules/mesh-delivery-checker/internal/check/series.go new file mode 100644 index 00000000..786d90bb --- /dev/null +++ b/modules/mesh-delivery-checker/internal/check/series.go @@ -0,0 +1,107 @@ +package check + +import ( + "context" + "fmt" + "slices" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// A change of a package series is named in the verdict, so the review of the change sees it (design 32 *A package, its +// series*; DLV-C4): for each module a pull request touches, every artifact both its trunk manifest and its head's +// manifest declare whose series differs, as `/ series → `. An artifact that names none +// is in catalogue.DefaultSeries. A manifest that cannot be read is said in the same words, never left out. + +// seriesChanges names, on the case per pull request, each change of an artifact's series the pull request makes. +func (st *state) seriesChanges(ctx context.Context) { + if st.j.Read == nil { + return + } + var trunk map[string]string + if st.j.Manifests != nil { + trunk = st.j.Manifests.ManifestDigests() + } + for _, p := range st.c.Pulls { + var words []string + for _, module := range p.Modules { + if trunk[module] == "" { + continue // new on this pull request: no series is changed + } + was, err := st.j.Read.Manifest(ctx, trunk[module]) + if err != nil { + words = append(words, fmt.Sprintf("%s: the trunk's series not read (%v)", module, err)) + continue + } + var now catalogue.Manifest + switch digest := p.Fps[module].Manifest; { + case digest == trunk[module]: + continue + case digest != "": + if now, err = st.j.Read.Manifest(ctx, digest); err != nil { + words = append(words, fmt.Sprintf("%s: the head's series not read (%v)", module, err)) + continue + } + case st.j.Forge != nil: + var found bool + if now, _, found, err = st.manifestAtHead(ctx, p, module); err != nil { + words = append(words, fmt.Sprintf("%s: the head's series not read (%v)", module, err)) + continue + } else if !found { + continue + } + default: + continue + } + words = append(words, seriesMoved(module, was, now)...) + } + if len(words) > 0 { + st.c.series(pullKey(p), words) + } + } +} + +// 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 { + if was.Build == nil || now.Build == nil { + return nil + } + before := map[string]string{} + for _, a := range was.Build.Artifacts { + before[a.Name] = a.SeriesOf() + } + var out []string + 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())) + } + } + slices.Sort(out) + 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) { + c.notesMu.Lock() + defer c.notesMu.Unlock() + if c.seriesOf == nil { + c.seriesOf = map[string][]string{} + } + c.seriesOf[key] = words +} + +// Series are the series changes a pull request (pullKey) makes, as the check named them; none when it makes none. +func (c *Case) Series(key string) []string { + c.notesMu.Lock() + defer c.notesMu.Unlock() + return slices.Clone(c.seriesOf[key]) +} + +// seriesWords is a pull request's series changes for its status line. +func seriesWords(c *Case, p Pull) string { + if c == nil { + return "" + } + return strings.Join(c.Series(pullKey(p)), ", ") +}