A merge on the operator's Approve into a repository the mesh builds nothing from asks the first build of the modules it holds (issue 1146, AC6, AC7)

This commit is contained in:
2026-10-19 00:11:52 +02:00
parent b1cfb85f86
commit 19f15d8ea0
7 changed files with 280 additions and 3 deletions
+5
View File
@@ -176,6 +176,11 @@ func (f following) hearMerge(ctx context.Context, m link.SourceMoved, now time.T
repository, m.Base, m.Commit, already)
return nil
}
if already == 0 {
if done, err := f.firstBuilds(ctx, m, entries, read, now); err != nil || done {
return err
}
}
fmt.Print(nothingReadsIt(repository, m))
return nil
}
+98
View File
@@ -0,0 +1,98 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// firstBuildWait is how long after the forge made a merge the controller keeps hearing it again for mesh-delivery's word
// on it, which may be on its way when the forge's announcement is heard (novox/hq ADR 0432).
const firstBuildWait = 2 * time.Minute
// firstBuilds asks the first build of every module a merge adds to a repository no registered module is built from, when
// the merge was announced as made on the operator's Approve of that head (novox/hq ADR 0432, issue 1146): the modules'
// directories are the ones the announcement says hold a module.json at the merge commit, as touchedBy reads them. The
// merge is kept, so the build's outcome is judged by the rule that registers it (mayRegisterFrom, approvedMove). Without
// the Approve nothing is asked, and it says why. Answers whether the merge was dealt with here.
func (f following) firstBuilds(ctx context.Context, m link.SourceMoved, entries []inventory.Entry,
read map[string][]inventory.ReadRepository, now time.Time) (bool, error) {
inv := f.open.inventory
repository := m.Owner + "/" + m.Repo
added, why, err := firstBuildsOf(ctx, inv, m, entries, read, now)
switch {
case errors.Is(err, errFirstNotApproved):
fmt.Printf("%s merged into %s (%.8s) adds %s, and no build is asked: %v\n", repository, m.Base, m.Commit,
strings.Join(addedDirs(m, entries, read), ", "), err)
return true, nil
case err != nil:
return true, notNow(err)
case len(added) == 0:
return false, nil
}
merged, err := time.Parse(time.RFC3339Nano, m.MergedAt)
if err != nil {
merged = now
}
event, err := json.Marshal(keptMerge{SourceMoved: m})
if err != nil {
return true, err
}
if _, err := inv.AddMerge(ctx, inventory.BatchedMerge{Repository: repository, Branch: m.Base, Commit: m.Commit,
Merged: merged.UTC(), Heard: now, Event: event}); err != nil {
return true, notNow(err)
}
fmt.Printf("%s merged into %s (%.8s) on the operator's Approve (%s): the mesh builds nothing from it yet, so the "+
"first build of %s is asked\n", repository, m.Base, m.Commit, why, strings.Join(added, ", "))
askBuilds(ctx, inv, m, buildSource{Repository: repository, Seat: gitSeat}, added)
return true, nil
}
// errFirstNotApproved says a merge into a repository the mesh builds nothing from is not one the operator approved.
var errFirstNotApproved = errors.New("it is no merge made on the operator's Approve of its head, so a hand `build` " +
"registers a module from a repository the mesh does not build yet")
// addedDirs are the module directories the merge adds to a repository no registered module is built from, none where
// the merge is not into the trunk or some module is built from the repository already.
func addedDirs(m link.SourceMoved, entries []inventory.Entry, read map[string][]inventory.ReadRepository) []string {
if m.Base != "main" {
return nil
}
for _, e := range entries {
if !e.Provided && sameRepository(e.Source.Repository, m) {
return nil
}
}
_, added, _ := touchedBy(nil, entries, m, read)
return added
}
// firstBuildsOf is the directories whose first build a merge asks, and the warrant it was made on; none when the merge
// is no first merge into a repository the mesh builds nothing from. errFirstNotApproved when it is, and the operator's
// Approve of its head is not on record, a try-again error while mesh-delivery's word may still be on its way.
func firstBuildsOf(ctx context.Context, inv catalogueReader, m link.SourceMoved, entries []inventory.Entry,
read map[string][]inventory.ReadRepository, now time.Time) ([]string, string, error) {
added := addedDirs(m, entries, read)
if len(added) == 0 {
return nil, "", nil
}
why, err := approvedAnnounced(ctx, inv, m.Owner, m.Repo, keptMerge{SourceMoved: m})
if err == nil {
return added, why, nil
}
merged, perr := time.Parse(time.RFC3339Nano, m.MergedAt)
if errors.Is(err, errNotAnnounced) && perr == nil && now.Sub(merged) < firstBuildWait {
return nil, "", fmt.Errorf("%w: %w", link.ErrTryAgain, err)
}
var nope error = fmt.Errorf("%w (%v)", errFirstNotApproved, err)
if inventory.Unreachable(err) {
nope = err
}
return nil, "", nope
}
@@ -0,0 +1,81 @@
//go:build integration
package main
import (
"context"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// 1146/AC7: mesh-delivery #1 merged on the operator's Approve into a repository the mesh builds nothing from. The forge's
// announcement, heard as the controller hears it (SourceMoved, hearMerge), asks the first build of the module the merge
// commit holds; the build's outcome registers it; and the module is in the catalogue. Without the Approve nothing is
// asked. Store-backed: skips without one.
func TestAMergeOnTheApproveIntoANewRepositoryAsksTheFirstBuildAndItsOutcomeRegistersTheModule(t *testing.T) {
const head, commit = "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", "0177ea7a00000000"
for _, approved := range []bool{true, false} {
open := aMesh(t)
ctx := t.Context()
inv := open.inventory
asked := asksWithPaths(t)
a := aDeliveryMergeOf(t, head, map[string]string{"novox/mesh-delivery@a1a1a1a1a1a1": head})
a.merge.Number = 1
wasKept, wasAsk := keptDeliveryMerge, routerAskOf
keptDeliveryMerge = func(context.Context, string, string, int, string) (deliveryMerge, bool, error) {
return a.merge, approved, nil
}
routerAskOf = func(context.Context, catalogueReader, string) (*asks.Ask, *asks.Warrant, error) {
return &a.ask, &a.warrant, nil
}
t.Cleanup(func() { keptDeliveryMerge, routerAskOf = wasKept, wasAsk })
m := link.SourceMoved{Owner: "novox", Repo: "mesh-delivery", Base: "main", HeadSHA: head, Commit: commit, Number: 1,
MergedAt: time.Now().Add(-time.Hour).UTC().Format(time.RFC3339Nano), Paths: []string{"module.json", "main.go"},
ModuleDirs: []string{"."}, ModuleDirsSaid: true}
if err := (following{open: open}).SourceMoved(ctx, m); err != nil {
t.Fatalf("approved %v: %v", approved, err)
}
if !approved {
if len(*asked) != 0 {
t.Fatalf("1146/AC7: a merge with no Approve asked %v", *asked)
}
continue
}
want := [3]string{"novox/mesh-delivery", "", "main"}
if len(*asked) != 1 || (*asked)[0] != want {
t.Fatalf("1146/AC7: the merge asked %v, want %v", *asked, want)
}
build := onTrunk("b-", "novox/mesh-delivery", "git", "", map[string]any{"module": "mesh-delivery-view", "version": "1"})
build.Commit = commit
if _, _, err := takeIn(ctx, inv, build); err != nil {
t.Fatalf("1146/AC7: the outcome of the first build was refused: %v", err)
}
src, err := inv.SourceOf(ctx, "mesh-delivery-view")
if err != nil || src.Repository != "novox/mesh-delivery" {
t.Fatalf("1146/AC7: the module is built from %q (%v)", src.Repository, err)
}
if _, known := inventoryHolds(t, inv, "mesh-delivery-view"); !known {
t.Fatal("1146/AC7: the module is not in the catalogue")
}
}
}
func inventoryHolds(t *testing.T, inv *inventory.Inventory, module string) (inventory.Entry, bool) {
t.Helper()
entries, err := inv.Catalogued(t.Context())
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
if e.Manifest.Module == module {
return e, true
}
}
return inventory.Entry{}, false
}
+77
View File
@@ -0,0 +1,77 @@
package main
import (
"context"
"errors"
"slices"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// 1146/AC6: the first merge into a repository no registered module is built from asks the first build of every module
// the merge commit holds when it was made on the operator's Approve of that head, and asks nothing without it.
func TestAFirstMergeIntoANewRepositoryAsksItsModulesBuildsOnlyOnTheApprove(t *testing.T) {
const head = "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
now := time.Date(2026, 10, 19, 0, 6, 0, 0, time.UTC)
mergedAt := now.Add(-time.Hour).Format(time.RFC3339Nano)
heard := link.SourceMoved{Owner: "novox", Repo: "mesh-delivery", Base: "main", HeadSHA: head, Commit: "0177ea7a",
Number: 1, MergedAt: mergedAt, ModuleDirsSaid: true, ModuleDirs: []string{"watchman", "view"},
Paths: []string{"view/module.json", "watchman/module.json", "README.md"}}
built := inventory.Entry{Source: inventory.Source{Repository: "novox/mesh-catalog", Seat: "git"}}
for _, c := range []struct {
what string
change func(*anApprovedMerge, *link.SourceMoved, *[]inventory.Entry)
unkept bool
dirs []string
refused bool
retry bool
}{
{what: "the Approve of the merged head", dirs: []string{"view", "watchman"}},
{what: "a Decline", refused: true, change: func(a *anApprovedMerge, _ *link.SourceMoved, _ *[]inventory.Entry) {
a.warrant.Option, a.warrant.Label, a.merge.Option = "decline", "Decline", "decline"
}},
{what: "no word of mesh-delivery on a merge made long ago", unkept: true, refused: true},
{what: "no word yet on a merge just made", unkept: true, retry: true, change: func(_ *anApprovedMerge, m *link.SourceMoved, _ *[]inventory.Entry) {
m.MergedAt = now.Add(-time.Second).Format(time.RFC3339Nano)
}},
{what: "a module already built from the repository", change: func(_ *anApprovedMerge, _ *link.SourceMoved, e *[]inventory.Entry) {
*e = append(*e, inventory.Entry{Source: inventory.Source{Repository: "novox/mesh-delivery", Seat: "git"}})
}},
{what: "a merge into another branch", change: func(_ *anApprovedMerge, m *link.SourceMoved, _ *[]inventory.Entry) { m.Base = "feat" }},
{what: "a merge that holds no module.json", change: func(_ *anApprovedMerge, m *link.SourceMoved, _ *[]inventory.Entry) {
m.ModuleDirs, m.Paths = nil, []string{"README.md"}
}},
} {
t.Run(c.what, func(t *testing.T) {
a := aDeliveryMergeOf(t, head, map[string]string{"novox/mesh-delivery@a1a1a1a1a1a1": head})
a.merge.Number = 1
m, entries := heard, []inventory.Entry{built}
if c.change != nil {
c.change(&a, &m, &entries)
}
wasKept, wasAsk := keptDeliveryMerge, routerAskOf
t.Cleanup(func() { keptDeliveryMerge, routerAskOf = wasKept, wasAsk })
keptDeliveryMerge = func(context.Context, string, string, int, string) (deliveryMerge, bool, error) {
return a.merge, !c.unkept, nil
}
routerAskOf = func(context.Context, catalogueReader, string) (*asks.Ask, *asks.Warrant, error) {
return &a.ask, &a.warrant, nil
}
dirs, _, err := firstBuildsOf(t.Context(), nil, m, entries, nil, now)
switch {
case c.retry && !errors.Is(err, link.ErrTryAgain):
t.Fatalf("1146/AC6: %s: want a try-again, got %v", c.what, err)
case c.refused && !errors.Is(err, errFirstNotApproved):
t.Fatalf("1146/AC6: %s: want the Approve missing, got %v (dirs %v)", c.what, err, dirs)
case !c.refused && !c.retry && err != nil:
t.Fatalf("1146/AC6: %s: %v", c.what, err)
case !slices.Equal(dirs, c.dirs):
t.Fatalf("1146/AC6: %s: first builds asked of %v, want %v", c.what, dirs, c.dirs)
}
})
}
}
+12 -2
View File
@@ -236,7 +236,17 @@ func approvedMove(ctx context.Context, inv registrations, forms *sourceForms, bu
if !known {
return "", fmt.Errorf("the controller kept no merge of %s/%s at %.8s", owner, name, r.Commit)
}
k := announcedOf(merged)
return approvedAnnounced(ctx, inv, owner, name, announcedOf(merged))
}
// errNotAnnounced says mesh-delivery's word on a merge is not kept (yet): the merge may have been made by someone else,
// or its word may still be on its way.
var errNotAnnounced = errors.New("mesh-delivery announced no such merge")
// approvedAnnounced is the warrant of the merge the forge announced as k: mesh-delivery announced it, and the ask it made
// it on holds the operator's Approve of that head. Why it holds, or why not. Shared by the approved move of a known
// module and the first build of a new repository's modules (ADR 0420 §7, ADR 0432).
func approvedAnnounced(ctx context.Context, inv catalogueReader, owner, name string, k keptMerge) (string, error) {
if k.Number <= 0 || k.HeadSHA == "" {
return "", errors.New("the forge's announcement of the merge names no pull request or head")
}
@@ -245,7 +255,7 @@ func approvedMove(ctx context.Context, inv registrations, forms *sourceForms, bu
return "", err
}
if !kept {
return "", fmt.Errorf("mesh-delivery announced no merge of %s/%s#%d at %.8s", owner, name, k.Number, k.HeadSHA)
return "", fmt.Errorf("%w: %s/%s#%d at %.8s", errNotAnnounced, owner, name, k.Number, k.HeadSHA)
}
ask, w, err := routerAskOf(ctx, inv, d.AskID)
if err != nil {
+6 -1
View File
@@ -406,7 +406,12 @@ func askNewModules(ctx context.Context, inv *inventory.Inventory, m link.SourceM
if len(added) == 0 || len(from) == 0 {
return nil
}
source := buildSource{Repository: from[0].Source.Repository, Seat: from[0].Source.Seat}
return askBuilds(ctx, inv, m, buildSource{Repository: from[0].Source.Repository, Seat: from[0].Source.Seat}, added)
}
// askBuilds asks the first build of each directory of a merge's repository, as askNewModules does, from the source given.
func askBuilds(ctx context.Context, inv *inventory.Inventory, m link.SourceMoved, source buildSource,
added []string) []string {
var asked []string
for _, dir := range added {
path := dir