From 23a209f3d0deafdcc42be420bc581274ae213a5a Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Mon, 19 Oct 2026 21:40:26 +0000 Subject: [PATCH] Answer plans what-if as data, the document delivery-plan answers (issue 1324) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate reads a delivery's plan as data once delivery-plan goes (ADR 0450 §2). The data form is an explicit switch, `data: true`, as plans wait asks for its data: a person still gets the text. Both verbs build their document from one function, deliveryPlanDocument, so they cannot differ; delivery-plan is unchanged (ADR 0450 §8). --- cmd/mesh-controller/delivery.go | 34 ++++-- cmd/mesh-controller/plans_data_test.go | 140 +++++++++++++++++++++++++ cmd/mesh-controller/release_plan.go | 26 +++++ cmd/mesh-controller/seatverbs.go | 9 ++ internal/catalogue/verbs.go | 14 ++- 5 files changed, 209 insertions(+), 14 deletions(-) create mode 100644 cmd/mesh-controller/plans_data_test.go diff --git a/cmd/mesh-controller/delivery.go b/cmd/mesh-controller/delivery.go index c8f6cd3a..62d26956 100644 --- a/cmd/mesh-controller/delivery.go +++ b/cmd/mesh-controller/delivery.go @@ -784,6 +784,27 @@ func reachOfMembers(members []orderMember, entries []inventory.Entry, read map[s return out } +// changeMember is one change as the planner is asked about it, the same for `delivery plan` and `plans --what-if`. +func changeMember(repository, base, head string, paths, removed []string, moduleDirs *[]string) orderMember { + m := orderMember{ID: repository + "@" + head, Repository: repository, Base: base, Head: head, + Paths: paths, Removed: removed} + if moduleDirs != nil { + m.ModuleDirs, m.ModuleDirsSaid = *moduleDirs, true + } + return m +} + +// deliveryPlanDocument is the answer of `delivery plan` and of `plans --what-if … --data`: one change's +// change plan with its reach, the mesh's own part in it and whether the gate would run. One function, so +// the two verbs cannot differ (novox/hq ADR 0450 §2, issue 1324). +func deliveryPlanDocument(m orderMember, entries []inventory.Entry, read map[string][]inventory.ReadRepository, + edges []inventory.Edge, policy policyOf) map[string]any { + s := pullScope(m.pull(), entries, read, edges) + plan := changePlanOf(m.Repository, m.Base, m.Head, s.Reach, entries, policy) + return map[string]any{"plan": plan, "reach": orderReach{Moved: s.Modules, Dependents: s.Dependents, + New: s.New, Manifests: s.Manifests}, "mesh": s.Mesh, "gated": s.gated()} +} + // deliveryCommand is `delivery`, the controller's verbs for the delivery's owner: // // delivery plan --repository owner/repo --head [--base main] --paths a,b [--module-dirs …] [--removed …] @@ -855,22 +876,15 @@ func deliveryCommand(ctx context.Context, args []string) error { if *repository == "" || *head == "" && *paths == "" { return errors.New("delivery plan --repository owner/repo --head --paths a,b") } - m := orderMember{ID: *repository + "@" + *head, Repository: *repository, Base: *base, Head: *head, - Paths: splitList(*paths), Removed: splitList(*removed)} - if d := moduleDirsOf(*moduleDirs); d != nil { - m.ModuleDirs, m.ModuleDirsSaid = *d, true - } + m := changeMember(*repository, *base, *head, splitList(*paths), splitList(*removed), moduleDirsOf(*moduleDirs)) entries, read, edges, err := theGraph(ctx, inv) if err != nil { return err } - s := pullScope(m.pull(), entries, read, edges) - plan := changePlanOf(*repository, *base, *head, s.Reach, entries, func(module string) (inventory.Upgrade, bool) { + return answer(deliveryPlanDocument(m, entries, read, edges, func(module string) (inventory.Upgrade, bool) { u, err := inv.UpgradeOf(ctx, module) return u, err == nil - }) - return answer(map[string]any{"plan": plan, "reach": orderReach{Moved: s.Modules, Dependents: s.Dependents, - New: s.New, Manifests: s.Manifests}, "mesh": s.Mesh, "gated": s.gated()}) + })) case "order": if len(members) == 0 { return errors.New("delivery order --members : a group has members") diff --git a/cmd/mesh-controller/plans_data_test.go b/cmd/mesh-controller/plans_data_test.go new file mode 100644 index 00000000..27c74ed2 --- /dev/null +++ b/cmd/mesh-controller/plans_data_test.go @@ -0,0 +1,140 @@ +package main + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" + "github.com/novox/mesh-controller/internal/inventory" +) + +// flagsOf reads a command line's flags (and its first positional after --what-if) into a map. +func flagsOf(argv []string) map[string]string { + out := map[string]string{} + for i := 0; i < len(argv); i++ { + if !strings.HasPrefix(argv[i], "--") { + continue + } + name := strings.TrimPrefix(argv[i], "--") + if i+1 < len(argv) && !strings.HasPrefix(argv[i+1], "--") { + out[name] = argv[i+1] + i++ + } else { + out[name] = "true" + } + } + return out +} + +// **The plan of a change as data from `plans`** (novox/hq ADR 0450 §2, issue 1324 AC1): asked for one change +// both ways, `plans` with data and `delivery-plan` answer the same plan in every field. +func TestPlansWhatIfAsDataAnswersWhatDeliveryPlanAnswers(t *testing.T) { + change := map[string]any{"repository": "novox/mesh-catalog", "paths": "modules/gitea/index.ts,modules/gitea/old.txt", + "head": "abc123", "base": "main", "module-dirs": "modules/gitea", "removed": "modules/gitea/old.txt"} + with := func(extra map[string]any) map[string]any { + out := map[string]any{} + for k, v := range change { + out[k] = v + } + for k, v := range extra { + out[k] = v + } + return out + } + plansArgv, err := argvFor("plans", with(map[string]any{"data": "true"})) + if err != nil { + t.Fatal(err) + } + deliveryArgv, err := argvFor("delivery-plan", change) + if err != nil { + t.Fatal(err) + } + if plansArgv[0] != "plans" || !slicesHas(plansArgv, "--data") || !slicesHas(plansArgv, "--what-if") { + t.Fatalf("plans with data became %q", strings.Join(plansArgv, " ")) + } + p, d := flagsOf(plansArgv), flagsOf(deliveryArgv) + for _, name := range []string{"paths", "head", "base", "module-dirs", "removed"} { + if p[name] == "" || p[name] != d[name] { + t.Errorf("--%s: plans says %q, delivery-plan %q", name, p[name], d[name]) + } + } + if p["what-if"] != d["repository"] { + t.Errorf("repository: plans says %q, delivery-plan %q", p["what-if"], d["repository"]) + } + + // Both commands build their document from the one function, from the same member. + const src = "http://forge.internal:20000/novox/mesh-catalog.git" + gitea := fromRepo("gitea", src, "modules/gitea") + gitea.Source.BuiltFrom = "old" + gitea.On = []string{"anchor", "laptop"} + entries := []inventory.Entry{gitea} + policy := func(string) (inventory.Upgrade, bool) { return inventory.Upgrade{RollOut: true}, true } + member := func(f map[string]string, repository string) orderMember { + return changeMember(repository, f["base"], f["head"], splitList(f["paths"]), splitList(f["removed"]), + moduleDirsOf(f["module-dirs"])) + } + a := deliveryPlanDocument(member(p, p["what-if"]), entries, nil, nil, policy) + b := deliveryPlanDocument(member(d, d["repository"]), entries, nil, nil, policy) + ja, _ := json.Marshal(a) + jb, _ := json.Marshal(b) + if !reflect.DeepEqual(ja, jb) { + t.Errorf("the two plans differ:\n%s\n%s", ja, jb) + } + plan := a["plan"].(interface{}) + raw, _ := json.Marshal(plan) + var fields map[string]any + _ = json.Unmarshal(raw, &fields) + for _, want := range []string{"moved", "tiers", "machines", "summary", "class", "head", "base", "repository"} { + if _, ok := fields[want]; !ok { + t.Errorf("the plan has no %q: %s", want, raw) + } + } + if fields["head"] != "abc123" || fields["base"] != "main" { + t.Errorf("head and base are %v and %v", fields["head"], fields["base"]) + } +} + +// Without the data form the text answer is unchanged (AC2): no data flag, and the data-only arguments are not passed. +func TestPlansWhatIfWithoutDataIsUnchanged(t *testing.T) { + argv, err := argvFor("plans", map[string]any{"repository": "novox/x", "paths": "a,b"}) + if err != nil { + t.Fatal(err) + } + if got := strings.Join(argv, " "); got != "plans --what-if novox/x --paths a,b" { + t.Errorf("the text what-if reads %q", got) + } + if _, err := argvFor("plans", map[string]any{"repository": "novox/x", "paths": "a", "head": "h"}); err == nil { + t.Error("a head without data was passed over, not refused") + } + argv, _ = argvFor("plans", map[string]any{"repository": "novox/x", "paths": "a", "data": "false"}) + if got := strings.Join(argv, " "); got != "plans --what-if novox/x --paths a" { + t.Errorf("data false reads %q", got) + } +} + +// delivery-plan stays and answers as before (AC3, ADR 0450 §8). +func TestDeliveryPlanStaysAsItWas(t *testing.T) { + argv, err := argvFor("delivery-plan", map[string]any{"repository": "novox/x", "paths": "a", "head": "h"}) + if err != nil { + t.Fatal(err) + } + if got := strings.Join(argv, " "); got != "delivery plan --repository novox/x --paths a --head h" { + t.Errorf("delivery-plan reads %q", got) + } + if _, ok := catalogueVerb("delivery-plan"); !ok { + t.Error("delivery-plan is gone") + } +} + +func catalogueVerb(name string) (catalogue.Verb, bool) { return controllerVerb(name) } + +func slicesHas(s []string, v string) bool { + for _, x := range s { + if x == v { + return true + } + } + return false +} diff --git a/cmd/mesh-controller/release_plan.go b/cmd/mesh-controller/release_plan.go index e9f1b7bb..e6364de5 100644 --- a/cmd/mesh-controller/release_plan.go +++ b/cmd/mesh-controller/release_plan.go @@ -6,6 +6,7 @@ import ( "errors" "flag" "fmt" + "os" "slices" "sort" "strings" @@ -2037,6 +2038,12 @@ func plansCommand(ctx context.Context, args []string) error { modules := set.String("modules", "", "or the modules it would change, comma-separated") moduleDirs := set.String("module-dirs", "", "with --paths: the directories holding a module.json at the commit, "+ "comma-separated, as the forge's announcer says them (novox/hq issue 278)") + // The what-if as data, the document `delivery plan` answers (novox/hq ADR 0450 §2, issue 1324). + data := set.Bool("data", false, "with --what-if: answer the change plan as data, the document `delivery plan` answers; "+ + "without it the answer is the text a person reads") + head := set.String("head", "", "with --data: the commit at hand") + base := set.String("base", "main", "with --data: the branch it merges into") + removed := set.String("removed", "", "with --data: the files among --paths it deletes, comma-separated") // Following a plan (novox/hq issue 229): `plans wait `. until := set.String("until", "", "with wait: what to wait for — moved (any change), tier (its next tier) or done (its end)") bound := set.String("bound", "", "with wait: how long to wait at most, as 15s or seconds (default and at most the "+ @@ -2152,6 +2159,25 @@ func plansCommand(ctx context.Context, args []string) error { } return nil } + if *data && *whatIf == "" { + return errors.New("--data answers a what-if: give --what-if owner/repository") + } + if *whatIf != "" && *data { + if *paths == "" { + return errors.New("plans --what-if --data: the change plan is of the files a change touches; give --paths") + } + entries, read, edges, err := theGraph(ctx, inv) + if err != nil { + return err + } + m := changeMember(*whatIf, *base, *head, splitList(*paths), splitList(*removed), moduleDirsOf(*moduleDirs)) + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(deliveryPlanDocument(m, entries, read, edges, func(module string) (inventory.Upgrade, bool) { + u, err := inv.UpgradeOf(ctx, module) + return u, err == nil + })) + } if *whatIf != "" { return planWhatIf(ctx, inv, *whatIf, splitList(*paths), splitList(*modules), moduleDirsOf(*moduleDirs)) } diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index da238e3f..5decbfef 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -352,6 +352,15 @@ func (a *verbArguments) commandLine() ([]string, error) { if d := str("module-dirs"); d != "" { argv = append(argv, "--module-dirs", d) } + // The plan as data, the document delivery-plan answers (novox/hq ADR 0450 §2, issue 1324). + if str("data") == "true" { + argv = append(argv, "--data") + for _, flag := range []string{"head", "base", "removed"} { + if v := str(flag); v != "" { + argv = append(argv, "--"+flag, v) + } + } + } return argv, nil } for _, act := range []string{"stop", "close", "retry", "go"} { diff --git a/internal/catalogue/verbs.go b/internal/catalogue/verbs.go index aa470807..17e417a0 100644 --- a/internal/catalogue/verbs.go +++ b/internal/catalogue/verbs.go @@ -135,10 +135,16 @@ var ControllerVerbs = []Verb{ "modules": "with repository: or the modules it would change, comma-separated", "module-dirs": "with repository and paths: the directories holding a module.json at the commit, comma-separated, " + "as the forge's announcer says them (novox/hq issue 278); without it, a directory is a module only when the paths hold its manifest", - "limit": "how many plans to list (default 10); only when listing", - "why": "with stop, close or go: why it is done by hand — required, and recorded in the hand-act log (novox/hq to-be 45 §7)", - "cause": "with stop, close or go: the cause in a word, or a condition's kind (optional)", - }, nil)}, + "data": "with repository and paths: true answers the change plan as data, the document delivery-plan answers " + + "(moved, dependents, new, tiers, machines with what each receives and waits for, steps, summary, class, head, base); " + + "without it the answer is text (novox/hq ADR 0450 §2)", + "head": "with data: the commit at hand", + "base": "with data: the branch it merges into (default main)", + "removed": "with data: the files among paths the change deletes, comma-separated", + "limit": "how many plans to list (default 10); only when listing", + "why": "with stop, close or go: why it is done by hand — required, and recorded in the hand-act log (novox/hq to-be 45 §7)", + "cause": "with stop, close or go: the cause in a word, or a condition's kind (optional)", + }, nil, "data")}, // The verbs the delivery's owner asks with (novox/hq ADR 0239): the planner's answer, a group's order and // composed check, and the start and end of a walk. Callable by a person too; mesh-delivery is who needs them. {Name: "delivery-plan", Description: "The delivery plan of a diffset, as the planner computes it and keeping " +