Merge branch 'feat/r4g-catalog' into feat/r4g
This commit is contained in:
@@ -1,3 +1,7 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/config
|
||||
|
||||
go 1.22
|
||||
|
||||
require checks/trunk v0.0.0
|
||||
|
||||
replace checks/trunk => ../trunk
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"checks/trunk"
|
||||
)
|
||||
|
||||
// The script's comparison of this list with the trunk's (novox/hq ADR 0529 clause 2): the list of modules not yet moved gains none
|
||||
// the trunk's copy at the merge base does not hold. The catalogue itself is judged against the list by
|
||||
// TestTheCatalogueSaysConfigInEveryMovedModule. Skipped, and said, where no trunk is beside.
|
||||
func TestTheUnmovedListHoldsNoModuleTheTrunksDoesNot(t *testing.T) {
|
||||
path, ok := trunk.Copy(t, "checks/config/unmoved")
|
||||
found, err := Check(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unmoved, err := Unmoved("unmoved")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := seed
|
||||
if ok {
|
||||
if base, err = Unmoved(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
_, _, added := Judge(found, unmoved, base)
|
||||
for _, m := range added {
|
||||
t.Errorf("checks/config/unmoved gains %s, which the trunk's copy does not hold: the list only shrinks", m)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module checks/health
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9
|
||||
@@ -0,0 +1,4 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012082113-9747f3e1b1ac h1:jnZ/n+fFMo7VQPNNXK1gjStHIJPw3SPbzPxsf/PpZqw=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012082113-9747f3e1b1ac/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9 h1:Ufmd2cYEXICQ817IGflccP9bexABE4WhIO49HPbaoSo=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
@@ -0,0 +1,70 @@
|
||||
// Package health is the catalogue's count of long-running resources that do not say how they are ready (novox/hq ADR 0240 rule
|
||||
// 8), kept in health-undeclared and judged by a test over the real tree (ADR 0529 clauses 2 and 4): it replaces the step of
|
||||
// merge-check.sh that read it out of the controller's `module check`. Every manifest is read through mesh-sdk's catalogue
|
||||
// package, the one the controller counts with.
|
||||
package health
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// Count is the long-running resources without `health` of every module under root/modules.
|
||||
func Count(root string) (int, error) {
|
||||
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
sort.Strings(manifests)
|
||||
if len(manifests) == 0 {
|
||||
return 0, fmt.Errorf("no manifest under %s: the count would be zero for any catalogue", filepath.Join(root, "modules"))
|
||||
}
|
||||
n := 0
|
||||
for _, path := range manifests {
|
||||
m, err := catalogue.ReadTreeManifest(root, filepath.Dir(path))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
n += len(catalogue.Undeclared(m))
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Kept is the number the file keeps: the first number of its first line.
|
||||
func Kept(path string) (int, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
first, _, _ := strings.Cut(string(raw), "\n")
|
||||
digits := ""
|
||||
for _, r := range strings.TrimSpace(first) {
|
||||
if r < '0' || r > '9' {
|
||||
break
|
||||
}
|
||||
digits += string(r)
|
||||
}
|
||||
n, err := strconv.Atoi(digits)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s does not begin with the number it keeps: %q", path, first)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Judge says what is wrong with a count against the kept number; empty when they agree. It only goes down: a rise is refused,
|
||||
// and a fall asks the new number written in health-undeclared, so the count cannot rise again.
|
||||
func Judge(kept, counted int) string {
|
||||
switch {
|
||||
case counted > kept:
|
||||
return fmt.Sprintf("the long-running resources without health rose from %d to %d: declare how each new one is ready (ADR 0240 rule 8)", kept, counted)
|
||||
case counted < kept:
|
||||
return fmt.Sprintf("the long-running resources without health fell from %d to %d: write %d in health-undeclared, so the count cannot rise again", kept, counted, counted)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func aCatalogue(t *testing.T, manifests map[string]string) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for module, body := range manifests {
|
||||
dir := filepath.Join(root, "modules", module)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
// A service that stays up and does not say how it is ready is counted; one that says, and a file, are not.
|
||||
func TestOnlyALongRunningResourceWithoutHealthIsCounted(t *testing.T) {
|
||||
root := aCatalogue(t, map[string]string{
|
||||
"quiet": `{"module":"quiet","resources":[{"id":"svc","type":"service","unit":"quiet.service","state":"running"}]}`,
|
||||
"says": `{"module":"says","resources":[{"id":"svc","type":"service","unit":"says.service","state":"running",` +
|
||||
`"health":{"kind":"unit"}}]}`,
|
||||
"file": `{"module":"file","resources":[{"id":"cfg","type":"file","path":"/etc/file.conf","content":"x"}]}`,
|
||||
})
|
||||
counted, err := Count(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if counted != 1 {
|
||||
t.Fatalf("counted %d, wanted the one quiet service", counted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheCountMayOnlyGoDown(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
kept, counted int
|
||||
says string
|
||||
}{
|
||||
{58, 58, ""},
|
||||
{58, 59, "rose from 58 to 59"},
|
||||
{58, 57, "fell from 58 to 57"},
|
||||
} {
|
||||
got := Judge(c.kept, c.counted)
|
||||
if (c.says == "") != (got == "") || !strings.Contains(got, c.says) {
|
||||
t.Errorf("kept %d counted %d: %q, wanted %q", c.kept, c.counted, got, c.says)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheKeptCountIsTheFirstNumberOfTheFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "health-undeclared")
|
||||
if err := os.WriteFile(path, []byte("58\nThe long-running resources ...\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, err := Kept(path); err != nil || n != 58 {
|
||||
t.Fatalf("%d %v", n, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("many\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Kept(path); err == nil {
|
||||
t.Fatal("a file with no number gave a count")
|
||||
}
|
||||
}
|
||||
|
||||
// The script's `module check` run over the real tree (novox/hq ADR 0240 rule 8, ADR 0529 clause 2 and 4): the long-running
|
||||
// resources that do not say how they are ready, counted as the controller counts them, are the number health-undeclared keeps.
|
||||
func TestTheCatalogueSaysHowItsLongRunningResourcesAreReady(t *testing.T) {
|
||||
kept, err := Kept(filepath.Join("..", "..", "health-undeclared"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
counted, err := Count(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if say := Judge(kept, counted); say != "" {
|
||||
t.Fatal(say)
|
||||
}
|
||||
}
|
||||
@@ -61,3 +61,15 @@ func TestAModuleThatIsNotTypeScriptIsNotJudged(t *testing.T) {
|
||||
t.Fatalf("%v %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The script's run of this check over the real tree (novox/hq ADR 0529 clause 2): every TypeScript module that names packages
|
||||
// beyond the SDK commits its lock.
|
||||
func TestTheCatalogueCommitsTheLocksItNeeds(t *testing.T) {
|
||||
found, err := Check("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, f := range found {
|
||||
t.Errorf("%s names packages beyond the SDK (%v) and has no package-lock.json: commit one made by the build module's toolchain (novox/hq ADR 0469 §5)", f.Module, f.Packages)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module checks/noscripts
|
||||
|
||||
go 1.22
|
||||
@@ -0,0 +1,19 @@
|
||||
package noscripts
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The catalogue's checks are Go tests (novox/hq ADR 0529 clause 5, ADR 0445): the checker runs every nested Go module's tests by
|
||||
// the language's convention and no script at the root says what else to run. A script back at the root would be a check nobody
|
||||
// runs (ADR 0503 §3: since R4e the checker has not run them), so it is refused.
|
||||
func TestNoScriptOfTheOldCheckStandsAtTheRoot(t *testing.T) {
|
||||
for _, name := range []string{"merge-check.sh", "merge-check-typescript.sh", "check-nested-modules.sh"} {
|
||||
if _, err := os.Stat(filepath.Join("..", "..", name)); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Errorf("%s stands at the catalogue's root (%v): what it ran is a Go test in checks/ or a step of the build module", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/owned
|
||||
|
||||
go 1.22
|
||||
|
||||
require checks/trunk v0.0.0
|
||||
|
||||
replace checks/trunk => ../trunk
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"checks/trunk"
|
||||
)
|
||||
|
||||
// The script's run of this check over the real tree (novox/hq ADR 0529 clause 2): every rule holds.
|
||||
func TestTheCatalogueKeepsEveryRuleOfOwnedPaths(t *testing.T) {
|
||||
found, err := Check("../..", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, f := range found {
|
||||
t.Errorf("%s: %s: %s (rule %d, novox/hq ADR 0422 §8)", f.Module, f.ID, f.Why, f.Rule)
|
||||
}
|
||||
}
|
||||
|
||||
// And the list of system directories' known exceptions holds no line the trunk's copy at the merge base does not (rule 5). A trunk
|
||||
// with none had no list: its first copy. Skipped, and said, where no trunk is beside.
|
||||
func TestTheSystemListHoldsNoLineTheTrunksDoesNot(t *testing.T) {
|
||||
path, ok := trunk.Copy(t, "checks/owned/system-known")
|
||||
var base []string
|
||||
if ok {
|
||||
lines, err := readLines(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base = append([]string{}, lines...)
|
||||
}
|
||||
found, err := Check("../..", base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, f := range found {
|
||||
if f.Rule == 5 {
|
||||
t.Errorf("%s: %s: %s (rule %d, issue 1841)", f.Module, f.ID, f.Why, f.Rule)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,3 +114,10 @@ func TestOnlyCoveredModulesAreRead(t *testing.T) {
|
||||
t.Errorf("got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The script's run of this check over the real tree (novox/hq ADR 0529 clause 2): no node name of this mesh in a covered module.
|
||||
func TestTheCoveredModulesNameNoNode(t *testing.T) {
|
||||
if code := check("../.."); code != 0 {
|
||||
t.Fatalf("the check of the real catalogue exits %d (see what it printed above)", code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/paths
|
||||
|
||||
go 1.22
|
||||
|
||||
require checks/trunk v0.0.0
|
||||
|
||||
replace checks/trunk => ../trunk
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"checks/trunk"
|
||||
)
|
||||
|
||||
// The script's run of this check over the real tree (novox/hq ADR 0529 clause 2): no module names another's state on its machine,
|
||||
// and the known list holds only what still occurs.
|
||||
func TestNoModuleNamesAnothersState(t *testing.T) {
|
||||
root := filepath.Join("..", "..")
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shared, _, err := list("shared")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
known, _, err := list("known")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
refused, gone := Judge(found, shared, known)
|
||||
for _, f := range refused {
|
||||
t.Error(f)
|
||||
}
|
||||
for _, k := range gone {
|
||||
t.Errorf("%q is in checks/paths/known and no longer occurs: take it out, so it cannot come back", k)
|
||||
}
|
||||
}
|
||||
|
||||
// And neither list gains a line the trunk's copies at the merge base do not hold, unless the line says why after `# allow:`. Skipped,
|
||||
// and said, where no trunk is beside.
|
||||
func TestTheListsOfPathsHoldNoLineTheTrunksDoNot(t *testing.T) {
|
||||
sharedPath, okShared := trunk.Copy(t, "checks/paths/shared")
|
||||
knownPath, okKnown := trunk.Copy(t, "checks/paths/known")
|
||||
if !okShared || !okKnown {
|
||||
t.Log("the trunk holds not both lists: their first copies, to which nothing was added")
|
||||
return
|
||||
}
|
||||
shared, sharedWhy, err := list("shared")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
known, knownWhy, err := list("known")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
baseShared, _, err := list(sharedPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
baseKnown, _, err := list(knownPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, g := range append(Grown("shared", shared, sharedWhy, baseShared), Grown("known", known, knownWhy, baseKnown)...) {
|
||||
t.Error(g)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
# Paths every program on a machine reads as the machine's own, not as a message from the module that
|
||||
# writes them (checks/paths, novox/hq ADR 0260). One per line, with why.
|
||||
/etc/hosts # every resolver on the machine reads it; the hostname module writes it for all of them
|
||||
/var/lib/mesh-build-node-cache # allow: the Go build module gives its integration level the node's installed Node toolchains read-only, so the Node build module's tests build offline (issue 1891, ADR 0533)
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/predefined
|
||||
|
||||
go 1.22
|
||||
|
||||
require checks/trunk v0.0.0
|
||||
|
||||
replace checks/trunk => ../trunk
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"checks/trunk"
|
||||
)
|
||||
|
||||
// The script's run of this check over the real tree (novox/hq ADR 0529 clause 2): predefined.json removes no key a module still
|
||||
// names, judged against the trunk's copy at the merge base. A trunk with none had no keys; with no trunk to read, nothing can be
|
||||
// seen removed, and the test says so.
|
||||
func TestThePredefinedListRemovesNoKeyAModuleStillNames(t *testing.T) {
|
||||
tree, err := Keys(filepath.Join("..", "..", File))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path, ok := trunk.Copy(t, File)
|
||||
if !ok {
|
||||
t.Log("no trunk copy of " + File + ": no removal can be seen")
|
||||
return
|
||||
}
|
||||
base, err := Keys(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
named, err := Named(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
removed := Removed(base, tree, named)
|
||||
keys := make([]string, 0, len(removed))
|
||||
for k := range removed {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
t.Errorf("%q is removed from %s and still named by %v", k, File, removed[k])
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/secrets
|
||||
|
||||
go 1.22
|
||||
|
||||
require checks/trunk v0.0.0
|
||||
|
||||
replace checks/trunk => ../trunk
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"checks/trunk"
|
||||
)
|
||||
|
||||
// The script's run of this check over the real tree (novox/hq ADR 0529 clause 2): the known list gains no line the trunk's copy
|
||||
// at the merge base does not hold. Without a trunk copy the lines in seed are the trunk's.
|
||||
func TestTheKnownListHoldsNoLineTheTrunksDoesNot(t *testing.T) {
|
||||
known, err := Known("known")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := seed
|
||||
if path, ok := trunk.Copy(t, "checks/secrets/known"); ok {
|
||||
if base, err = Known(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, line := range Added(known, base) {
|
||||
t.Errorf("checks/secrets/known gains %q, which the trunk's copy does not hold: the list only shrinks — protect the key instead (novox/hq issue 342)", line)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/templates
|
||||
|
||||
go 1.22
|
||||
|
||||
require checks/trunk v0.0.0
|
||||
|
||||
replace checks/trunk => ../trunk
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"checks/trunk"
|
||||
)
|
||||
|
||||
// The script's comparison of this list with the trunk's (novox/hq ADR 0529 clause 2): the list of modules keeping file text inline
|
||||
// gains none the trunk's copy at the merge base does not hold. The catalogue itself is judged against the list by
|
||||
// TestTheCatalogueKeepsFileTextInlineOnlyWhereListed. Skipped, and said, where no trunk is beside.
|
||||
func TestTheInlineListHoldsNoModuleTheTrunksDoesNot(t *testing.T) {
|
||||
path, ok := trunk.Copy(t, "checks/templates/inline")
|
||||
found, err := Check("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listed, err := Listed(listFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := seed
|
||||
if ok {
|
||||
if base, err = Listed(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
_, _, added := Judge(found, listed, base)
|
||||
for _, m := range added {
|
||||
t.Errorf("checks/templates/inline gains %s, which the trunk's copy does not hold: the list only shrinks", m)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module checks/trunk
|
||||
|
||||
go 1.22
|
||||
@@ -0,0 +1,67 @@
|
||||
// Package trunk is where a catalogue check finds the trunk's copy of a list that may only shrink (novox/hq ADR 0529 clause 2):
|
||||
// the file as the catalogue's base commit holds it, to judge a change's list against.
|
||||
//
|
||||
// The checker fetches the change's own repository at its base commit beside the change, as it fetches a sibling, and says where
|
||||
// in MESH_CHECK_BESIDE; the copy is under the repository's name (the same convention the other repositories' tests use). With
|
||||
// MESH_CHECK_BESIDE unset the comparison is skipped, and the test says NOT COMPARED WITH THE TRUNK. Set, without the catalogue's
|
||||
// copy in it, the test fails loudly: a check that cannot compare must not look as if it had. A trunk that holds no such file had
|
||||
// none: the list's first copy, which nothing was added to.
|
||||
package trunk
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Repository is the catalogue's name beside a check.
|
||||
const Repository = "mesh-catalog"
|
||||
|
||||
// BesideEnv is where a merge check says the clones beside the one checked are.
|
||||
const BesideEnv = "MESH_CHECK_BESIDE"
|
||||
|
||||
// Copy is the trunk's copy of the file at rel (a path from the catalogue's root), written to a file of the test's own: its path,
|
||||
// and whether the trunk holds one (false: its first copy). It skips the test when no trunk is beside, and fails it when the
|
||||
// directory is named and holds no copy of the catalogue.
|
||||
func Copy(t testing.TB, rel string) (string, bool) {
|
||||
t.Helper()
|
||||
raw, found, skip, err := Read(rel)
|
||||
switch {
|
||||
case skip != "":
|
||||
t.Skip(skip)
|
||||
case err != nil:
|
||||
t.Fatal(err)
|
||||
case !found:
|
||||
t.Logf("the trunk holds no %s: its first copy", rel)
|
||||
return "", false
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), filepath.Base(rel))
|
||||
if err := os.WriteFile(path, raw, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path, true
|
||||
}
|
||||
|
||||
// Read is the trunk's copy of rel and whether the trunk holds one. skip is the words to skip the comparison with, when no trunk is
|
||||
// named; err is a trunk named and not there, or unreadable.
|
||||
func Read(rel string) (content []byte, found bool, skip string, err error) {
|
||||
beside := os.Getenv(BesideEnv)
|
||||
if beside == "" {
|
||||
return nil, false, "NOT COMPARED WITH THE TRUNK: " + BesideEnv + " is not set, so the trunk's copy of " + rel +
|
||||
" is not beside this run; the checker places it", nil
|
||||
}
|
||||
dir := filepath.Join(beside, Repository)
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
return nil, false, "", errors.New(Repository + " is not beside this check in " + BesideEnv + "=" + beside +
|
||||
", so " + rel + " cannot be judged against the trunk's copy: " + err.Error())
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, rel))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, false, "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, "", err
|
||||
}
|
||||
return raw, true, "", nil
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package trunk
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTheCloneBesideTheCheckIsTheTrunk(t *testing.T) {
|
||||
beside := t.TempDir()
|
||||
dir := filepath.Join(beside, Repository, "checks", "x")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "known"), []byte("a\nb\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(BesideEnv, beside)
|
||||
raw, found, skip, err := Read("checks/x/known")
|
||||
if err != nil || skip != "" || !found || string(raw) != "a\nb\n" {
|
||||
t.Fatalf("read %q found %v skip %q err %v", raw, found, skip, err)
|
||||
}
|
||||
if _, found, skip, err := Read("checks/x/absent"); err != nil || skip != "" || found {
|
||||
t.Fatalf("a file the trunk does not hold: found %v skip %q err %v; wanted its first copy", found, skip, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithNothingBesideTheComparisonIsSkippedAndSaidNotMade(t *testing.T) {
|
||||
t.Setenv(BesideEnv, "")
|
||||
_, found, skip, err := Read("checks/x/known")
|
||||
if found || err != nil || len(skip) < 12 || skip[:12] != "NOT COMPARED" {
|
||||
t.Fatalf("found %v, skip %q, err %v; wanted NOT COMPARED", found, skip, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACloneBesideWithoutTheCatalogueFailsLoudly(t *testing.T) {
|
||||
t.Setenv(BesideEnv, t.TempDir()) // the siblings are there, the catalogue's own base is not
|
||||
if _, _, skip, err := Read("checks/x/known"); err == nil || skip != "" {
|
||||
t.Fatalf("skip %q err %v; wanted an error", skip, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# The modules of the catalogue whose texts still name the build agent or the toolchain images, and why (checks/words,
|
||||
# buildagent_test.go). One module directory a line, with its reason after a #. It only shrinks.
|
||||
modules/mesh-build-go # build.go:45 says "the build agent's recipe" in a step's description: the asks lane's module (issue 1887), not E6's to edit
|
||||
@@ -0,0 +1,57 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The build agent and the toolchain images are gone (novox/hq ADR 0524 §5, issue 1295 and E6 of milestone R4g): no text a module
|
||||
// can show names them. Where the word stands for a reason that is another lane's or still waits for its change, buildagent-allowed
|
||||
// names the module's directory with that reason and the date it is dated from; the list only shrinks: a directory on it that no
|
||||
// longer names the word is refused, so the line is taken out.
|
||||
var gone = []string{"build-agent", "build agent", "toolchain image"}
|
||||
|
||||
func allowedModules(t *testing.T) map[string]string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile("buildagent-allowed")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := map[string]string{}
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
entry, why, _ := strings.Cut(line, "#")
|
||||
if entry = strings.TrimSpace(entry); entry != "" && !strings.HasPrefix(line, "#") {
|
||||
if strings.TrimSpace(why) == "" {
|
||||
t.Fatalf("%q is allowed without a reason", entry)
|
||||
}
|
||||
out[entry] = strings.TrimSpace(why)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestNoTextOfTheCatalogueNamesTheBuildAgentOutsideTheModulesAllowed(t *testing.T) {
|
||||
texts, _, err := collect("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
allowed := allowedModules(t)
|
||||
seen := map[string]bool{}
|
||||
for _, f := range check(gone, texts) {
|
||||
module := f
|
||||
if rest, ok := strings.CutPrefix(f, "modules/"); ok {
|
||||
module = "modules/" + strings.SplitN(rest, "/", 2)[0]
|
||||
}
|
||||
if _, ok := allowed[module]; ok {
|
||||
seen[module] = true
|
||||
continue
|
||||
}
|
||||
t.Errorf("%s (the build agent and the toolchain images are gone: say what stands in their place)", f)
|
||||
}
|
||||
for module, why := range allowed {
|
||||
if !seen[module] {
|
||||
t.Errorf("buildagent-allowed names %s (%s), which names the word no more: take the line out, so it cannot come back", module, why)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,9 @@ func checkLines(lines []retiredLine, texts []text) []string {
|
||||
for _, l := range lines {
|
||||
rx := linePattern(l)
|
||||
for _, t := range texts {
|
||||
if !strings.Contains(t.s, l.daemon) { // the pattern holds the daemon's name whole: the cheap test first
|
||||
continue
|
||||
}
|
||||
if m := rx.FindStringSubmatchIndex(t.s); m != nil {
|
||||
line := t.line
|
||||
if line > 0 {
|
||||
|
||||
+63
-36
@@ -43,18 +43,63 @@ func main() {
|
||||
if len(os.Args) > 1 {
|
||||
root = os.Args[1]
|
||||
}
|
||||
os.Exit(run(root))
|
||||
}
|
||||
|
||||
// run reads every text a module can show under root and answers the command's exit code: 0 clean, 1 a finding, 2 a list or a
|
||||
// file unreadable. The command prints what it found; a test of the real catalogue reads the code.
|
||||
func run(root string) int {
|
||||
words, err := readList(filepath.Join(root, "retired-words"))
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "words:", err)
|
||||
os.Exit(2)
|
||||
return 2
|
||||
}
|
||||
if len(words) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "words: retired-words lists no word — the check would pass on anything")
|
||||
os.Exit(2)
|
||||
return 2
|
||||
}
|
||||
texts, files, err := collect(root)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "words:", err)
|
||||
return 2
|
||||
}
|
||||
findings := check(words, texts)
|
||||
for _, f := range findings {
|
||||
fmt.Println(f)
|
||||
}
|
||||
if len(findings) > 0 {
|
||||
fmt.Printf("words: %d use(s) of a word the glossary retired (novox/hq ADR 0244) — say it in the glossary's word\n", len(findings))
|
||||
return 1
|
||||
}
|
||||
// And no daemon's line (novox/hq ADR 0272): what a module tells the operator to type is a `nox` line.
|
||||
lines, err := readLines(filepath.Join(root, "retired-lines"))
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "words:", err)
|
||||
return 2
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "words: retired-lines lists no line — the check would pass on anything")
|
||||
return 2
|
||||
}
|
||||
if findings := checkLines(lines, texts); len(findings) > 0 {
|
||||
for _, f := range findings {
|
||||
fmt.Println(f)
|
||||
}
|
||||
fmt.Printf("words: %d daemon's line(s) in what a module shows — the operator types one command, nox (novox/hq ADR 0272)\n",
|
||||
len(findings))
|
||||
return 1
|
||||
}
|
||||
fmt.Printf("words: %d retired words and %d daemons' lines, none in the %d strings of %d files\n", len(words), len(lines),
|
||||
len(texts), files)
|
||||
return 0
|
||||
}
|
||||
|
||||
// collect reads every text a module can show under root: the strings of its own code that is not a test, and every string of
|
||||
// its manifests.
|
||||
func collect(root string) ([]text, int, error) {
|
||||
var texts []text
|
||||
files := 0
|
||||
err = filepath.WalkDir(filepath.Join(root, "modules"), func(path string, d fs.DirEntry, err error) error {
|
||||
err := filepath.WalkDir(filepath.Join(root, "modules"), func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -87,38 +132,7 @@ func main() {
|
||||
texts = append(texts, found...)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "words:", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
findings := check(words, texts)
|
||||
for _, f := range findings {
|
||||
fmt.Println(f)
|
||||
}
|
||||
if len(findings) > 0 {
|
||||
fmt.Printf("words: %d use(s) of a word the glossary retired (novox/hq ADR 0244) — say it in the glossary's word\n", len(findings))
|
||||
os.Exit(1)
|
||||
}
|
||||
// And no daemon's line (novox/hq ADR 0272): what a module tells the operator to type is a `nox` line.
|
||||
lines, err := readLines(filepath.Join(root, "retired-lines"))
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "words:", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "words: retired-lines lists no line — the check would pass on anything")
|
||||
os.Exit(2)
|
||||
}
|
||||
if findings := checkLines(lines, texts); len(findings) > 0 {
|
||||
for _, f := range findings {
|
||||
fmt.Println(f)
|
||||
}
|
||||
fmt.Printf("words: %d daemon's line(s) in what a module shows — the operator types one command, nox (novox/hq ADR 0272)\n",
|
||||
len(findings))
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("words: %d retired words and %d daemons' lines, none in the %d strings of %d files\n", len(words), len(lines),
|
||||
len(texts), files)
|
||||
return texts, files, err
|
||||
}
|
||||
|
||||
// readList reads retired-words: one word or phrase a line; blank lines and lines starting with # are not words.
|
||||
@@ -152,9 +166,22 @@ func pattern(word string) *regexp.Regexp {
|
||||
|
||||
func check(words []string, texts []text) []string {
|
||||
var out []string
|
||||
// A text can hold a word only if it holds the word's first part, in any case: the regular expression is run on those alone,
|
||||
// which keeps the check of the whole catalogue within the unit level's bound.
|
||||
lower := make([]string, len(texts))
|
||||
for i, t := range texts {
|
||||
lower[i] = strings.ToLower(t.s)
|
||||
}
|
||||
for _, w := range words {
|
||||
rx := pattern(w)
|
||||
for _, t := range texts {
|
||||
first := ""
|
||||
if parts := strings.Fields(w); len(parts) > 0 {
|
||||
first = strings.ToLower(parts[0])
|
||||
}
|
||||
for i, t := range texts {
|
||||
if !strings.Contains(lower[i], first) {
|
||||
continue
|
||||
}
|
||||
if m := rx.FindStringSubmatchIndex(t.s); m != nil {
|
||||
line := t.line
|
||||
if line > 0 {
|
||||
|
||||
@@ -87,3 +87,11 @@ func TestRetiredLinesAreReadAsDaemonAndCommand(t *testing.T) {
|
||||
t.Fatal("a line with no command was read")
|
||||
}
|
||||
}
|
||||
|
||||
// The script's run of this check over the real tree (novox/hq ADR 0529 clause 2): no word the glossary retired, and no
|
||||
// daemon's line, in any text a module's tools can show an agent.
|
||||
func TestTheCatalogueHoldsNoRetiredWordAndNoDaemonsLine(t *testing.T) {
|
||||
if code := run("../.."); code != 0 {
|
||||
t.Fatalf("the check of the real catalogue exits %d (see what it printed above)", code)
|
||||
}
|
||||
}
|
||||
|
||||
Executable
BIN
Binary file not shown.
+1
-1
@@ -1,5 +1,5 @@
|
||||
58
|
||||
The long-running resources of this catalogue that do not say how they are ready (`health`, novox/hq ADR 0240
|
||||
rule 8), as `module check` counts them. It may only go down: merge-check.sh fails a change that raises it, and
|
||||
rule 8), as `module check` counts them. It may only go down: checks/health fails a change that raises it, and
|
||||
one that lowers it writes the new number on the first line. From 2026-11-18, or once it is 0, a long-running
|
||||
resource without `health` is refused.
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
#!/bin/sh
|
||||
# The catalogue's TypeScript part of its own check (novox/hq issue 302's `mesh-check-also`), declared in
|
||||
# merge-check.sh's header and run by the build seat in the TypeScript toolchain, after the Go part. The media
|
||||
# catalogue's twin is issue 311. Before this, a TypeScript module's change ran nothing of its own before it
|
||||
# merged: the Go part said "NOT TESTED HERE", and a type error in the forge module would first have shown in
|
||||
# its walk.
|
||||
#
|
||||
# For every touched module that has a package.json, in a copy of it outside the clone:
|
||||
#
|
||||
# 1. its dependencies are the toolchain's own (/app/node_modules: the mesh's SDK, Node's types and what the
|
||||
# runtime carries), copied beside it, never fetched: the check holds no credential for the package
|
||||
# registry. A module that depends on a package the toolchain does not hold **fails the check**, by name:
|
||||
# it could not be type-checked or tested, and what could not run is a failure, never a note (novox/hq
|
||||
# issue 385);
|
||||
# 2. every source that is not a test is type-checked with the toolchain's compiler and the flags a build
|
||||
# compiles with — the same check the build makes, before the merge rather than in the walk;
|
||||
# 3. its tests (test/*.test.ts) run under Node's own runner with types stripped, as its own `npm test` does
|
||||
# after its build: the sources are compiled to dist/ and beside themselves first, so a test that imports
|
||||
# the compiled module, or a source that imports its sibling by its .js name, finds it.
|
||||
#
|
||||
# Said, never passed silently: a module with no tests is said NOT TESTED on every run that touches it —
|
||||
# nothing of it could fail. A module whose tests exist and could not run fails (novox/hq issue 385).
|
||||
#
|
||||
# By hand: MESH_CHECK_MODULES=gitea sh merge-check-typescript.sh, in the toolchain image (it reads
|
||||
# /app/node_modules), or with TOOLCHAIN_MODULES pointing at a node_modules that holds the same.
|
||||
#
|
||||
# **In an agent's session, outside the toolchain image** (novox/hq issue 742), the same node_modules is
|
||||
# assembled first, in a throwaway directory under the check's own workspace, the way the image's `deps` stage
|
||||
# assembles it: the toolchain's own package.json and lock (mesh-tools' node-tools/, read from the forge this
|
||||
# clone came from, so the list is never copied here) installed with `npm ci`, then the SDK the mesh last
|
||||
# published (`@novox/mesh-sdk@latest`), the @novox scope from the forge's package registry, read without a
|
||||
# credential. Node and npm are the dev-tools module's; nothing is installed on the machine, no npm
|
||||
# configuration or cache of the account's is read or written. Only when neither TOOLCHAIN_MODULES nor
|
||||
# /app/node_modules is there: the forge's check, in the image, fetches nothing, as before. The forge is
|
||||
# MESH_FORGE (default: the origin remote's), the owner MESH_FORGE_OWNER (default: the origin's owner).
|
||||
set -eu
|
||||
|
||||
work=$(mktemp -d)
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
deps="${TOOLCHAIN_MODULES:-/app/node_modules}"
|
||||
if [ -z "${TOOLCHAIN_MODULES:-}" ] && [ ! -f "$deps/typescript/bin/tsc" ]; then
|
||||
origin=$(git remote get-url origin 2>/dev/null || true)
|
||||
forge="${MESH_FORGE:-$(printf '%s\n' "$origin" | sed -n 's#^\(https\{0,1\}://[^/]*\)/.*#\1#p')}"
|
||||
owner="${MESH_FORGE_OWNER:-$(printf '%s\n' "$origin" | sed -n 's#^https\{0,1\}://[^/]*/\([^/]*\)/.*#\1#p')}"
|
||||
if [ -z "$forge" ] || [ -z "$owner" ]; then
|
||||
echo "NOT CHECKED: no TypeScript toolchain at $deps, and no forge to assemble one from (origin is '$origin'; set MESH_FORGE and MESH_FORGE_OWNER, or TOOLCHAIN_MODULES)"
|
||||
exit 1
|
||||
fi
|
||||
echo "no TypeScript toolchain at $deps: assembling one from $forge/$owner/mesh-tools node-tools/ and $owner's package registry (novox/hq issue 742)"
|
||||
tc="$work/toolchain"
|
||||
mkdir -p "$tc"
|
||||
printf '@%s:registry=%s/api/packages/%s/npm/\n' "$owner" "$forge" "$owner" >"$work/npmrc"
|
||||
# Read with Node's own fetch (dev-tools declares Node, not curl); a status that is not 2xx fails.
|
||||
if ! { node -e '
|
||||
const [base, dir] = process.argv.slice(1);
|
||||
Promise.all(["package.json", "package-lock.json"].map(async (f) => {
|
||||
const r = await fetch(base + f);
|
||||
if (!r.ok) throw new Error(base + f + ": " + r.status);
|
||||
require("fs").writeFileSync(dir + "/" + f, await r.text());
|
||||
})).catch((e) => { console.error(e.message); process.exit(1); });' \
|
||||
"$forge/$owner/mesh-tools/raw/branch/main/node-tools/" "$tc" &&
|
||||
(cd "$tc" && export npm_config_userconfig="$work/npmrc" npm_config_cache="$work/npm-cache" &&
|
||||
npm ci --ignore-scripts --no-audit --no-fund --loglevel=error &&
|
||||
npm install --ignore-scripts --no-audit --no-fund --no-save --loglevel=error "@$owner/mesh-sdk@latest"); }; then
|
||||
echo "NOT CHECKED: the TypeScript toolchain could not be assembled from $forge (novox/hq issue 742)"
|
||||
exit 1
|
||||
fi
|
||||
deps="$tc/node_modules"
|
||||
fi
|
||||
tsc="$deps/typescript/bin/tsc"
|
||||
if [ ! -f "$tsc" ]; then
|
||||
echo "NOT CHECKED: no TypeScript compiler at $tsc — this part runs in the TypeScript toolchain"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The modules the change reaches: the planner's answer (hq ADR 0238), and every module a changed file is in —
|
||||
# a change to a module's tests alone moves no build (hq ADR 0267), and its tests must still run.
|
||||
touched=$({
|
||||
printf '%s\n' "${MESH_CHECK_MODULES:-}" | tr ',' '\n' | sed -e 's#^modules/##' -e '/\//d'
|
||||
printf '%s\n' "${MESH_CHECK_CHANGED:-}" | tr ',' '\n' | sed -n 's#^modules/\([^/]*\)/.*#\1#p'
|
||||
} | sed '/^$/d' | sort -u)
|
||||
|
||||
failed=""
|
||||
for m in $touched; do
|
||||
d="modules/$m"
|
||||
[ -f "$d/package.json" ] || continue
|
||||
copy="$work/$m"
|
||||
mkdir -p "$copy"
|
||||
(cd "$d" && tar --exclude=./node_modules --exclude=./dist -cf - .) | (cd "$copy" && tar -xf -)
|
||||
# What it depends on, against what the toolchain holds.
|
||||
missing=$(node -e '
|
||||
const p = require(process.argv[1]); const have = require("fs").readdirSync(process.argv[2]);
|
||||
const scoped = (n) => n.startsWith("@") ? n.split("/")[0] : n;
|
||||
const names = Object.keys({ ...(p.dependencies || {}), ...(p.devDependencies || {}) });
|
||||
const held = (n) => have.includes(n) || (n.startsWith("@") && have.includes(scoped(n)) &&
|
||||
require("fs").existsSync(process.argv[2] + "/" + n));
|
||||
console.log(names.filter((n) => n !== "typescript" && !held(n)).join(" "));' "$copy/package.json" "$deps")
|
||||
if [ -n "$missing" ]; then
|
||||
echo "FAILED: $d could not be checked — it depends on $missing, which the toolchain does not hold and the check cannot fetch (novox/hq issue 385)"
|
||||
failed="$failed $m"
|
||||
continue
|
||||
fi
|
||||
mkdir -p "$copy/node_modules"
|
||||
for p in "$deps"/*; do
|
||||
case "$(basename "$p")" in typescript | esbuild | @esbuild) continue ;; esac
|
||||
cp -r "$p" "$copy/node_modules/"
|
||||
done
|
||||
sources=$(cd "$copy" && find . -name '*.ts' ! -name '*.d.ts' ! -path './test/*' ! -path './node_modules/*' ! -path './dist/*' | sort)
|
||||
if [ -n "$sources" ]; then
|
||||
echo "type-checking $d (TypeScript)"
|
||||
# shellcheck disable=SC2086 # the module's sources, none with a space in its name
|
||||
if ! (cd "$copy" && node "$tsc" --noEmit --module NodeNext --moduleResolution NodeNext --target ES2022 \
|
||||
--types node $sources); then
|
||||
failed="$failed $m"
|
||||
continue
|
||||
fi
|
||||
else
|
||||
echo "$d has no TypeScript sources of its own to type-check: its tests read its files"
|
||||
fi
|
||||
if ! ls "$copy"/test/*.test.ts >/dev/null 2>&1; then
|
||||
echo "NOT TESTED: $d is TypeScript and has no tests of its own"
|
||||
continue
|
||||
fi
|
||||
if [ -n "$sources" ]; then
|
||||
# shellcheck disable=SC2086
|
||||
(cd "$copy" && node "$tsc" --module NodeNext --moduleResolution NodeNext --target ES2022 --types node \
|
||||
--rootDir . --outDir dist $sources && node "$tsc" --module NodeNext --moduleResolution NodeNext \
|
||||
--target ES2022 --types node --rootDir . --outDir . $sources)
|
||||
fi
|
||||
echo "testing $d (TypeScript)"
|
||||
if ! (cd "$copy" && node --test --test-reporter=spec --experimental-strip-types \
|
||||
--no-warnings=ExperimentalWarning test/*.test.ts); then
|
||||
failed="$failed $m"
|
||||
fi
|
||||
done
|
||||
if [ -n "$failed" ]; then
|
||||
echo "TypeScript modules that failed:$failed"
|
||||
exit 1
|
||||
fi
|
||||
-233
@@ -1,233 +0,0 @@
|
||||
#!/bin/sh
|
||||
# mesh-check-toolchain: go
|
||||
# mesh-check-also: typescript merge-check-typescript.sh
|
||||
#
|
||||
# The catalogue's own check (novox/hq ADR 0237 as amended): the second layer of a pull request's merge
|
||||
# check, `mesh/repo-check`, run by the build seat in the mesh's Go toolchain.
|
||||
#
|
||||
# The gate — the manifests the change touches through the running controller's module check, every machine
|
||||
# of the facts snapshot composed with them and validated by the node-engine's own validator, the replays
|
||||
# against this tree — is the build seat's first layer (`mesh/merge-gate`), run because the mesh's module
|
||||
# graph builds these modules from here. It is not repeated here. This is the repository's own:
|
||||
#
|
||||
# 1. every manifest through the controller's module check, so one module's change cannot leave another
|
||||
# it shares a rule with refused (MESH_GATE is the controller the mesh runs) — and the count of
|
||||
# long-running resources without `health` held to the number in health-undeclared, which only goes down;
|
||||
# 2. the Go tests of every module the change touches that has them, under the race detector when the
|
||||
# toolchain has a C compiler. A requirement of the mesh's own forge the module cache does not hold (a
|
||||
# pinned SDK commit) is taken from its repository's clone beside the check (checks/deps); a module whose
|
||||
# dependencies still cannot be had **fails the check**, by name and why — its tests could not run, and a
|
||||
# test that could not run is a failure, never a note (novox/hq issue 385). The integration tests (`//go:build
|
||||
# integration`) run here, with -tags integration, and only here. A module written in
|
||||
# TypeScript is type-checked and tested by
|
||||
# merge-check-typescript.sh, declared above, in the TypeScript toolchain (novox/hq issues 302, 311);
|
||||
# 3. the words (novox/hq ADR 0244): no word the glossary retired for the tools, as copied in
|
||||
# retired-words, in any text a module's tools can show an agent — every string in its own code that is
|
||||
# not a test, and every description in its manifest. Run over every module, not only the touched ones,
|
||||
# so a word newly retired is found everywhere it stands. The check's own test runs first.
|
||||
# 4. the paths (novox/hq ADR 0260): no module names a path under /run, /var/lib, /usr/local/lib or /etc
|
||||
# that another module's manifest places — modules talk over the bus, not through each other's files.
|
||||
# Over every module; checks/paths/shared and checks/paths/known narrow it, and known only shrinks.
|
||||
# 5. the agent's home (novox/hq ADR 0266): no module unpacks an archive, or writes into a file, under
|
||||
# ${machine:agent-home} — the node-engine does both after checking the path, and the agent account owns
|
||||
# that home and could swap a link in between.
|
||||
# 6. the secrets (novox/hq issue 342): no mergeable file fills a key with a vault secret that a setting could
|
||||
# replace — the key is `protected`, or left out of the file's `takes-settings`. checks/secrets/known names
|
||||
# the nested ones the controller cannot protect alone yet, and only shrinks.
|
||||
# 7. the list of predefined keys (novox/hq ADR 0348 clause 7): a change to predefined.json removes no key a
|
||||
# module still names, judged against the trunk's copy at the merge base. Whether a removed key still holds a
|
||||
# value at the mesh or a node is mesh-config's to refuse; this check reads no store. The list itself and
|
||||
# every manifest against it are judged by the controller's module check in step 1.
|
||||
# 8. the config form (novox/hq ADR 0348 clauses 1 and 11): no module says `settings`, `takes-settings` or
|
||||
# `${setting:<key>}`, in its manifest or a template it names, or has a mergeable file naming no keys (which
|
||||
# takes its whole layer), unless checks/config/unmoved lists it as not yet moved; that list only shrinks,
|
||||
# judged against the trunk's copy at the merge base.
|
||||
# 9. the file text (novox/hq ADR 0349 §2): no module keeps a file's text inline in its manifest (`content`)
|
||||
# unless checks/templates/inline lists it as not yet moved; each names its template in files/. That list
|
||||
# only shrinks, judged against the trunk's copy at the merge base.
|
||||
# 10. the mail-store seat (novox/hq ADR 0378 §3, issue 800): no manifest but modules/mesh-mail claims it.
|
||||
# 11. owned paths (novox/hq ADR 0422 §8, issue 1024; it holds ~/.ssh for ssh-client as ADR 0415 §6 asked):
|
||||
# every directory and archive says `unknown` (move-out, module or program) and a valid `knows`; `move-out`
|
||||
# only under a home; no module declares a path inside a directory of another module; and none is, lies in or
|
||||
# holds a directory a system package owns under /var/lib (checks/owned/system, issue 1841).
|
||||
# checks/sshdir (ADR 0415 §6) stays while main runs it; it goes under issue 1035 once main runs checks/owned.
|
||||
# 12. the image builder (novox/hq ADR 0487 §3, issue 1704): only modules/buildah provides rootless-image-build.
|
||||
# 13. the image's place (novox/hq ADR 0487 §1, issue 1707): every image builds from a Dockerfile in its own module's directory.
|
||||
set -eu
|
||||
|
||||
(cd checks/words && go test -count=1 . && go run . ../..)
|
||||
# Node names (novox/hq ADR 0507 §9, issue 1838): no node name of this mesh in a covered module; the names are in
|
||||
# `particulars`, the covered modules in checks/particulars/covered.
|
||||
(cd checks/particulars && go test -count=1 . && go run . ../..)
|
||||
# The toolchains (novox/hq ADR 0477 clause 4): the package go only in modules/go, nodejs and npm only in modules/nodejs.
|
||||
(cd checks/toolchains && go test -count=1 . && go run . ../..)
|
||||
# The image builder (novox/hq ADR 0487 §3): only modules/buildah provides rootless-image-build, docker never.
|
||||
(cd checks/imagebuild && go test -count=1 . && go run . ../..)
|
||||
# The image's place (novox/hq ADR 0487 §1): every image builds from a Dockerfile in its own module's directory.
|
||||
(cd checks/imageplace && go test -count=1 . && go run . ../..)
|
||||
# The Node lock files (novox/hq ADR 0469 §5, issue 1868): a TypeScript module that names packages beyond the SDK commits its lock.
|
||||
(cd checks/locks && go test -count=1 . && go run . ../..)
|
||||
# The agent's home (novox/hq ADR 0266): no archive, and no file written into, under it.
|
||||
(cd checks/agenthome && go test -count=1 . && go run . ../..)
|
||||
# The mail-store seat (novox/hq ADR 0378 §3): only mesh-mail may claim it.
|
||||
(cd checks/mailstore && go test -count=1 . && go run . ../..)
|
||||
# ~/.ssh (novox/hq ADR 0415 §6): only ssh-client may name a path under it. Main's merge check runs this line, so
|
||||
# checks/sshdir stays until main runs checks/owned; it goes under issue 1035.
|
||||
(cd checks/sshdir && go test -count=1 . && go run . ../..)
|
||||
# Owned paths (novox/hq ADR 0422 §8): unknown files named per directory; no path inside another module's directory;
|
||||
# none in a system package's directory under /var/lib (issue 1841), the known exceptions only shrinking: judged
|
||||
# against the trunk's copy of checks/owned/system-known at the merge base; a trunk without one is the list's first copy.
|
||||
owned_base=""
|
||||
if merge_base=$(git merge-base HEAD "origin/${MESH_CHECK_BASE:-main}" 2>/dev/null); then
|
||||
if git cat-file -e "$merge_base:checks/owned/system-known" 2>/dev/null; then
|
||||
owned_base=$(mktemp)
|
||||
git show "$merge_base:checks/owned/system-known" >"$owned_base"
|
||||
fi
|
||||
else
|
||||
echo "NOT COMPARED WITH THE TRUNK: no merge base with origin/${MESH_CHECK_BASE:-main}; checks/owned/system-known is held to the list it was first written with"
|
||||
fi
|
||||
(cd checks/owned && go test -count=1 . && if [ -n "$owned_base" ]; then go run . -base "$owned_base" ../..; else go run . ../..; fi)
|
||||
# The secrets (novox/hq issue 342): a password is not a setting, so no setting can replace the vault's. The
|
||||
# known list only shrinks: judged against the trunk's copy at the merge base, a line it does not hold is refused.
|
||||
secrets_base=""
|
||||
secrets_ref="origin/${MESH_CHECK_BASE:-main}"
|
||||
if merge_base=$(git merge-base HEAD "$secrets_ref" 2>/dev/null); then
|
||||
if git cat-file -e "$merge_base:checks/secrets/known" 2>/dev/null; then
|
||||
secrets_base=$(mktemp)
|
||||
git show "$merge_base:checks/secrets/known" >"$secrets_base"
|
||||
fi
|
||||
else
|
||||
echo "NOT COMPARED WITH THE TRUNK: no merge base with $secrets_ref; checks/secrets/known is held to the list it was first written with"
|
||||
fi
|
||||
(cd checks/secrets && go test -count=1 . && if [ -n "$secrets_base" ]; then go run . -base "$secrets_base" ../..; else go run . ../..; fi)
|
||||
# The list of predefined keys (novox/hq ADR 0348 clause 7): no key removed that a module still names. The trunk's
|
||||
# list at the merge base is what it is judged against; a trunk without the file had none.
|
||||
predefined_base=""
|
||||
if merge_base=$(git merge-base HEAD "origin/${MESH_CHECK_BASE:-main}" 2>/dev/null); then
|
||||
predefined_base=$(mktemp)
|
||||
if ! git show "$merge_base:predefined.json" >"$predefined_base" 2>/dev/null; then
|
||||
echo '{}' >"$predefined_base"
|
||||
fi
|
||||
else
|
||||
echo "NOT COMPARED WITH THE TRUNK: no merge base with origin/${MESH_CHECK_BASE:-main}; no removal from predefined.json can be seen"
|
||||
fi
|
||||
(cd checks/predefined && go test -count=1 . && if [ -n "$predefined_base" ]; then go run . -base "$predefined_base" ../..; else go run . ../..; fi)
|
||||
# The config form (novox/hq ADR 0348 §1, §11): a moved module says no old name, and the list of modules not yet moved
|
||||
# only shrinks, judged against the trunk's copy at the merge base; a trunk without one is the list's first copy.
|
||||
unmoved_base=""
|
||||
if merge_base=$(git merge-base HEAD "origin/${MESH_CHECK_BASE:-main}" 2>/dev/null); then
|
||||
if git cat-file -e "$merge_base:checks/config/unmoved" 2>/dev/null; then
|
||||
unmoved_base=$(mktemp)
|
||||
git show "$merge_base:checks/config/unmoved" >"$unmoved_base"
|
||||
fi
|
||||
else
|
||||
echo "NOT COMPARED WITH THE TRUNK: no merge base with origin/${MESH_CHECK_BASE:-main}; checks/config/unmoved is held to the list it was first written with"
|
||||
fi
|
||||
(cd checks/config && go test -count=1 . && if [ -n "$unmoved_base" ]; then go run . -base "$unmoved_base" ../..; else go run . ../..; fi)
|
||||
# The file text (novox/hq ADR 0349 §2): a file's text is a template in files/, outside the modules not yet moved; the
|
||||
# list of those only shrinks, judged against the trunk's copy at the merge base, a trunk without one being its first.
|
||||
inline_base=""
|
||||
if merge_base=$(git merge-base HEAD "origin/${MESH_CHECK_BASE:-main}" 2>/dev/null); then
|
||||
if git cat-file -e "$merge_base:checks/templates/inline" 2>/dev/null; then
|
||||
inline_base=$(mktemp)
|
||||
git show "$merge_base:checks/templates/inline" >"$inline_base"
|
||||
fi
|
||||
else
|
||||
echo "NOT COMPARED WITH THE TRUNK: no merge base with origin/${MESH_CHECK_BASE:-main}; checks/templates/inline is held to the list it was first written with"
|
||||
fi
|
||||
(cd checks/templates && go test -count=1 . && if [ -n "$inline_base" ]; then go run . -base "$inline_base" ../..; else go run . ../..; fi)
|
||||
# The lists that narrow the paths check may not grow unseen: the base branch's copies are what they are
|
||||
# judged against (MESH_CHECK_BASE, main when unset), read from the clone the check runs in.
|
||||
base_lists=""
|
||||
base_ref="origin/${MESH_CHECK_BASE:-main}"
|
||||
if git rev-parse --verify --quiet "$base_ref^{commit}" >/dev/null 2>&1 &&
|
||||
git cat-file -e "$base_ref:checks/paths/known" 2>/dev/null && git cat-file -e "$base_ref:checks/paths/shared" 2>/dev/null; then
|
||||
base_lists=$(mktemp -d)
|
||||
git show "$base_ref:checks/paths/known" >"$base_lists/known"
|
||||
git show "$base_ref:checks/paths/shared" >"$base_lists/shared"
|
||||
fi
|
||||
(cd checks/paths && go test -count=1 . && if [ -n "$base_lists" ]; then go run . -base "$base_lists" ../..; else go run . ../..; fi)
|
||||
|
||||
if [ -n "${MESH_GATE:-}" ]; then
|
||||
checked=$("$MESH_GATE" module check modules/*/module.json) || { printf '%s\n' "$checked"; exit 1; }
|
||||
# **The count only goes down** (novox/hq ADR 0240 rule 8): the long-running resources that do not say how
|
||||
# they are ready, as the controller counts them, against the number kept in health-undeclared. A change
|
||||
# that raises it fails; one that lowers it writes the new number there, so it can never rise again.
|
||||
kept=$(sed -n 's/^\([0-9][0-9]*\).*/\1/p' health-undeclared | head -n 1)
|
||||
counted=$(printf '%s\n' "$checked" | sed -n 's/^long-running resources without health: \([0-9][0-9]*\)$/\1/p')
|
||||
if [ -z "$counted" ]; then
|
||||
echo "NOT COUNTED: the controller the mesh runs is older than the count of resources without health (ADR 0240 Phase B)"
|
||||
elif [ "$counted" -gt "$kept" ]; then
|
||||
echo "the long-running resources without health rose from $kept to $counted: declare how each new one is ready (ADR 0240 rule 8)"
|
||||
exit 1
|
||||
elif [ "$counted" -lt "$kept" ]; then
|
||||
echo "the long-running resources without health fell from $kept to $counted: write $counted in health-undeclared, so the count cannot rise again"
|
||||
exit 1
|
||||
else
|
||||
echo "long-running resources without health: $counted, as kept"
|
||||
fi
|
||||
else
|
||||
echo "NOT CHECKED: no controller was built beside this check, so the manifests were not read by one"
|
||||
fi
|
||||
|
||||
race=""
|
||||
if command -v gcc >/dev/null 2>&1; then race="-race"; else echo "NOT RACE-CHECKED: the toolchain holds no C compiler"; fi
|
||||
|
||||
# The modules the change reaches are the controller's planner's answer (MESH_CHECK_MODULES, hq ADR 0238):
|
||||
# a module by its name, a new one by its directory. By hand, without it, the directories the changed files
|
||||
# are in.
|
||||
if [ -n "${MESH_CHECK_MODULES:-}" ]; then
|
||||
touched=$(printf '%s\n' "$MESH_CHECK_MODULES" | tr ',' '\n' | sed -e 's#^modules/##' -e '/\//d' | sort -u)
|
||||
else
|
||||
touched=$(printf '%s\n' "${MESH_CHECK_CHANGED:-}" | tr ',' '\n' | sed -n 's#^modules/\([^/]*\)/.*#\1#p' | sort -u)
|
||||
fi
|
||||
# A module's dependencies: fetched, or a forge requirement taken from its clone beside the check
|
||||
# (MESH_CHECK_BESIDE); its tests run against them (checks/deps, novox/hq issue 385). The helper's own test
|
||||
# runs first.
|
||||
(cd checks/deps && go test -count=1 .)
|
||||
deps=$(mktemp -d)
|
||||
trap 'rm -rf "$deps"' EXIT
|
||||
(cd checks/deps && go build -o "$deps/deps" .)
|
||||
untestable=""
|
||||
for m in $touched; do
|
||||
[ -d "modules/$m" ] || continue
|
||||
if [ ! -f "modules/$m/go.mod" ]; then
|
||||
[ -f "modules/$m/package.json" ] && echo "modules/$m is TypeScript: merge-check-typescript.sh type-checks and tests it"
|
||||
continue
|
||||
fi
|
||||
if ! modfile=$(GOPRIVATE=git.novox.be "$deps/deps" -beside "${MESH_CHECK_BESIDE:-}" -work "$deps" "modules/$m"); then
|
||||
echo "FAILED: modules/$m could not be tested — $modfile"
|
||||
untestable="$untestable modules/$m"
|
||||
continue
|
||||
fi
|
||||
[ -n "$modfile" ] && echo "modules/$m takes its forge dependencies from the clones beside the check"
|
||||
echo "testing modules/$m"
|
||||
unformatted=$(cd "modules/$m" && gofmt -l .)
|
||||
if [ -n "$unformatted" ]; then
|
||||
echo "modules/$m is not gofmt'd: $unformatted"
|
||||
exit 1
|
||||
fi
|
||||
cgo=0
|
||||
[ -n "$race" ] && cgo=1
|
||||
# The integration tests (a file whose first line is `//go:build integration`: a test that starts a bus, a store or
|
||||
# another process) run here and nowhere else: the unit suite stays under a second (novox/hq playbook 07).
|
||||
(cd "modules/$m" && CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go vet -tags integration ${modfile:+"-modfile=$modfile"} ./... &&
|
||||
CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go test -tags integration ${modfile:+"-modfile=$modfile"} $race -count=1 ./...)
|
||||
# The docker tests of the bus's snapshot program and entrypoint (novox/hq ADR 0480 clause 6) start throwaway nats
|
||||
# containers, and the Go toolchain container holds no docker, so above they skip with a "NOT RUN" line. Where the
|
||||
# build seat says docker exists (MESH_CHECK_DOCKER=1) they run here by name, and a missing docker fails the check
|
||||
# (MESH_REQUIRE_DOCKER=1 makes the tests fail instead of skip). Without it the step says it did not run.
|
||||
if [ "$m" = nats ]; then
|
||||
if [ "${MESH_CHECK_DOCKER:-}" = 1 ]; then
|
||||
(cd modules/nats && MESH_REQUIRE_DOCKER=1 CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go test -tags integration ${modfile:+"-modfile=$modfile"} -count=1 \
|
||||
-run 'TestTheDeclaredDumpRunsInTheBussImage|TestTheEntrypointReloadsTheServerWithoutDroppingAConnection|TestTheStoreSurvivesTheNewDeclarationTheOldAndTheNewAgain|TestASnapshotOfALiveBusRestoresToTheSameContent|TestTheSnapshotUserCannotChangeTheBus' \
|
||||
./cmd/mesh-nats-snapshot) || { echo "the bus's docker tests failed"; exit 1; }
|
||||
else
|
||||
echo "NOT RUN: the bus's five docker tests (modules/nats/cmd/mesh-nats-snapshot) skipped: this check has no docker (MESH_CHECK_DOCKER is not 1)"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
if [ -n "$untestable" ]; then
|
||||
echo "the check fails: the tests of$untestable could not run (novox/hq issue 385)"
|
||||
exit 1
|
||||
fi
|
||||
@@ -951,7 +951,7 @@ func HeardEnd(p Paths, store ProposalStore, w asks.Warrant, now time.Time) (stri
|
||||
return "", nil
|
||||
}
|
||||
// Said to a proposer waiting on it only once the discard is kept: what it reads next is already there (the
|
||||
// flake of the router's refusal test under a loaded build agent, 2026-10-12).
|
||||
// flake of the router's refusal test under a loaded build node, 2026-10-12).
|
||||
tell := func() {
|
||||
endedMu.Lock()
|
||||
ended[w.Ask] = w
|
||||
|
||||
@@ -19,8 +19,8 @@ Assign it to every node, with the `go` and `nodejs` modules: it requires their p
|
||||
| git and jq | `git`, `jq` | package |
|
||||
|
||||
All are in the distribution's official repositories, so the node-engine's package manager installs them on any
|
||||
node. The versions are the distribution's: the toolchain images that run `mesh/repo-check` stay the judge of a
|
||||
pull request, and a local run is the developer's early answer, not the verdict.
|
||||
node. The versions are the distribution's: the build modules' toolchains, which the checker runs a pull request's tests with, stay the
|
||||
judge of a pull request, and a local run is the developer's early answer, not the verdict.
|
||||
|
||||
Nothing else is placed: no file, no service, no environment. A module's own dependencies come from its lock
|
||||
file (`go.mod`, `package-lock.json`), as in the toolchain images.
|
||||
file (`go.mod`, `package-lock.json`), as in the build modules.
|
||||
|
||||
@@ -22,8 +22,7 @@ Every new head of an open pull request is announced as `pull.updated`, once, and
|
||||
minutes, or when the search fails.
|
||||
- **A pull request nobody checks is said — by the delivery's owner, not here.** `gitea_open_pulls` answers every
|
||||
open pull request from the forge itself, with the statuses its base requires and the ones its head has. The
|
||||
delivery gate asks it every five minutes, and a head on a base requiring `mesh/check` (or hq's
|
||||
`mesh/repo-check`) with none of them for ten minutes since it first saw it so is a delivery held past its bound
|
||||
delivery gate asks it every five minutes, and a head on a base requiring `mesh/check` with none of them for ten minutes since it first saw it so is a delivery held past its bound
|
||||
(`delivery.<id>.stalled`, state `unannounced`), which the controller raises like any other (issue 347).
|
||||
Each of the head's statuses comes with its state and when it was set, so a head whose check started and never
|
||||
answered — a required check pending, or never set beside one that is — is said as a stalled line in the state
|
||||
@@ -153,19 +152,18 @@ listed repository:
|
||||
- one that is not in the organisation and not under the setup account is made there, **empty and under the setup
|
||||
account**, where neither the tools' team nor the agents' team (both on every repository of the organisation)
|
||||
reaches it. Main gets a rule letting the setup login alone push; main's first commit is written with it: a README
|
||||
naming the description, and a `merge-check.sh` declaring `# mesh-check-toolchain: go` that vets and runs the unit
|
||||
tests, then the integration tests (`-tags integration`), of every Go module in the tree, and says so and passes on
|
||||
a tree with none. The rule is then narrowed to main's rule of ADR 0398 §2 (no push, only `mesh-tools` merges),
|
||||
naming the description and nothing else (the checker runs a repository's tests by their language's convention,
|
||||
ADR 0445, ADR 0529 §5). The rule is then narrowed to main's rule of ADR 0398 §2 (no push, only `mesh-tools` merges),
|
||||
requiring `mesh/check` (ADR 0447 §1, issue 1301), and only then is the repository moved into the organisation.
|
||||
There main's rule is read back and put right once more (the step `rule-after-move`): the forge drops from a merge
|
||||
list a user without access to the repository, and under the setup account `mesh-tools` has none;
|
||||
- one found still under the setup account is finished: the steps it lacks are done, main is never written twice;
|
||||
- one in the organisation is only put right: main's rule narrowed, `mesh/check` required in place of
|
||||
`mesh/merge-gate` and `mesh/repo-check`, and every other status already there kept. A main that requires
|
||||
`mesh/repo-check` without `mesh/merge-gate` is judged by its own script alone (hq, ADR 0503 §5, until issue 1296)
|
||||
and keeps it; `mesh/check` is never dropped.
|
||||
`mesh/repo-check` alone (hq's did, until issue 1296) is given `mesh/check` in its place too, since nothing writes
|
||||
`mesh/repo-check` once the merge check's second layer is gone (ADR 0527 §2); `mesh/check` is never dropped.
|
||||
|
||||
Every other repository of the organisation whose main requires `mesh/merge-gate` has the two old statuses swapped
|
||||
Every other repository of the organisation whose main requires `mesh/merge-gate` or `mesh/repo-check` has the old statuses swapped
|
||||
for `mesh/check`, and only its statuses (issue 1301): the switch reaches every repository the merge gate judged, with
|
||||
no list kept by hand. A step the forge refuses stops that repository, and the others go on. Beyond that swap, a
|
||||
repository taken out of the value is never changed: nothing is deleted, renamed, moved out or unprotected. The pass keeps what it did per repository in
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
// The tests of issue 1301 (ADR 0447 §1, §5; ADR 0503 §3, §5; lead decisions L4 and L11): main requires `mesh/check`
|
||||
// alone in place of `mesh/merge-gate` and `mesh/repo-check`; a main that requires `mesh/repo-check` without
|
||||
// `mesh/merge-gate` (hq) keeps it; every repository of the organisation whose main requires `mesh/merge-gate` is
|
||||
// `mesh/merge-gate` (hq) is swapped too (ADR 0527 §2); every repository of the organisation whose main requires either is
|
||||
// switched, listed in the value or not; no other status is dropped.
|
||||
|
||||
func narrowedRule(contexts ...any) map[string]any {
|
||||
@@ -84,30 +84,27 @@ func TestListedRuleRequiringNothingGetsCheck(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 1301-S2 (ADR 0503 §5): a main that requires mesh/repo-check without mesh/merge-gate is judged by its own script
|
||||
// alone (hq): it keeps mesh/repo-check and is not given mesh/check, listed or not.
|
||||
func TestOwnCheckAloneIsKept(t *testing.T) {
|
||||
// 1296/E1 (ADR 0527 §2): a main that requires mesh/repo-check alone (hq) is given mesh/check in its place, listed or
|
||||
// not, since nothing writes mesh/repo-check once build-agent is gone.
|
||||
func TestOwnCheckAloneIsSwapped(t *testing.T) {
|
||||
f := newFake()
|
||||
hq := f.add("novox/hq", map[string]map[string]any{mainRule: narrowedRule("mesh/repo-check")}, "main")
|
||||
lab := f.add("novox/mesh-lab", map[string]map[string]any{mainRule: narrowedRule("mesh/repo-check")}, "main")
|
||||
s, _ := syncer(t, f)
|
||||
o := run(t, s, Value{"hq": {}})
|
||||
for name, repo := range map[string]*fRepo{"hq": hq, "mesh-lab": lab} {
|
||||
if got := strs(repo.Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != "mesh/repo-check" {
|
||||
t.Errorf("%s requires %v, want mesh/repo-check alone", name, got)
|
||||
if got := strs(repo.Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != CheckStatus {
|
||||
t.Errorf("%s requires %v, want %s alone", name, got, CheckStatus)
|
||||
}
|
||||
}
|
||||
if result(o, "hq").Did != DidUnchanged {
|
||||
t.Errorf("hq outcome %+v, want unchanged", result(o, "hq"))
|
||||
}
|
||||
for _, w := range f.writes() {
|
||||
t.Errorf("a repository judged by its own check alone was written: %s", w)
|
||||
if result(o, "hq").Did == DidUnchanged {
|
||||
t.Errorf("hq outcome %+v, want a change", result(o, "hq"))
|
||||
}
|
||||
}
|
||||
|
||||
// 1301-S3 (L4, L11): a repository the value does not list whose main requires mesh/merge-gate has the two old
|
||||
// statuses swapped for mesh/check, and only its statuses: nothing else of its rule is sent. One that requires
|
||||
// neither, or mesh/repo-check alone, is not written to.
|
||||
// neither is not written to.
|
||||
func TestUnlistedRepositoryRequiringTheGateIsSwitched(t *testing.T) {
|
||||
f := newFake()
|
||||
catalog := f.add("novox/mesh-catalog", map[string]map[string]any{mainRule: {
|
||||
@@ -115,7 +112,7 @@ func TestUnlistedRepositoryRequiringTheGateIsSwitched(t *testing.T) {
|
||||
}}, "main")
|
||||
toolsGo := f.add("novox/mesh-tools-go", map[string]map[string]any{mainRule: narrowedRule("mesh/merge-gate")}, "main")
|
||||
f.add("novox/plain", map[string]map[string]any{mainRule: {"enable_push": false}}, "main")
|
||||
f.add("novox/hq", map[string]map[string]any{mainRule: narrowedRule("mesh/repo-check")}, "main")
|
||||
hq := f.add("novox/hq", map[string]map[string]any{mainRule: narrowedRule("mesh/repo-check")}, "main")
|
||||
f.add("novox/norule", nil, "main")
|
||||
f.add("mesh-admin/elsewhere", map[string]map[string]any{mainRule: narrowedRule("mesh/merge-gate")}, "main")
|
||||
s, _ := syncer(t, f)
|
||||
@@ -130,19 +127,23 @@ func TestUnlistedRepositoryRequiringTheGateIsSwitched(t *testing.T) {
|
||||
if got := strs(toolsGo.Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != CheckStatus {
|
||||
t.Errorf("mesh-tools-go requires %v, want %s", got, CheckStatus)
|
||||
}
|
||||
if got := strs(hq.Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != CheckStatus {
|
||||
t.Errorf("hq requires %v, want %s", got, CheckStatus)
|
||||
}
|
||||
want := []string{
|
||||
"PATCH /api/v1/repos/novox/hq/branch_protections/main",
|
||||
"PATCH /api/v1/repos/novox/mesh-catalog/branch_protections/main",
|
||||
"PATCH /api/v1/repos/novox/mesh-tools-go/branch_protections/main",
|
||||
}
|
||||
if got := f.writes(); strings.Join(got, "\n") != strings.Join(want, "\n") {
|
||||
t.Errorf("writes:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
|
||||
}
|
||||
for _, n := range []string{"mesh-catalog", "mesh-tools-go"} {
|
||||
for _, n := range []string{"hq", "mesh-catalog", "mesh-tools-go"} {
|
||||
if r := result(o, n); r.Did != DidPutRight {
|
||||
t.Errorf("%s outcome %+v, want put right", n, r)
|
||||
}
|
||||
}
|
||||
for _, n := range []string{"plain", "hq", "norule", "elsewhere"} {
|
||||
for _, n := range []string{"plain", "norule", "elsewhere"} {
|
||||
if r := result(o, n); r.Name != "" {
|
||||
t.Errorf("%s is in the outcome though nothing was done: %+v", n, r)
|
||||
}
|
||||
|
||||
@@ -2,34 +2,9 @@ package main
|
||||
|
||||
import "strings"
|
||||
|
||||
// MergeCheck is the first commit's merge-check.sh (ADR 0428 §4): it declares the Go toolchain (ADR 0238 §4, read
|
||||
// among a script's first lines by the build seat), and runs the unit tests, then with -tags integration the
|
||||
// integration tests, of every Go module in the tree; on a tree with none it says so and passes.
|
||||
const MergeCheck = `#!/bin/sh
|
||||
# mesh-check-toolchain: go
|
||||
#
|
||||
# This repository's own check (novox/hq ADR 0238 §4), run by the checker within mesh/check (ADR 0447), written with its first commit (ADR 0428 §4):
|
||||
# every Go module in the tree is vetted, its unit tests run, and then its integration tests (-tags integration).
|
||||
# A tree with no Go module has nothing to test, and the check says so and passes.
|
||||
set -eu
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
modules=$(find . -name go.mod -not -path '*/vendor/*' -not -path './.git/*' | sort)
|
||||
if [ -z "$modules" ]; then
|
||||
echo "This tree holds no Go module, so there are no tests to run; the check passes."
|
||||
exit 0
|
||||
fi
|
||||
for mod in $modules; do
|
||||
dir=$(dirname "$mod")
|
||||
echo "== $dir: go vet and the unit tests"
|
||||
(cd "$dir" && go vet ./... && go test ./...)
|
||||
echo "== $dir: the integration tests (-tags integration)"
|
||||
(cd "$dir" && go vet -tags integration ./... && go test -tags integration ./...)
|
||||
done
|
||||
`
|
||||
|
||||
// FirstCommit is main's first commit of a listed repository: its README, naming the repository's purpose, and
|
||||
// its own check.
|
||||
// nothing else: the checker runs a repository's tests by their language's convention (ADR 0445), so no script is written
|
||||
// (ADR 0529 §5).
|
||||
func FirstCommit(name string, r Repo) map[string]string {
|
||||
purpose := strings.TrimSpace(r.Description)
|
||||
if purpose == "" {
|
||||
@@ -38,6 +13,6 @@ func FirstCommit(name string, r Repo) map[string]string {
|
||||
readme := "# " + name + "\n\n" + purpose + "\n\n" +
|
||||
"Made by the mesh from the gitea module's config value `repositories` (novox/hq ADR 0428). Main takes no push: " +
|
||||
"a change reaches it by a pull request whose `mesh/check` passed: the checker's verdict on its head merged onto " +
|
||||
"main, this repository's `merge-check.sh` among its tests (novox/hq ADR 0447).\n"
|
||||
return map[string]string{"README.md": readme, "merge-check.sh": MergeCheck}
|
||||
"main, its tests run by their language's convention (novox/hq ADR 0447, ADR 0445).\n"
|
||||
return map[string]string{"README.md": readme}
|
||||
}
|
||||
|
||||
@@ -38,14 +38,10 @@ const (
|
||||
)
|
||||
|
||||
// wanted is what a main rule requiring have should require: have with GateStatus and OwnCheckStatus replaced by
|
||||
// CheckStatus, every other status kept in its order (ADR 0447 §1). A rule requiring OwnCheckStatus with neither
|
||||
// GateStatus nor CheckStatus is judged by its own script alone, as hq is until issue 1296 settles how hq is
|
||||
// checked (ADR 0503 §5): the checker writes no status there (ADR 0484 §8), so it is left as it is. This reads the
|
||||
// rule, not a list of names (lead decision L11 of R4e).
|
||||
// CheckStatus, every other status kept in its order (ADR 0447 §1). A rule requiring OwnCheckStatus alone, as hq's
|
||||
// did, is swapped too: the checker judges hq like any repository and nothing writes OwnCheckStatus once the merge
|
||||
// check's second layer is gone (ADR 0527 §2, issue 1296). This reads the rule, not a list of names (lead decision L11 of R4e).
|
||||
func wanted(have []string) []string {
|
||||
if !has(have, GateStatus) && !has(have, CheckStatus) && has(have, OwnCheckStatus) {
|
||||
return have
|
||||
}
|
||||
out := []string{}
|
||||
for _, s := range have {
|
||||
if s != GateStatus && s != OwnCheckStatus && s != CheckStatus {
|
||||
@@ -214,11 +210,11 @@ func (s *Syncer) Sync(ctx context.Context, v Value) (Outcome, error) {
|
||||
const orgPage = 50
|
||||
|
||||
// switchUnlisted swaps the two old statuses for CheckStatus on main's rule of every repository of the organisation
|
||||
// the value does not list whose main requires GateStatus (issue 1301, lead decisions L4 and L11 of R4e): the
|
||||
// the value does not list whose main requires GateStatus or OwnCheckStatus (issue 1301, lead decisions L4 and L11 of R4e): the
|
||||
// repositories the merge gate judges are switched in the delivery that has the checker write mesh/check (ADR 0447
|
||||
// §5), with no list kept by hand. Only the statuses of a rule covering main are sent; nothing else of a rule the
|
||||
// §5; hq's, ADR 0527 §2), with no list kept by hand. Only the statuses of a rule covering main are sent; nothing else of a rule the
|
||||
// value does not hold is changed (ADR 0428 §7 for all of it but the statuses the mesh itself requires). A main that
|
||||
// does not require GateStatus gets no call. It answers only what it did: a repository switched, or refused.
|
||||
// requires neither gets no call. It answers only what it did: a repository switched, or refused.
|
||||
func (s *Syncer) switchUnlisted(ctx context.Context, v Value) []Result {
|
||||
var names []string
|
||||
for page := 1; ; page++ {
|
||||
@@ -248,7 +244,7 @@ func (s *Syncer) switchUnlisted(ctx context.Context, v Value) []Result {
|
||||
}
|
||||
var did *Result
|
||||
for _, r := range rules {
|
||||
if ok, _ := path.Match(ruleName(r), MainBranch); !ok || !has(required(r), GateStatus) {
|
||||
if ok, _ := path.Match(ruleName(r), MainBranch); !ok || !(has(required(r), GateStatus) || has(required(r), OwnCheckStatus)) {
|
||||
continue
|
||||
}
|
||||
body := map[string]any{"enable_status_check": true, "status_check_contexts": wanted(required(r))}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -53,7 +52,7 @@ func TestNewRepositoryIsMadeInOrder(t *testing.T) {
|
||||
if repo.Private || repo.Description != "the mesh's delivery" {
|
||||
t.Errorf("made as %+v, want public with its description", repo)
|
||||
}
|
||||
if repo.Commits != 1 || len(repo.Files) != 2 || repo.Files["README.md"] == "" || repo.Files["merge-check.sh"] == "" {
|
||||
if repo.Commits != 1 || len(repo.Files) != 1 || repo.Files["README.md"] == "" {
|
||||
t.Errorf("first commit: %d commits, files %v", repo.Commits, keysOf(repo.Files))
|
||||
}
|
||||
if !strings.Contains(repo.Files["README.md"], "the mesh's delivery") {
|
||||
@@ -213,7 +212,7 @@ func TestRepositoryUnderTheSetupAccountIsFinished(t *testing.T) {
|
||||
}
|
||||
|
||||
// 1124/AC5 as issue 1301 changes it: putting a rule right adds mesh/check when the rule requires mesh/merge-gate,
|
||||
// and keeps every other status there; a rule requiring mesh/repo-check alone keeps it (ADR 0503 §5).
|
||||
// and keeps every other status there; a rule requiring mesh/repo-check alone is given mesh/check (ADR 0527 §2).
|
||||
func TestStatusesAreAddedAndNeverDropped(t *testing.T) {
|
||||
f := newFake()
|
||||
only := f.add("novox/only", map[string]map[string]any{mainRule: {
|
||||
@@ -227,7 +226,7 @@ func TestStatusesAreAddedAndNeverDropped(t *testing.T) {
|
||||
}}, "main")
|
||||
s, _ := syncer(t, f)
|
||||
o := run(t, s, Value{"only": {}, "more": {}})
|
||||
if c := strs(only.Rules[mainRule]["status_check_contexts"]); strings.Join(c, ",") != "mesh/repo-check" {
|
||||
if c := strs(only.Rules[mainRule]["status_check_contexts"]); strings.Join(c, ",") != CheckStatus {
|
||||
t.Errorf("only: %v", c)
|
||||
}
|
||||
if c := strs(more.Rules[mainRule]["status_check_contexts"]); len(c) != 2 || !has(c, "mesh/delivery") {
|
||||
@@ -409,66 +408,19 @@ func TestReadValue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 1124/AC4: the first commit's merge-check.sh declares its toolchain in its first lines, runs the tests of a Go
|
||||
// module in the tree, and on a tree with none says so and passes.
|
||||
func TestFirstCommitMergeCheck(t *testing.T) {
|
||||
// 1296/E1b (ADR 0527 §2, ADR 0529 §5): the first commit holds a README naming the purpose and no merge-check.sh: the
|
||||
// checker runs a repository's tests by their language's convention and no script is written for it.
|
||||
func TestFirstCommitHasNoMergeCheck(t *testing.T) {
|
||||
files := FirstCommit("mesh-delivery", Repo{Description: "the mesh's delivery"})
|
||||
script := files["merge-check.sh"]
|
||||
lines := strings.SplitN(script, "\n", 4)
|
||||
if lines[0] != "#!/bin/sh" || lines[1] != "# mesh-check-toolchain: go" {
|
||||
t.Fatalf("first lines %q", lines[:2])
|
||||
if _, has := files["merge-check.sh"]; has || len(files) != 1 {
|
||||
t.Fatalf("first commit files %v, want the README alone", keysOf(files))
|
||||
}
|
||||
if !strings.Contains(files["README.md"], "mesh-delivery") || !strings.Contains(files["README.md"], "the mesh's delivery") {
|
||||
t.Errorf("README %q", files["README.md"])
|
||||
}
|
||||
goBin, err := exec.LookPath("go")
|
||||
if err != nil {
|
||||
t.Skip("no go on PATH to run the script with")
|
||||
if strings.Contains(files["README.md"], "merge-check") {
|
||||
t.Errorf("the README names a merge-check.sh: %q", files["README.md"])
|
||||
}
|
||||
|
||||
tree := t.TempDir()
|
||||
mod := filepath.Join(tree, "sub")
|
||||
_ = os.MkdirAll(mod, 0o755)
|
||||
_ = os.WriteFile(filepath.Join(tree, "merge-check.sh"), []byte(script), 0o755)
|
||||
_ = os.WriteFile(filepath.Join(mod, "go.mod"), []byte("module tiny\n\ngo 1.21\n"), 0o644)
|
||||
_ = os.WriteFile(filepath.Join(mod, "tiny_test.go"), []byte(`package tiny
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRan(t *testing.T) { _ = os.WriteFile("ran", []byte("yes"), 0o644) }
|
||||
`), 0o644)
|
||||
cmd := exec.Command("sh", "merge-check.sh")
|
||||
cmd.Dir = tree
|
||||
cmd.Env = append(os.Environ(), "GOFLAGS=", "GOTOOLCHAIN=local", "PATH="+filepath.Dir(goBin)+":"+os.Getenv("PATH"))
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("on a tree with a Go module: %v\n%s", err, out)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(mod, "ran")); err != nil || !strings.Contains(string(out), "integration") {
|
||||
t.Fatalf("its tests did not run, or not with -tags integration:\n%s", out)
|
||||
}
|
||||
|
||||
// A failing test fails the check.
|
||||
_ = os.WriteFile(filepath.Join(mod, "fail_test.go"), []byte("package tiny\n\nimport \"testing\"\n\nfunc TestFails(t *testing.T) { t.Fatal(\"no\") }\n"), 0o644)
|
||||
if out, err := cmd2(tree, cmd.Env).CombinedOutput(); err == nil {
|
||||
t.Fatalf("a failing test passed the check:\n%s", out)
|
||||
}
|
||||
|
||||
empty := t.TempDir()
|
||||
_ = os.WriteFile(filepath.Join(empty, "merge-check.sh"), []byte(script), 0o755)
|
||||
out, err = cmd2(empty, cmd.Env).CombinedOutput()
|
||||
if err != nil || !strings.Contains(string(out), "no Go module") {
|
||||
t.Fatalf("on an empty tree: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func cmd2(dir string, env []string) *exec.Cmd {
|
||||
c := exec.Command("sh", "merge-check.sh")
|
||||
c.Dir, c.Env = dir, env
|
||||
return c
|
||||
}
|
||||
|
||||
func keysOf(m map[string]string) []string {
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
// commit's statuses: `mesh/merge-gate`, the modules of the mesh's graph the change touches, and
|
||||
// `mesh/repo-check`, the repository's own merge-check.sh (novox/hq ADR 0237 as amended); with a comment
|
||||
// saying why when the gate is not a pass or the repository's own check failed. Of the two, only those the base
|
||||
// still requires are written (issue 1301): main requires `mesh/check` — the checker's — everywhere but hq, whose main
|
||||
// requires `mesh/repo-check` alone (ADR 0503 §5).
|
||||
// still requires are written (issue 1301): main requires `mesh/check` — the checker's — everywhere, hq included since
|
||||
// ADR 0527 §2, so these are written only for a revert.
|
||||
//
|
||||
// Reads mesh-delivery-checker.owed — what the checker owes the forge, mesh/check on each head it judged — by a watch, and
|
||||
// writes it (check.ts; ADR 0521, R4e finding R2-1). No tool writes mesh/check (finding K1), and no event: the watch hands
|
||||
@@ -257,8 +257,8 @@ async function announceHead(client: GiteaClient, repo: RepoOf, pull: OpenOf): Pr
|
||||
console.log(`[gitea] announced ${key} at ${sha.slice(0, 8)} to be checked before it merges`);
|
||||
// Pending until the verdict comes, so the pull request says a check is running rather than nothing — and
|
||||
// where it is: in the build queue from now, until a build machine takes it (novox/hq issue 446). Only where the base
|
||||
// still requires mesh/merge-gate (issue 1301): a main that requires mesh/check is the checker's, and hq's requires
|
||||
// mesh/repo-check alone; a rule that cannot be read is said pending, as before.
|
||||
// still requires mesh/merge-gate (issue 1301): a main that requires mesh/check is the checker's;
|
||||
// a rule that cannot be read is said pending, as before.
|
||||
const rule = await client.appliedProtection(repo.owner, repo.name, String(pull.base || "main")).catch(() => undefined);
|
||||
if (writesGate(requiredOf(rule))) await gates.pending(repo.owner, repo.name, sha);
|
||||
return true;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//
|
||||
// **It judges on the status the base requires** (issue 1301; ADR 0447 §1, §4; ADR 0503 §5): mesh/check, the checker's,
|
||||
// whose line names the trunk it judged ("pass <head12> on <trunk12>"); where a base does not require it, the controller's
|
||||
// status it still requires (hq's mesh/repo-check), as before. A mesh/check of another trunk is refused and nothing is
|
||||
// status it still requires (only on a revert), as before. A mesh/check of another trunk is refused and nothing is
|
||||
// asked: the checker sets it out of date on the trunk's move and checks it again.
|
||||
|
||||
import { announce } from "./announce.js";
|
||||
@@ -37,7 +37,7 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
|
||||
return {
|
||||
name: "gitea_merge_pull_request",
|
||||
description: "Merge a pull request at the head you name — the commit you reviewed — when that is still its head " +
|
||||
"and every status its base branch's protection requires succeeded there (mesh/check; hq's mesh/repo-check), " +
|
||||
"and every status its base branch's protection requires succeeded there (mesh/check), " +
|
||||
"judged against the trunk's head as it is now, optionally deleting the source branch afterwards. Refused " +
|
||||
"otherwise, with nothing merged: no tool merges past a check, and none merges a head that moved after it was " +
|
||||
"reviewed. A verdict judged against an earlier trunk commit is refused, naming both, and the head's check against " +
|
||||
@@ -131,7 +131,7 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
|
||||
`ADR 0447); this tool asks nothing. Ask again at ${twelve(pull.head_sha)} once it passes. Nothing was merged`);
|
||||
}
|
||||
}
|
||||
// Where the base still requires the controller's statuses (hq's mesh/repo-check; mesh/merge-gate only on a
|
||||
// Where the base still requires the controller's status (mesh/merge-gate, only on a
|
||||
// revert), the first it requires judges, as before (ADR 0323 §5).
|
||||
const kept = maintained(required);
|
||||
const judge = kept[0] ?? CHECK_CONTEXT;
|
||||
@@ -161,7 +161,8 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
|
||||
// Pending first, so a second ask finds the check on its way; then the head is announced as a new head is. An
|
||||
// announcement that cannot be made puts the statuses back to out of date, so the next ask asks again rather
|
||||
// than wait for ever on a check nobody asked.
|
||||
// The statuses the base requires of the controller's (issue 1301): hq's mesh/repo-check alone.
|
||||
// The statuses the base requires of the controller's (issue 1301): the one that judges, and the repository's own
|
||||
// check beside it where a revert has the base require both.
|
||||
const contexts = kept.filter((c) => c === judge || (c === REPO_CHECK_CONTEXT && checks[REPO_CHECK_CONTEXT]));
|
||||
// Through the module's one writer of the merge check (gate.ts, novox/hq issue 456): the pending opens a new
|
||||
// check of the head, so where it runs is said ("building on …") and its verdict is not weighed as older.
|
||||
|
||||
@@ -172,7 +172,7 @@
|
||||
},
|
||||
"repositories": {
|
||||
"type": "object",
|
||||
"description": "The repositories of the organisation novox the mesh holds (ADR 0428): each by name, {\"<name>\": {\"private\": true, \"description\": \"...\"}}. One that does not exist is made by the module's setup login under its own account, its main protected and given a first commit (a README naming the description and a merge-check.sh), its rule narrowed to no push and the tools' merge requiring mesh/check, and only then moved into the organisation; one that exists only has main's rule put right, mesh/check in place of mesh/merge-gate and mesh/repo-check and its other statuses kept (a main requiring mesh/repo-check alone keeps it). Every other repository whose main requires mesh/merge-gate has the two swapped for mesh/check; nothing else of a repository out of the value is touched.",
|
||||
"description": "The repositories of the organisation novox the mesh holds (ADR 0428): each by name, {\"<name>\": {\"private\": true, \"description\": \"...\"}}. One that does not exist is made by the module's setup login under its own account, its main protected and given a first commit (a README naming the description), its rule narrowed to no push and the tools' merge requiring mesh/check, and only then moved into the organisation; one that exists only has main's rule put right, mesh/check in place of mesh/merge-gate and mesh/repo-check and its other statuses kept (a main requiring mesh/repo-check alone is swapped too, ADR 0527 §2). Every other repository whose main requires mesh/merge-gate or mesh/repo-check has the old statuses swapped for mesh/check; nothing else of a repository out of the value is touched.",
|
||||
"trusted": true,
|
||||
"default": {}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// A branch's protection (novox/hq ADR 0237): what makes a pull request wait for the mesh's check — `mesh/check`,
|
||||
// the checker's verdict on the head merged onto its trunk (ADR 0447 §1; hq's `mesh/repo-check` until issue 1296) —
|
||||
// the checker's verdict on the head merged onto its trunk (ADR 0447 §1, hq's too since ADR 0527 §2) —
|
||||
// before it may merge. Pure, so it is tested without a forge; the client does the asking (client.ts).
|
||||
|
||||
import { TOOLS_ACCOUNT } from "./tools-account.js";
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// half of the provisioner around it — pure of the forge's address, so it is tested with a stand-in.
|
||||
//
|
||||
// **Two teams, by what the consumer said it needs** (novox/hq issue 624). A consumer that publishes —
|
||||
// the build agent, whose builds publish the SDK and every TypeScript module's packages — joins `packages`:
|
||||
// the build modules, whose builds publish the SDK and every TypeScript module's packages — joins `packages`:
|
||||
// code read on the org's repositories and write on its packages. A consumer that contributes
|
||||
// `{"access": "read"}` joins `readers`: code read and package read, and no write. The lab is the first: it
|
||||
// clones branches that are not yet reviewed and runs their code beside its credential, and code that is
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
//
|
||||
// Since issue 1301 (ADR 0447 §4) a main that requires mesh/check is the checker's: its line names the trunk commit it judged
|
||||
// ("pass <head12> on <trunk12>"), the checker sets it out of date when the trunk moves, and the merge tool refuses a verdict
|
||||
// of another trunk and asks nothing. The two statuses above are kept only where a base still requires them (hq's
|
||||
// mesh/repo-check, pulls.ts maintained).
|
||||
// of another trunk and asks nothing. The two statuses above are kept only where a base still requires them (a revert,
|
||||
// pulls.ts maintained).
|
||||
//
|
||||
// Pure functions and two small files of the module's own state, so this is tested without a forge.
|
||||
|
||||
@@ -234,14 +234,11 @@ export async function announcementOf(client: GiteaClient, owner: string, name: s
|
||||
const files = await client.listPullFiles(owner, name, pull.number);
|
||||
const head = String(pull.head_sha);
|
||||
// What the controller maps the change onto the mesh's module graph with (novox/hq ADR 0237 as amended): which
|
||||
// changed directories hold a module at the head — the merge's rule (issue 278) — and whether the head holds the
|
||||
// repository's own merge-check.sh. Not said when it could not be read; the controller then reads the change as
|
||||
// changed directories hold a module at the head — the merge's rule (issue 278). Not said when it could not be read; the controller then reads the change as
|
||||
// touching everything built from the repository.
|
||||
let moduleDirs: string[] | null = null;
|
||||
let mergeCheck: boolean | null = null;
|
||||
try {
|
||||
if (!files.truncated) moduleDirs = await client.moduleDirsAt(owner, name, head, files.paths);
|
||||
mergeCheck = await client.holdsFile(owner, name, head, "merge-check.sh");
|
||||
} catch (err) {
|
||||
console.error(`[gitea] ${owner}/${name}#${pull.number}: what the head holds could not be read — ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
@@ -264,6 +261,5 @@ export async function announcementOf(client: GiteaClient, owner: string, name: s
|
||||
paths_truncated: files.truncated,
|
||||
removed: files.removed,
|
||||
...(moduleDirs ? { module_dirs: moduleDirs, module_dirs_said: true } : {}),
|
||||
...(mergeCheck !== null ? { merge_check: mergeCheck, merge_check_said: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ export async function npmRegistry(forge: string, get: typeof fetch = fetch, publ
|
||||
* The forge credential a run's clones may present, as a git credential-store line —
|
||||
* scheme://user:password@host[:port] — for the forge's own origin, or undefined when the lab holds none.
|
||||
*
|
||||
* **The mesh's credential for the forge, the way the build agent holds it** (novox/hq issue 624): the lab
|
||||
* **The mesh's credential for the forge, the way the build modules hold it** (novox/hq issue 624): the lab
|
||||
* requires the package registry, the forge's provisioner binds it an account (`as`) and seals that
|
||||
* account's password, and the forge that answers npm is the forge that hosts the repositories. Private
|
||||
* repositories (mesh-tools-go) are cloned with it; public ones clone as before. Written to a file git reads
|
||||
|
||||
@@ -3,14 +3,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0505 §4 over the real bus, in the bed of bus_test.go: the gate whole, its deliveries kept in its buckets.
|
||||
@@ -33,56 +29,3 @@ func (r *runtime) call(t *testing.T, name string, args map[string]any) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ADR 0505 §4: an approved head the merge tool refused because its trunk moved is shown by `show` at once but is not in
|
||||
// `stalled` while its fresh check is unanswered; once that check passed and the merge is refused again, it is.
|
||||
func TestATrunkMovedRefusalIsStalledOnlyOnceItsFreshCheckDidNotCureItOverARealBus(t *testing.T) {
|
||||
b := newTestBus(t)
|
||||
ctx := context.Background()
|
||||
rt := b.start(t)
|
||||
evctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
rt.deliverEvents(evctx)
|
||||
rt.until("the module to subscribe to the events it follows", 20*time.Second, func() bool {
|
||||
rt.mu.Lock()
|
||||
defer rt.mu.Unlock()
|
||||
return len(rt.subs) >= len(followed)
|
||||
})
|
||||
rt.untilHolding()
|
||||
|
||||
const head = "1111111111111111111111111111111111111111"
|
||||
id := IDOf("novox/app", head)
|
||||
announced := pr("novox/app", 7, head, "fix/x", "src/a.go")
|
||||
announced.BaseSHA = "t1t1t1t1t1t1"
|
||||
b.publish(t, "mesh.mod.gitea.event.pull.updated", announced)
|
||||
b.publish(t, "mesh.seat."+ControllerSeat+".event.checked", judgedAt("novox/app", 7, head, "pass", "t1t1t1t1t1t1"))
|
||||
var key string
|
||||
rt.until("the ask to be written", 10*time.Second, func() bool { key = onlyAsk(b); return key != "" })
|
||||
b.answer(t, key, OptionApprove)
|
||||
rt.until("the merge to be handed to the bridge", 10*time.Second, func() bool { return len(mergeAt(b, "fix/x").Pulls) > 0 })
|
||||
|
||||
refused := func() {
|
||||
b.forgeSays(t, sdk.Forge{Kind: sdk.ForgeRefused, Act: "merge", Repository: "novox/app", Number: 7, Head: head,
|
||||
Words: errAskedAgain.Error()})
|
||||
}
|
||||
refused()
|
||||
rt.until("show to name the refusal at once", 10*time.Second, func() bool {
|
||||
return strings.Contains(rt.call(t, Seat+".show", map[string]any{"id": id}), "its verdict was judged against trunk")
|
||||
})
|
||||
if got := rt.call(t, Seat+".stalled", map[string]any{}); strings.Contains(got, id) {
|
||||
t.Fatalf("said as stalled while its fresh check runs: %s", got)
|
||||
}
|
||||
|
||||
// The trunk moved; the head is announced again and its fresh check passes; the merge is refused again on it.
|
||||
b.publish(t, "mesh.mod.gitea.event.pull.merged", merged("novox/app", 9, "9999999999999999999999999999999999999999", "t2t2t2t2t2t2"))
|
||||
b.publish(t, "mesh.mod.gitea.event.pull.updated", announced)
|
||||
time.Sleep(time.Second)
|
||||
b.publish(t, "mesh.seat."+ControllerSeat+".event.checked", judgedAt("novox/app", 7, head, "pass", "t2t2t2t2t2t2"))
|
||||
rt.until("the fresh verdict to be heard", 10*time.Second, func() bool {
|
||||
return strings.Contains(rt.call(t, Seat+".show", map[string]any{"id": id}), "t2t2t2t2t2t2")
|
||||
})
|
||||
refused()
|
||||
rt.until("the refusal on the fresh verdict to be said as stalled", 10*time.Second, func() bool {
|
||||
return strings.Contains(rt.call(t, Seat+".stalled", map[string]any{}), id)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -95,39 +95,6 @@ func linesFor(w *world, id string) []StalledLine {
|
||||
return out
|
||||
}
|
||||
|
||||
// ADR 0505 §4: a refusal because the trunk moved, whose check the gate asked again, is in the gate's own list at once
|
||||
// but is no stalled line while its check is unanswered; once that check passed and the merge is refused again, it is.
|
||||
func TestATrunkMovedRefusalIsStalledOnlyOnceItsFreshCheckAnsweredAndTheMergeIsRefusedAgain(t *testing.T) {
|
||||
w, id := refusedForAMovedTrunk(t)
|
||||
if got := linesFor(w, id); len(got) != 0 {
|
||||
t.Fatalf("said as stalled while its fresh check runs: %+v", got)
|
||||
}
|
||||
shown, err := w.h.Show(id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if raw, _ := json.Marshal(shown); !strings.Contains(string(raw), "is not merged: its verdict was judged against trunk") {
|
||||
t.Fatalf("the gate's own list does not show the refusal at once: %s", raw)
|
||||
}
|
||||
w.later(5 * time.Minute)
|
||||
if got := linesFor(w, id); len(got) != 0 {
|
||||
t.Fatalf("said as stalled at five minutes, its check unanswered: %+v", got)
|
||||
}
|
||||
// As on the live mesh (events_test.go): the trunk moved, the head is announced again, its fresh verdict passes
|
||||
// against the new trunk, and the merge is asked and refused again, the trunk having moved once more.
|
||||
w.h.PullMerged(merged("novox/app", 9, "9999999999999999", "t2t2t2t2t2t2"))
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
w.later(time.Second)
|
||||
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t2t2t2t2t2t2"))
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 2 {
|
||||
t.Fatalf("the fresh verdict did not ask the merge again: %v", w.forge.merges)
|
||||
}
|
||||
if got := linesFor(w, id); len(got) != 1 {
|
||||
t.Fatalf("its check answered and the merge refused again, yet no stalled line: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0505 §4: with no answer, the refusal is said at twice the repository's last check time, at least ten minutes.
|
||||
func TestATrunkMovedRefusalIsStalledAtTwiceTheLastCheckTimeAtLeastTheFloor(t *testing.T) {
|
||||
w, id := refusedForAMovedTrunk(t)
|
||||
@@ -180,9 +147,15 @@ func rejectedAfterItsApprove(t *testing.T, pushedBy string) (*world, string) {
|
||||
w.settleAll()
|
||||
id := IDOf("novox/app", readyHead)
|
||||
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
if _, err := w.h.Recheck(id, "a second look", "tester"); err != nil {
|
||||
// Judged again (the table's recheck, which the checker's verdict on a new trunk makes): proposed once more.
|
||||
w.h.mu.Lock()
|
||||
d := w.h.deliveries[id]
|
||||
if _, err := Apply(d, EvRecheck, true, Facts{Now: w.h.Now(), Why: "a second look", By: "tester"}); err != nil {
|
||||
w.h.mu.Unlock()
|
||||
t.Fatal(err)
|
||||
}
|
||||
d.Check = nil
|
||||
w.h.mu.Unlock()
|
||||
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "fail", "t1t1t1t1t1t1"))
|
||||
approve(w)
|
||||
w.h.React()
|
||||
|
||||
@@ -380,19 +380,18 @@ func (h *Holder) approval(d *Delivery, now time.Time) (string, func()) {
|
||||
// A group is not asked while a member's required status is not a success at its head (issue 1275): the group
|
||||
// says which, and is asked once every member may merge.
|
||||
var holdMember, holdSays string
|
||||
var holdOwn bool
|
||||
var readErr error
|
||||
for _, m := range reads {
|
||||
if gid == "" {
|
||||
break
|
||||
}
|
||||
says, ownOnly, err := h.notASuccess(m)
|
||||
says, err := h.notASuccess(m)
|
||||
if err != nil {
|
||||
readErr = err
|
||||
break
|
||||
}
|
||||
if says != "" {
|
||||
holdMember, holdSays, holdOwn = m.id, says, ownOnly
|
||||
holdMember, holdSays = m.id, says
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -422,18 +421,7 @@ func (h *Holder) approval(d *Delivery, now time.Time) (string, func()) {
|
||||
if readErr != nil || holdSays != "" {
|
||||
stale()
|
||||
h.mu.Lock()
|
||||
var own func()
|
||||
// A member's own repository check pending or missing (a sibling's new head put it out of date, issue 1792) is
|
||||
// asked again here, once for the head: nothing else asks it, and the composed check sets no repository check.
|
||||
if d := h.deliveries[holdMember]; holdOwn && d != nil && d.Ready != nil {
|
||||
own = h.askOwnAfterRefusal(d)
|
||||
}
|
||||
defer func() {
|
||||
h.mu.Unlock()
|
||||
if own != nil {
|
||||
own()
|
||||
}
|
||||
}()
|
||||
defer h.mu.Unlock()
|
||||
undo()
|
||||
if g := h.groups[gid]; g != nil && holdSays != "" {
|
||||
if g.Hold == nil || g.Hold.Kind != "unasked" || g.Hold.Says != holdSays || g.Hold.Member != holdMember {
|
||||
|
||||
@@ -339,21 +339,18 @@ func (r *runtime) answer(m rpcMessage) {
|
||||
case "seat:" + ControllerSeat + ".delivery-plan":
|
||||
return toolAnswer(map[string]any{"plan": DeliveryPlan{Repository: "novox/app", Moved: []string{"app"},
|
||||
Summary: "builds app, sent to the node"}}), nil
|
||||
case "seat:" + ControllerSeat + ".delivery-check":
|
||||
return toolAnswer(map[string]any{"asked": "check-1"}), nil
|
||||
case "gitea.gitea_open_pulls":
|
||||
return toolAnswer(map[string]any{"pulls": pulls}), nil
|
||||
case "gitea.gitea_commit_statuses":
|
||||
// Green: both merge checks a success at the head asked, as the gitea module answers.
|
||||
// Green: mesh/check a success at the head asked, as the gitea module answers.
|
||||
sha, _ := a.Body["sha"].(string)
|
||||
if sha == "" {
|
||||
sha = "0000000"
|
||||
}
|
||||
return toolAnswer(map[string]any{"commit": sha, "statuses": []any{
|
||||
map[string]any{"context": "mesh/merge-gate", "state": "success"},
|
||||
map[string]any{"context": "mesh/repo-check", "state": "success"}},
|
||||
"merge": map[string]any{"branch": "main", "protected": true, "required": []string{"mesh/merge-gate", "mesh/repo-check"},
|
||||
"blocking": []any{}, "mergeable": true, "says": "main requires mesh/merge-gate, mesh/repo-check"}}), nil
|
||||
map[string]any{"context": "mesh/check", "state": "success"}},
|
||||
"merge": map[string]any{"branch": "main", "protected": true, "required": []string{"mesh/check"},
|
||||
"blocking": []any{}, "mergeable": true, "says": "main requires mesh/check"}}), nil
|
||||
case "gitea.gitea_note_append", "gitea.gitea_delivery_view", "gitea.gitea_commit_status", "gitea.gitea_merge_pull_request",
|
||||
"gitea.gitea_add_comment":
|
||||
return toolAnswer(map[string]any{}), nil
|
||||
@@ -513,11 +510,11 @@ func TestAPassingVerdictAsksTheApproveAndItsApproveMergesWithinSecondsOverARealB
|
||||
// A pull request into main is announced, and the controller's verdict on its head passes.
|
||||
const head = "1111111111111111111111111111111111111111"
|
||||
announced := pr("novox/app", 7, head, "fix/x", "src/a.go")
|
||||
announced.BaseSHA = "t1t1t1t1t1t1"
|
||||
announced.BaseSHA = "a1a1a1a1a1a1"
|
||||
announced.Title = "Fix the apostrophe (issue 975)"
|
||||
announced.Body = "The description, with no review line yet."
|
||||
b.publish(t, "mesh.mod.gitea.event.pull.updated", announced)
|
||||
passed := b.publish(t, "mesh.seat."+ControllerSeat+".event.checked", judgedAt("novox/app", 7, head, "pass", "t1t1t1t1t1t1"))
|
||||
b.checkPasses(t, rt, "fix/x", "a1a1a1a1a1a1", judgedPull{"novox/app", 7, head})
|
||||
|
||||
// No review names the head, so nothing is asked (ADR 0496, issue 1803): the delivery waits for a review.
|
||||
rt.until("the delivery to wait for a review", eventWithin, func() bool {
|
||||
@@ -535,7 +532,6 @@ func TestAPassingVerdictAsksTheApproveAndItsApproveMergesWithinSecondsOverARealB
|
||||
edit := time.Now()
|
||||
b.publish(t, "mesh.mod.gitea.event.forge.pull-request.edited", map[string]any{"owner": "novox", "repo": "app", "number": 7,
|
||||
"title": announced.Title, "head_sha": head, "base": "main", "body": "Reviewed-by: lead@shanks at " + head})
|
||||
_ = passed
|
||||
var key string
|
||||
rt.until("the ask to be written", eventWithin, func() bool { key = onlyAsk(b); return key != "" })
|
||||
ask := askAt(t, b, key)
|
||||
@@ -550,7 +546,7 @@ func TestAPassingVerdictAsksTheApproveAndItsApproveMergesWithinSecondsOverARealB
|
||||
rt.until("the pull request's key to hold head, trunk and verdict", eventWithin, func() bool {
|
||||
e, err := b.kvs["pulls"].Get(ctx, "novox.app.7")
|
||||
var k PullKey
|
||||
return err == nil && json.Unmarshal(e.Value(), &k) == nil && k == PullKey{Head: head, Trunk: "t1t1t1t1t1t1", Verdict: "pass"}
|
||||
return err == nil && json.Unmarshal(e.Value(), &k) == nil && k == PullKey{Head: head, Trunk: "a1a1a1a1a1a1", Verdict: "pass"}
|
||||
})
|
||||
|
||||
// What the transitions owe the forge is written as owed keys; the gate calls no tool of the forge that writes (issue 1349).
|
||||
@@ -698,34 +694,16 @@ func TestAGroupOfTwoWithGreenChecksGetsOneAskAndOnItsApproveBothMergeOneAfterThe
|
||||
const ctlHead, catHead = "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", "d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2"
|
||||
for _, p := range []PullEvent{pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go"),
|
||||
pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json")} {
|
||||
p.BaseSHA = "t1t1t1t1t1t1"
|
||||
p.BaseSHA = "a1a1a1a1a1a1"
|
||||
p.Body = "Reviewed-by: lead@shanks at " + p.HeadSHA
|
||||
b.publish(t, "mesh.mod.gitea.event.pull.updated", p)
|
||||
}
|
||||
b.publish(t, "mesh.seat."+ControllerSeat+".event.checked", judgedAt("novox/mesh-controller", 1, ctlHead, "pass", "t1t1t1t1t1t1"))
|
||||
b.publish(t, "mesh.seat."+ControllerSeat+".event.checked", judgedAt("novox/mesh-catalog", 2, catHead, "pass", "t1t1t1t1t1t1"))
|
||||
|
||||
// Both members green: the composed check is asked, once, for both.
|
||||
// Each member's own check is asked at once (issue 1115), before the group's: wait for the one carrying the group.
|
||||
rt.until("the composed check to be asked", 10*time.Second, func() bool {
|
||||
for _, c := range rt.asksOf("seat:" + ControllerSeat + ".delivery-check") {
|
||||
if c.Body["group"] == "feat/x" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
// Both members pass at the checker, whose one verdict covers both heads: the group is asked once, no composed check is asked
|
||||
// of the controller (issue 1318).
|
||||
b.checkPasses(t, rt, "feat/x", "a1a1a1a1a1a1", judgedPull{"novox/mesh-controller", 1, ctlHead}, judgedPull{"novox/mesh-catalog", 2, catHead})
|
||||
if n := len(rt.asksOf("seat:" + ControllerSeat + ".delivery-order")); n != 0 {
|
||||
t.Fatalf("the controller's delivery-order was asked %d times: no order is asked for", n)
|
||||
}
|
||||
if keys, _ := b.kvs["asks"].Keys(ctx); len(keys) != 0 {
|
||||
t.Fatalf("the operator was asked before the group's composed check passed: %v", keys)
|
||||
}
|
||||
// The composed check passes at both heads: the operator is asked once, for the group.
|
||||
b.publish(t, "mesh.seat."+ControllerSeat+".event.checked", map[string]any{"group": "feat/x", "id": "check-1", "verdict": "pass",
|
||||
"summary": "both compose", "members": []map[string]any{
|
||||
{"owner": "novox", "repo": "mesh-controller", "commit": ctlHead, "trunk": "t1t1t1t1t1t1"},
|
||||
{"owner": "novox", "repo": "mesh-catalog", "commit": catHead, "trunk": "t1t1t1t1t1t1"}}})
|
||||
var key string
|
||||
rt.until("the operator to be asked once for the group", 10*time.Second, func() bool { key = onlyAsk(b); return key != "" })
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
@@ -796,7 +774,7 @@ func TestAPlanMovedCarryingAFailedBackupHoldsTheDeliveryWithTheBackupsWordsOverA
|
||||
rt.untilHolding()
|
||||
const head, merge = "3333333333333333333333333333333333333333", "4444444444444444444444444444444444444444"
|
||||
announced := pr("novox/app", 9, head, "fix/y", "src/a.go")
|
||||
announced.BaseSHA = "t1t1t1t1t1t1"
|
||||
announced.BaseSHA = "a1a1a1a1a1a1"
|
||||
deliveryOf := func() Delivery {
|
||||
e, err := b.kvs["deliveries"].Get(context.Background(), kvKey(IDOf("novox/app", head)))
|
||||
var d Delivery
|
||||
@@ -809,7 +787,7 @@ func TestAPlanMovedCarryingAFailedBackupHoldsTheDeliveryWithTheBackupsWordsOverA
|
||||
// may hand over a later one first, which the table would rightly judge on its own (merged before checked).
|
||||
b.publish(t, "mesh.mod.gitea.event.pull.updated", announced)
|
||||
rt.until("the pull request to be taken", 10*time.Second, func() bool { return deliveryOf().State != "" })
|
||||
b.publish(t, "mesh.seat."+ControllerSeat+".event.checked", judgedAt("novox/app", 9, head, "pass", "t1t1t1t1t1t1"))
|
||||
b.checkPasses(t, rt, "fix/y", "a1a1a1a1a1a1", judgedPull{"novox/app", 9, head})
|
||||
rt.until("the verdict to be taken", 10*time.Second, func() bool { d := deliveryOf(); return d.State != "" && d.State != Proposed })
|
||||
b.publish(t, "mesh.mod.gitea.event.pull.merged", merged("novox/app", 9, head, merge))
|
||||
rt.until("the merge to be taken", 10*time.Second, func() bool { return deliveryOf().MergedAs != "" })
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A delivery that waits for its check gets one (novox/hq issue 290): a head adopted while it already waited —
|
||||
// its announcement heard again from the bus's history, which the controller took long ago — has its check asked
|
||||
// by this owner; so does one whose announcement the controller missed. Once asked, the proposed bound means
|
||||
// "asked and not answered", and healer H2 may ask it once more before it is the operator's.
|
||||
|
||||
// The live case: a pull request opened before merge checks existed, announced again at this owner's first
|
||||
// start, read back proposed after a restart. Its check is asked at the first tick, once, for that head alone.
|
||||
func TestAProposedDeliveryAdoptedWithNoVerdictHasItsCheckAsked(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf("novox/hq", head)
|
||||
w.h.PullUpdated(pr("novox/hq", 94, head, "issues/245-246", "04-ISSUES/245/00-report.md"))
|
||||
w.settleAll()
|
||||
if len(w.ctl.checks) != 1 {
|
||||
t.Fatalf("a head just announced was not asked at once: %v", w.ctl.checks)
|
||||
}
|
||||
|
||||
// A restart, a minute later: read back proposed with its ask kept; it is not asked again.
|
||||
again := newWorldOver(t, w.store, w.ctl, w.forge, w.now.Add(time.Minute))
|
||||
if s := again.state(id); s != Proposed {
|
||||
t.Fatalf("read back, it is %s", s)
|
||||
}
|
||||
again.settleAll()
|
||||
if len(w.ctl.checks) != 1 || w.ctl.checks[0] != ":"+id {
|
||||
t.Fatalf("its check was asked %v, wanted once for its head alone", w.ctl.checks)
|
||||
}
|
||||
d := again.delivery(id)
|
||||
if d.CheckAsk == nil || d.CheckAsk.ID != "check-1" || d.CheckAsk.By != byOwner || d.State != Proposed {
|
||||
t.Fatalf("the ask is not kept: %+v (%s)", d.CheckAsk, d.State)
|
||||
}
|
||||
if notes := w.forge.notesOn(head); len(notes) == 0 || !strings.Contains(notes[len(notes)-1], "its check asked") {
|
||||
t.Fatalf("the ask is not noted on the head: %q", notes)
|
||||
}
|
||||
// Asked, it is not stalled: the bound runs from the ask.
|
||||
if st := again.h.Stalled(); len(st) != 0 {
|
||||
t.Fatalf("a check just asked is stalled: %+v", st)
|
||||
}
|
||||
// Asked once: the ticks after do not ask again.
|
||||
again.later(30 * time.Minute)
|
||||
again.settleAll()
|
||||
if len(w.ctl.checks) != 1 {
|
||||
t.Fatalf("a check asked and in flight was asked again: %v", w.ctl.checks)
|
||||
}
|
||||
// The verdict arrives: checked, then ready, by itself.
|
||||
again.h.Checked(verdict("novox/hq", 94, head, "pass"))
|
||||
if s := again.state(id); s != Ready {
|
||||
t.Fatalf("its verdict left it %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
// The same head adopted at a first start with no restart: its check is asked at once, with no grace (issue 1115).
|
||||
func TestAMissedAnnouncementHasItsCheckAskedAtOnce(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf("novox/app", head)
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
// The controller does not answer: nothing is kept as asked, and it is tried again a minute on.
|
||||
w.ctl.down = true
|
||||
w.settleAll()
|
||||
if d := w.delivery(id); d.CheckAsk != nil {
|
||||
t.Fatalf("an ask the controller did not take is kept as asked: %+v", d.CheckAsk)
|
||||
}
|
||||
w.ctl.down = false
|
||||
w.settleAll()
|
||||
if len(w.ctl.checks) != 0 {
|
||||
t.Fatalf("asked again within the minute: %v", w.ctl.checks)
|
||||
}
|
||||
w.later(time.Minute)
|
||||
w.settleAll()
|
||||
if len(w.ctl.checks) != 1 || w.delivery(id).CheckAsk == nil {
|
||||
t.Fatalf("a missed announcement was not asked: %v", w.ctl.checks)
|
||||
}
|
||||
// A verdict that arrived in the meantime, or a delivery past its proposal, is never asked.
|
||||
other := IDOf("novox/app", "bbbbbbbbbbbb2222")
|
||||
w.h.PullUpdated(pr("novox/app", 8, "bbbbbbbbbbbb2222", "feat/y"))
|
||||
w.h.Checked(verdict("novox/app", 8, "bbbbbbbbbbbb2222", "fail"))
|
||||
w.later(time.Hour)
|
||||
w.settleAll()
|
||||
if w.state(other) != Rejected || len(w.ctl.checks) != 1 {
|
||||
t.Fatalf("a head with a verdict was asked: %s %v", w.state(other), w.ctl.checks)
|
||||
}
|
||||
}
|
||||
|
||||
// An announcement heard again carrying its head's decided verdict takes it; a pending one does not.
|
||||
func TestAnAnnouncementCarryingItsHeadsVerdictTakesIt(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
p := pr("novox/app", 7, head, "feat/x", "src/a.go")
|
||||
p.HeadChecks = map[string]string{"mesh/merge-gate": "success", "mesh/repo-check": "success"}
|
||||
w.h.PullUpdated(p)
|
||||
if s := w.state(IDOf("novox/app", head)); s != Ready {
|
||||
t.Fatalf("a head whose gate passed on the forge is %s", s)
|
||||
}
|
||||
q := pr("novox/app", 8, "bbbbbbbbbbbb2222", "feat/y")
|
||||
q.HeadChecks = map[string]string{"mesh/merge-gate": "pending"}
|
||||
w.h.PullUpdated(q)
|
||||
if s := w.state(IDOf("novox/app", "bbbbbbbbbbbb2222")); s != Proposed {
|
||||
t.Fatalf("a pending gate on the forge left it %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
// Asked and not answered past the bound: stalled, and H2's close re-asks once — the second close is refused
|
||||
// by the table, and the delivery is the operator's.
|
||||
func TestH2ReasksAStalledCheckOnceThenItIsTheOperators(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf("novox/app", head)
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
w.settleAll()
|
||||
if len(w.ctl.checks) != 1 {
|
||||
t.Fatalf("asked %v", w.ctl.checks)
|
||||
}
|
||||
if _, err := w.h.Close(id, "too early"); err == nil {
|
||||
t.Fatal("re-asked within the bound")
|
||||
}
|
||||
w.later(Bounds[Proposed].For + time.Minute)
|
||||
st := w.h.Stalled()
|
||||
if len(st) != 1 || st[0].ID != id || !strings.Contains(st[0].H2, "re-ask") {
|
||||
t.Fatalf("stalled %+v", st)
|
||||
}
|
||||
// The controller does not take the re-ask: nothing is spent.
|
||||
w.ctl.down = true
|
||||
if _, err := w.h.Close(id, "delivery stalled"); err == nil {
|
||||
t.Fatal("a re-ask the controller did not take was said done")
|
||||
}
|
||||
if d := w.delivery(id); d.CheckAsk.Reasked != 0 {
|
||||
t.Fatal("a re-ask that was not made was spent")
|
||||
}
|
||||
w.ctl.down = false
|
||||
said, err := w.h.Close(id, "delivery stalled")
|
||||
if err != nil || !strings.Contains(said, "re-asked") {
|
||||
t.Fatalf("H2's close: %q %v", said, err)
|
||||
}
|
||||
d := w.delivery(id)
|
||||
if len(w.ctl.checks) != 2 || d.State != Proposed || d.CheckAsk.Reasked != 1 || d.CheckAsk.By != byH2 ||
|
||||
d.Transitions[len(d.Transitions)-1].Event != EvReask {
|
||||
t.Fatalf("after the re-ask: %v %s %+v", w.ctl.checks, d.State, d.CheckAsk)
|
||||
}
|
||||
if len(w.h.Stalled()) != 0 {
|
||||
t.Fatal("a check just re-asked is stalled")
|
||||
}
|
||||
// Still not answered: stalled again, and the operator's.
|
||||
w.later(Bounds[Proposed].For + time.Minute)
|
||||
st = w.h.Stalled()
|
||||
if len(st) != 1 || !strings.Contains(st[0].H2, "operator's") {
|
||||
t.Fatalf("stalled after the re-ask %+v", st)
|
||||
}
|
||||
if _, err := w.h.Close(id, "delivery stalled"); err == nil || !strings.Contains(err.Error(), "once already") {
|
||||
t.Fatalf("a second re-ask: %v", err)
|
||||
}
|
||||
if len(w.ctl.checks) != 2 {
|
||||
t.Fatalf("a second re-ask reached the controller: %v", w.ctl.checks)
|
||||
}
|
||||
// The verdict, late, still settles it.
|
||||
w.h.Checked(verdict("novox/app", 7, head, "pass"))
|
||||
if s := w.state(id); s != Ready {
|
||||
t.Fatalf("a late verdict left it %s", s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// ctxGate and ctxRepo are the two statuses mesh/check replaced: only a base that a revert gives them again requires them
|
||||
// (unchecked.go mergeChecks), so only tests of that name them.
|
||||
const (
|
||||
ctxGate = "mesh/merge-gate"
|
||||
ctxRepo = "mesh/repo-check"
|
||||
)
|
||||
|
||||
// CheckedEvent is a verdict as these tests state it. The gate follows no verdict of the controller any more (issue 1318,
|
||||
// ADR 0524 §5): the shim below says it the way the mesh says it now, as the head's mesh/check and the checker's verdict at
|
||||
// the heads, so the table, the Approve and the merges are exercised on the one path they have.
|
||||
type CheckedEvent struct {
|
||||
Owner string
|
||||
Repo string
|
||||
Number int
|
||||
Commit string
|
||||
Verdict string
|
||||
Summary string
|
||||
ID string
|
||||
On string
|
||||
Report string
|
||||
Gate *struct {
|
||||
Verdict string `json:"verdict"`
|
||||
Summary string `json:"summary"`
|
||||
Modules []string `json:"modules,omitempty"`
|
||||
Dependents []string `json:"dependents,omitempty"`
|
||||
}
|
||||
RepoCheck *struct {
|
||||
Verdict string `json:"verdict"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
Plan *DeliveryPlan
|
||||
Group string
|
||||
Members []struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
Commit string `json:"commit"`
|
||||
Trunk string `json:"trunk,omitempty"`
|
||||
}
|
||||
Trunk string
|
||||
Beside map[string]string
|
||||
Took string
|
||||
}
|
||||
|
||||
// Checked takes a verdict as the checker's: the head's mesh/check, and the checker's verdict at the heads its Approve covers.
|
||||
func (h *Holder) Checked(c CheckedEvent) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
state := map[string]string{"pass": "success", "warning": "warning", "fail": "failure", "error": "error"}[c.Verdict]
|
||||
if state == "" {
|
||||
state = "error"
|
||||
}
|
||||
if c.Group != "" {
|
||||
g := h.groups[c.Group]
|
||||
if g == nil {
|
||||
return
|
||||
}
|
||||
named := map[string]string{}
|
||||
for _, m := range c.Members {
|
||||
if m.Trunk != "" {
|
||||
named[strings.ToLower(m.Owner+"/"+m.Repo)] = m.Trunk
|
||||
}
|
||||
}
|
||||
h.putChecker(h.membersOf(g), c.Verdict, named)
|
||||
return
|
||||
}
|
||||
repository := strings.ToLower(c.Owner + "/" + c.Repo)
|
||||
id := IDOf(repository, c.Commit)
|
||||
if h.deliveries[id] == nil && c.Number != 0 {
|
||||
d := &Delivery{ID: id, Repository: repository, Commit: c.Commit, Number: c.Number, Created: h.Now()}
|
||||
if t, err := Apply(d, EvAnnounced, false, Facts{Now: h.Now(), Why: "its verdict arrived before its head was heard"}); err == nil {
|
||||
h.moved(d, []Transition{t})
|
||||
}
|
||||
}
|
||||
d := h.deliveries[id]
|
||||
if d == nil {
|
||||
return
|
||||
}
|
||||
if c.Plan != nil {
|
||||
d.Plan = c.Plan
|
||||
}
|
||||
words := map[string]string{"success": "pass", "warning": "pass", "failure": "failed: " + c.Summary, "error": "error"}[state]
|
||||
line := words + " " + shortOf(c.Commit, 12)
|
||||
if c.Trunk != "" {
|
||||
line += " on " + shortOf(c.Trunk, 12) // a verdict that names no trunk commit is judged against none
|
||||
}
|
||||
h.takeCheckStatus(repository, c.Commit, state, line+" | "+c.Summary)
|
||||
if d := h.deliveries[id]; d != nil && state == "success" && d.Ready != nil && d.Ready.Merged == nil {
|
||||
h.mergeAgainNow(d) // as the forge's success status event does (CommitStatus)
|
||||
}
|
||||
if d := h.deliveries[id]; d != nil && d.Check != nil && c.Beside != nil {
|
||||
d.Check.Beside, d.Check.Took = c.Beside, c.Took
|
||||
}
|
||||
if d := h.deliveries[id]; d != nil {
|
||||
h.putChecker(h.askMembers(d), c.Verdict, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// putChecker keeps the checker's verdict at these members' heads, judged on each repository's trunk the members name or, for
|
||||
// none named, the one the check line named.
|
||||
func (h *Holder) putChecker(members []*Delivery, verdict string, named map[string]string) {
|
||||
state := "pass"
|
||||
switch verdict {
|
||||
case "pass", "warning":
|
||||
default:
|
||||
state = "fail"
|
||||
}
|
||||
hash, err := sdk.HeadsHash(headsOf(members))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
trunks := map[string]string{}
|
||||
for _, d := range members {
|
||||
if d.Check != nil && d.Check.Trunk != "" {
|
||||
trunks[strings.ToLower(d.Repository)] = d.Check.Trunk
|
||||
}
|
||||
if d.TrunkNow != "" && trunks[strings.ToLower(d.Repository)] == "" {
|
||||
trunks[strings.ToLower(d.Repository)] = d.TrunkNow
|
||||
}
|
||||
}
|
||||
for repository, trunk := range named {
|
||||
trunks[repository] = trunk
|
||||
}
|
||||
value, _ := json.Marshal(map[string]any{"v": 1, "group": "g", "heads": hash, "trunk": trunks, "state": state})
|
||||
if h.checker == nil {
|
||||
h.checker = map[string]checkerVerdict{}
|
||||
}
|
||||
var v checkerVerdict
|
||||
_ = json.Unmarshal(value, &v)
|
||||
h.checker[hash] = v
|
||||
h.Poke()
|
||||
}
|
||||
|
||||
var _ = time.Second
|
||||
@@ -0,0 +1,67 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// A pull request and the head the checker judged, for checkPasses.
|
||||
type judgedPull struct {
|
||||
repository string
|
||||
number int
|
||||
head string
|
||||
}
|
||||
|
||||
// checkPasses says on the bus what the mesh says of heads the checker passed (issue 1302, ADR 0447): its verdict at these
|
||||
// heads in its verdicts bucket, each pull request's base requiring mesh/check and the forge's word that its bridge set it, the
|
||||
// line naming the head and the trunk commit it judged. The gate follows no verdict of the controller (issue 1318).
|
||||
func (b *testBus) checkPasses(t *testing.T, rt *runtime, group, trunk string, pulls ...judgedPull) {
|
||||
t.Helper()
|
||||
var heads []sdk.Head
|
||||
trunks := map[string]string{}
|
||||
for _, p := range pulls {
|
||||
heads = append(heads, sdk.Head{Repository: p.repository, Number: p.number, Head: p.head})
|
||||
trunks[p.repository] = trunk
|
||||
}
|
||||
hash, err := sdk.HeadsHash(heads)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
verdict, _ := json.Marshal(map[string]any{"v": 1, "group": group, "heads": hash, "trunk": trunks, "state": "pass"})
|
||||
if _, err := b.kvs[checkerVerdicts].Put(context.Background(), group+"."+hash, verdict); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, p := range pulls {
|
||||
owner, repo := splitRepository(p.repository)
|
||||
line := "pass " + p.head[:12] + " on " + trunk[:12] + " | unit 3 run, 0 kept"
|
||||
rt.mu.Lock()
|
||||
found := false
|
||||
for i := range rt.openPulls {
|
||||
o := &rt.openPulls[i]
|
||||
if o.Owner == owner && o.Repo == repo && o.Number == p.number {
|
||||
o.Required, o.Statuses, o.Checks, found = []string{ctxCheck}, []string{ctxCheck}, map[string]HeadCheck{ctxCheck: {State: "success", Description: line}}, true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
rt.openPulls = append(rt.openPulls, OpenPull{Owner: owner, Repo: repo, Number: p.number, Base: "main", HeadSHA: p.head,
|
||||
Required: []string{ctxCheck}, Statuses: []string{ctxCheck}, Checks: map[string]HeadCheck{ctxCheck: {State: "success", Description: line}}})
|
||||
}
|
||||
rt.mu.Unlock()
|
||||
b.publish(t, "mesh.mod.gitea.event.forge.status", map[string]any{"owner": owner, "repo": repo, "sha": p.head,
|
||||
"context": ctxCheck, "state": "success", "description": line})
|
||||
}
|
||||
}
|
||||
|
||||
func splitRepository(r string) (owner, repo string) {
|
||||
for i := range r {
|
||||
if r[i] == '/' {
|
||||
return r[:i], r[i+1:]
|
||||
}
|
||||
}
|
||||
return "", r
|
||||
}
|
||||
@@ -18,9 +18,8 @@ import (
|
||||
|
||||
// Context names of the statuses this owner can say more of than the forge keeps.
|
||||
const (
|
||||
// ctxGate is the controller's merge gate, kept where a base still requires it; mesh/check is ctxCheck (onestatus.go).
|
||||
ctxGate = "mesh/merge-gate"
|
||||
ctxRepo = "mesh/repo-check"
|
||||
// mesh/check, the one status main requires, is ctxCheck (onestatus.go); the controller's mesh/merge-gate and hq's
|
||||
// mesh/repo-check are gone with the merge gate (ADR 0524 §5, ADR 0527 §2).
|
||||
ctxDelivery = "mesh/delivery"
|
||||
ctxGroup = "mesh/delivery-group"
|
||||
)
|
||||
@@ -136,10 +135,7 @@ func (h *Holder) Checks(repository string, number int, commit string) (*ChecksAn
|
||||
} else {
|
||||
// The forge away: the statuses the verdict set, as the forge holder sets them from it, said as such.
|
||||
if v := a.Verdict; v != nil {
|
||||
a.Statuses = append(a.Statuses, CheckStatus{Name: ctxGate, State: forgeStateOf(v.Gate), Source: "mesh-delivery's verdict; the forge could not be read"})
|
||||
if v.Repo != "" {
|
||||
a.Statuses = append(a.Statuses, CheckStatus{Name: ctxRepo, State: forgeStateOf(v.Repo), Source: "mesh-delivery's verdict; the forge could not be read"})
|
||||
}
|
||||
a.Statuses = append(a.Statuses, CheckStatus{Name: ctxCheck, State: forgeStateOf(v.Gate), Source: "mesh-delivery's verdict; the forge could not be read"})
|
||||
}
|
||||
a.Merge = ForgeMerge{Branch: firstOf(base, "main"), Says: "not known: the forge could not be read, and only it holds the protection"}
|
||||
}
|
||||
@@ -211,29 +207,12 @@ func newerHead(d, e *Delivery) bool {
|
||||
// addWhatIsKnown joins this owner's verdict to the status it set, and the delivery to its own statuses.
|
||||
func (h *Holder) addWhatIsKnown(s *CheckStatus, v *Verdict, d *checksDelivery) {
|
||||
switch s.Name {
|
||||
case ctxGate, ctxRepo:
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
s.Build, s.CheckedOn = v.ID, v.On
|
||||
if !v.At.IsZero() {
|
||||
s.CheckedAt = v.At.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if s.Name == ctxGate {
|
||||
s.Verdict = strings.ToUpper(firstOf(v.Gate, "error")) + " — " + v.Summary
|
||||
if len(v.Modules) > 0 {
|
||||
s.Verdict += " (modules: " + strings.Join(v.Modules, ", ")
|
||||
if len(v.Dependents) > 0 {
|
||||
s.Verdict += "; built after them: " + strings.Join(v.Dependents, ", ")
|
||||
}
|
||||
s.Verdict += ")"
|
||||
}
|
||||
} else if v.Repo != "" {
|
||||
s.Verdict = strings.ToUpper(v.Repo) + " — " + v.RepoSaid
|
||||
}
|
||||
case ctxCheck:
|
||||
if v != nil && v.Status == ctxCheck {
|
||||
s.Verdict = strings.ToUpper(firstOf(v.Gate, "error")) + " — " + v.Summary
|
||||
if !v.At.IsZero() {
|
||||
s.CheckedAt = v.At.UTC().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
case ctxDelivery:
|
||||
if d != nil {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -10,26 +9,17 @@ import (
|
||||
// the forge's statuses, the verdict whole as the controller said it, and whether the base's protection lets
|
||||
// it merge.
|
||||
|
||||
// checkedAs is the controller's `checked` as it comes off the bus, both layers said.
|
||||
func checkedAs(t *testing.T, repo string, number int, sha, gate, gateSaid, repoVerdict, repoSaid string) CheckedEvent {
|
||||
// checkedAs is a verdict as the checker says it on the head's mesh/check.
|
||||
func checkedAs(t *testing.T, repo string, number int, sha, gate, gateSaid string) CheckedEvent {
|
||||
t.Helper()
|
||||
owner, name, _ := strings.Cut(repo, "/")
|
||||
raw := map[string]any{"owner": owner, "repo": name, "number": number, "commit": sha, "verdict": gate,
|
||||
"summary": gateSaid, "id": "build-" + sha[:8], "on": "the-build-node", "report": "== compose\n" + gateSaid,
|
||||
"gate": map[string]any{"verdict": gate, "summary": gateSaid, "modules": []string{"app"}, "dependents": []string{"web"}},
|
||||
"repo-check": map[string]any{"verdict": repoVerdict, "summary": repoSaid}}
|
||||
var c CheckedEvent
|
||||
if err := json.Unmarshal(mustJSON(raw), &c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c
|
||||
return CheckedEvent{Owner: owner, Repo: name, Number: number, Commit: sha, Verdict: gate, Summary: gateSaid, ID: "build-" + sha[:8]}
|
||||
}
|
||||
|
||||
// statusesAsTheForgeSets puts the two statuses the forge's holder sets from a verdict: clipped, as it keeps them.
|
||||
// statusesAsTheForgeSets puts the status the forge's bridge sets from a verdict: clipped, as the forge keeps it.
|
||||
func statusesAsTheForgeSets(w *world, repo, sha string, c CheckedEvent) {
|
||||
owner, name, _ := strings.Cut(repo, "/")
|
||||
_ = w.forge.Status(owner, name, sha, ctxGate, forgeStateOf(c.Gate.Verdict), clip(c.Gate.Verdict+": "+c.Gate.Summary, 40), "https://forge.invalid/pr")
|
||||
_ = w.forge.Status(owner, name, sha, ctxRepo, forgeStateOf(c.RepoCheck.Verdict), clip(c.RepoCheck.Verdict+": "+c.RepoCheck.Summary, 40), "https://forge.invalid/pr")
|
||||
_ = w.forge.Status(owner, name, sha, ctxCheck, forgeStateOf(c.Verdict), clip(c.Verdict+": "+c.Summary, 40), "https://forge.invalid/pr")
|
||||
}
|
||||
|
||||
func statusNamed(a *ChecksAnswer, name string) *CheckStatus {
|
||||
@@ -43,11 +33,11 @@ func statusNamed(a *ChecksAnswer, name string) *CheckStatus {
|
||||
|
||||
func TestChecksOfAPullRequestSayEveryStatusTheVerdictWholeAndWhetherItMerges(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.forge.required = []string{ctxGate, ctxRepo}
|
||||
w.forge.required = []string{ctxCheck}
|
||||
w.forge.pulls["novox/app#7"] = ForgePull{Number: 7, Title: "a change", State: "open", Base: "main", Head: "feat/x", HeadSHA: head}
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
said := "0 of 4 compose: anchor refuses app's manifest — a provision nothing provides, and three more machines alike"
|
||||
c := checkedAs(t, "novox/app", 7, head, "fail", said, "pass", "its merge-check.sh passed")
|
||||
c := checkedAs(t, "novox/app", 7, head, "fail", said)
|
||||
w.h.Checked(c)
|
||||
w.settleAll()
|
||||
statusesAsTheForgeSets(w, "novox/app", head, c)
|
||||
@@ -59,35 +49,30 @@ func TestChecksOfAPullRequestSayEveryStatusTheVerdictWholeAndWhetherItMerges(t *
|
||||
if a.Commit != head || a.Pull == nil || a.Pull.Number != 7 || a.Forge != "" {
|
||||
t.Fatalf("the head read is %q, pull %+v, forge %q", a.Commit, a.Pull, a.Forge)
|
||||
}
|
||||
gate := statusNamed(a, ctxGate)
|
||||
gate := statusNamed(a, ctxCheck)
|
||||
if gate == nil || gate.State != "failure" || gate.Source != "the forge" || gate.SetBy != "mesh-admin" {
|
||||
t.Fatalf("the gate's status is %+v", gate)
|
||||
t.Fatalf("the check's status is %+v", gate)
|
||||
}
|
||||
// The forge clipped it; the verdict says it whole, with the machine, the build and the modules.
|
||||
// The forge clipped it; the verdict says it whole.
|
||||
if strings.Contains(gate.Description, "three more machines") {
|
||||
t.Fatalf("the fake forge did not clip: %q", gate.Description)
|
||||
}
|
||||
if !strings.Contains(gate.Verdict, "FAIL — 0 of 4 compose") || !strings.Contains(gate.Verdict, "three more machines") ||
|
||||
!strings.Contains(gate.Verdict, "modules: app; built after them: web") {
|
||||
t.Fatalf("the gate's verdict is %q", gate.Verdict)
|
||||
if !strings.Contains(gate.Verdict, "FAIL — failed: 0 of 4 compose") || !strings.Contains(gate.Verdict, "three more machines") {
|
||||
t.Fatalf("the check's verdict is %q", gate.Verdict)
|
||||
}
|
||||
if gate.Build != "build-"+head[:8] || gate.CheckedOn != "the-build-node" || gate.CheckedAt == "" {
|
||||
t.Fatalf("the gate was checked as %q on %q at %q", gate.Build, gate.CheckedOn, gate.CheckedAt)
|
||||
}
|
||||
repo := statusNamed(a, ctxRepo)
|
||||
if repo == nil || repo.State != "success" || repo.Verdict != "PASS — its merge-check.sh passed" || repo.CheckedOn != "the-build-node" {
|
||||
t.Fatalf("the repository check's status is %+v", repo)
|
||||
if gate.CheckedAt == "" {
|
||||
t.Fatal("the check says no time it was heard")
|
||||
}
|
||||
if d := statusNamed(a, ctxDelivery); d == nil || !strings.HasPrefix(d.Verdict, "rejected: waits for a new head") {
|
||||
t.Fatalf("the delivery's status is %+v", d)
|
||||
}
|
||||
if a.Verdict == nil || !strings.Contains(a.Verdict.Report, "== compose") {
|
||||
if a.Verdict == nil || !strings.Contains(a.Verdict.Summary, "three more machines") {
|
||||
t.Fatalf("the verdict whole is %+v", a.Verdict)
|
||||
}
|
||||
if a.Delivery == nil || a.Delivery.ID != IDOf("novox/app", head) || a.Delivery.State != Rejected {
|
||||
t.Fatalf("the delivery is %+v", a.Delivery)
|
||||
}
|
||||
if a.Merge.Mergeable == nil || *a.Merge.Mergeable || len(a.Merge.Blocking) != 1 || a.Merge.Blocking[0].Context != ctxGate {
|
||||
if a.Merge.Mergeable == nil || *a.Merge.Mergeable || len(a.Merge.Blocking) != 1 || a.Merge.Blocking[0].Context != ctxCheck {
|
||||
t.Fatalf("the merge is %+v", a.Merge)
|
||||
}
|
||||
if !strings.Contains(a.Says, "novox/app#7 at aaaaaaaa") || !strings.Contains(a.Says, "NOT mergeable") {
|
||||
@@ -101,10 +86,10 @@ func TestChecksOfAPullRequestSayEveryStatusTheVerdictWholeAndWhetherItMerges(t *
|
||||
|
||||
func TestANewHeadIsReadWithItsOwnVerdictNeverTheOlderOnes(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.forge.required = []string{ctxGate, ctxRepo}
|
||||
w.forge.required = []string{ctxCheck}
|
||||
newer := "bbbbbbbbbbbb2222"
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x"))
|
||||
w.h.Checked(checkedAs(t, "novox/app", 7, head, "fail", "0 of 4 compose", "pass", "passed"))
|
||||
w.h.Checked(checkedAs(t, "novox/app", 7, head, "fail", "0 of 4 compose"))
|
||||
w.h.PullUpdated(pr("novox/app", 7, newer, "feat/x"))
|
||||
w.forge.pulls["novox/app#7"] = ForgePull{Number: 7, State: "open", Base: "main", HeadSHA: newer}
|
||||
|
||||
@@ -118,27 +103,27 @@ func TestANewHeadIsReadWithItsOwnVerdictNeverTheOlderOnes(t *testing.T) {
|
||||
if a.Verdict != nil {
|
||||
t.Fatalf("the older head's verdict was given for the newer: %+v", a.Verdict)
|
||||
}
|
||||
if a.Merge.Mergeable == nil || *a.Merge.Mergeable || len(a.Merge.Blocking) != 2 || a.Merge.Blocking[0].State != "missing" {
|
||||
if a.Merge.Mergeable == nil || *a.Merge.Mergeable || len(a.Merge.Blocking) != 1 || a.Merge.Blocking[0].State != "missing" {
|
||||
t.Fatalf("a head not checked yet merges as %+v", a.Merge)
|
||||
}
|
||||
|
||||
// Passed, it merges.
|
||||
c := checkedAs(t, "novox/app", 7, newer, "pass", "every machine composes", "pass", "its merge-check.sh passed")
|
||||
c := checkedAs(t, "novox/app", 7, newer, "pass", "every machine composes")
|
||||
w.h.Checked(c)
|
||||
statusesAsTheForgeSets(w, "novox/app", newer, c)
|
||||
a, _ = w.h.Checks("novox/app", 7, "")
|
||||
if a.Merge.Mergeable == nil || !*a.Merge.Mergeable || !strings.Contains(a.Says, "mergeable") || strings.Contains(a.Says, "NOT") {
|
||||
t.Fatalf("a head that passed: %+v, says %q", a.Merge, a.Says)
|
||||
}
|
||||
if g := statusNamed(a, ctxGate); g == nil || g.Verdict != "PASS — every machine composes (modules: app; built after them: web)" {
|
||||
t.Fatalf("its gate: %+v", g)
|
||||
if g := statusNamed(a, ctxCheck); g == nil || !strings.HasPrefix(g.Verdict, "PASS — pass ") || !strings.Contains(g.Verdict, "every machine composes") {
|
||||
t.Fatalf("its check: %+v", g)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACommitIsReadByTheStartOfItsShaAndAMergeByItsDelivery(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x"))
|
||||
c := checkedAs(t, "novox/app", 7, head, "pass", "every machine composes", "pass", "passed")
|
||||
c := checkedAs(t, "novox/app", 7, head, "pass", "every machine composes")
|
||||
w.h.Checked(c)
|
||||
statusesAsTheForgeSets(w, "novox/app", head, c)
|
||||
|
||||
@@ -175,7 +160,7 @@ func TestACommitIsReadByTheStartOfItsShaAndAMergeByItsDelivery(t *testing.T) {
|
||||
func TestWithTheForgeAwayTheVerdictIsSaidAndWhetherItMergesIsNotKnown(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x"))
|
||||
w.h.Checked(checkedAs(t, "novox/app", 7, head, "warning", "every machine composes, with a note", "error", "the toolchain was not there"))
|
||||
w.h.Checked(checkedAs(t, "novox/app", 7, head, "warning", "every machine composes, with a note"))
|
||||
w.forge.down = true
|
||||
|
||||
a, err := w.h.Checks("novox/app", 7, "")
|
||||
@@ -185,10 +170,9 @@ func TestWithTheForgeAwayTheVerdictIsSaidAndWhetherItMergesIsNotKnown(t *testing
|
||||
if a.Forge == "" || a.Commit != head || a.Verdict == nil {
|
||||
t.Fatalf("with the forge away: forge %q, commit %q, verdict %+v", a.Forge, a.Commit, a.Verdict)
|
||||
}
|
||||
gate, repo := statusNamed(a, ctxGate), statusNamed(a, ctxRepo)
|
||||
if gate == nil || gate.State != "success" || !strings.Contains(gate.Source, "could not be read") ||
|
||||
repo == nil || repo.State != "error" || repo.Verdict != "ERROR — the toolchain was not there" {
|
||||
t.Fatalf("the statuses from the verdict: %+v %+v", gate, repo)
|
||||
gate := statusNamed(a, ctxCheck)
|
||||
if gate == nil || gate.State != "success" || !strings.Contains(gate.Source, "could not be read") {
|
||||
t.Fatalf("the status from the verdict: %+v", gate)
|
||||
}
|
||||
if a.Merge.Mergeable != nil || !strings.Contains(a.Says, "mergeable: not known") {
|
||||
t.Fatalf("whether it merges, with the forge away: %+v, %q", a.Merge, a.Says)
|
||||
|
||||
@@ -95,59 +95,8 @@ func (h *Holder) askAgainForConfig(id, why string) {
|
||||
}
|
||||
}
|
||||
|
||||
// askProposedAgain asks a proposed delivery's check again, its merge check put back to pending first.
|
||||
// askProposedAgain says why a proposed delivery's check is not asked again for a config value: the checker's (issue 1302), and
|
||||
// whether a held config value is weighed is its composition's. Nothing is asked of the controller (issue 1318).
|
||||
func (h *Holder) askProposedAgain(id, why string) {
|
||||
h.mu.Lock()
|
||||
d := h.deliveries[id]
|
||||
if d == nil || d.State != Proposed {
|
||||
h.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if d.judgedByCheck() {
|
||||
// The checker's (issue 1302): its check is not asked here, and whether a held config value is weighed is the
|
||||
// checker's composition's.
|
||||
h.Logf("[mesh-delivery] %s: a held config value changed; %v", id, checkerRefusal(id))
|
||||
h.mu.Unlock()
|
||||
return
|
||||
}
|
||||
owner, repo, commit, page, number := d.Owner(), d.Repo(), d.Commit, d.HTMLURL, d.Number
|
||||
h.mu.Unlock()
|
||||
if err := h.checkingAgain(owner, repo, number, commit, page, why); err != nil {
|
||||
h.Logf("[mesh-delivery] %s: its check is not asked again for a config value: %v", id, err)
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
d = h.deliveries[id]
|
||||
if d == nil || d.State != Proposed {
|
||||
h.mu.Unlock()
|
||||
return
|
||||
}
|
||||
d.Check = nil
|
||||
if g := h.groups[d.Group]; g != nil && g.Check != nil {
|
||||
now := h.Now()
|
||||
g.Check.OutOfDate = fmt.Sprintf("composed check out of date: %s was rechecked (%s)", d.ID, why)
|
||||
g.Check.OutOfDateAt = now
|
||||
g.Updated = now
|
||||
h.keepGroup(g)
|
||||
}
|
||||
m := MemberOf(d)
|
||||
h.mu.Unlock()
|
||||
asked, err := h.Controller.Check("", []Member{m})
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] %s's check cannot be asked again yet (%v); it is asked at the next look", id, err)
|
||||
h.again("a check asked again for a config value that could not be asked")
|
||||
return
|
||||
}
|
||||
d = h.deliveries[id]
|
||||
if d == nil || d.State != Proposed {
|
||||
return
|
||||
}
|
||||
d.CheckAsk = &CheckAsk{ID: asked, At: h.Now(), By: byOwner, Why: why}
|
||||
h.Logf("[mesh-delivery] %s: its check asked again as %s — %s", id, asked, why)
|
||||
d.Owed = append(d.Owed, Effect{Kind: EffectNote, Commit: d.Commit, Since: h.Now(),
|
||||
Line: oneLine(fmt.Sprintf("%s delivery %s: its check asked again by %s as %s: %s", h.Now().UTC().Format("2006-01-02T15:04:05Z07:00"),
|
||||
id, byOwner, asked, why))})
|
||||
h.keep(d)
|
||||
h.Logf("[mesh-delivery] %s: a held config value changed; %v", id, checkerRefusal(id))
|
||||
}
|
||||
|
||||
@@ -392,100 +392,6 @@ func TestHeldDeliveriesReadBackCloseByThemselvesOnce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// **A group member is not held for a composed check that has not answered** (issue 316): it waits published,
|
||||
// and goes on once the check passes for the heads that merged.
|
||||
func TestAGroupMemberWaitsForItsComposedCheckToAnswer(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
const a, b = "novox/a", "novox/b"
|
||||
const aHead, bHead = "a1a1a1a1a1a1a1a1", "b2b2b2b2b2b2b2b2"
|
||||
aID := IDOf(a, aHead)
|
||||
pa, pb := pr(a, 1, aHead, "feat/k", "x"), pr(b, 2, bHead, "feat/k", "y")
|
||||
w.h.PullUpdated(pa)
|
||||
w.h.PullUpdated(pb)
|
||||
w.h.Checked(verdictMoving(a, 1, aHead, "a"))
|
||||
w.h.Checked(verdictMoving(b, 2, bHead, "b"))
|
||||
w.settleAll()
|
||||
if len(w.ctl.checks) != 1 {
|
||||
t.Fatalf("the composed check was asked %v", w.ctl.checks)
|
||||
}
|
||||
w.h.PullMerged(mergedAt(pa, "m1m1m1m1m1m1", t0))
|
||||
w.h.WalkMoved(w.ctl.put(aWalk("plan-a", a, "m1m1m1m1m1m1", true)))
|
||||
w.settleAll()
|
||||
if s := w.state(aID); s != Published {
|
||||
t.Fatalf("merged while its group's composed check had not answered, it is %s (%s)", s, w.delivery(aID).HeldWhy)
|
||||
}
|
||||
w.h.Checked(CheckedEvent{Group: "feat/k", ID: "check-1", Verdict: "pass", Summary: "both compose"})
|
||||
w.settleAll()
|
||||
if s := w.state(aID); s != Published || len(w.ctl.delivers) != 0 {
|
||||
t.Fatalf("its composed check passed, but the other member is not merged: %s, let go %v", s, w.ctl.delivers)
|
||||
}
|
||||
// The other member merges: every member is on the trunk, and the walk is let go (ADR 0418 §2).
|
||||
w.h.PullMerged(mergedAt(pb, "m2m2m2m2m2m2", t0))
|
||||
w.h.WalkMoved(w.ctl.put(aWalk("plan-b", b, "m2m2m2m2m2m2", true)))
|
||||
w.settleAll()
|
||||
if s := w.state(aID); s != Delivering || !containsString(w.ctl.delivers, "plan-a") {
|
||||
t.Fatalf("every member merged, and it is %s, let go %v", s, w.ctl.delivers)
|
||||
}
|
||||
}
|
||||
|
||||
// **A member already held for its group's composed check goes on when that check passes for the heads that
|
||||
// merged** (issue 316); a check that failed, or passed for other heads, keeps it held for a person.
|
||||
func TestAMemberHeldForItsComposedCheckIsLiftedWhenItPasses(t *testing.T) {
|
||||
const a, b = "novox/a", "novox/b"
|
||||
const aHead, bHead = "a1a1a1a1a1a1a1a1", "b2b2b2b2b2b2b2b2"
|
||||
aID, bID := IDOf(a, aHead), IDOf(b, bHead)
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
verdict string
|
||||
commits map[string]string
|
||||
want State
|
||||
}{
|
||||
{"passed for the heads that merged", "pass", map[string]string{aID: aHead, bID: bHead}, Delivering},
|
||||
{"warned for the heads that merged", "warning", map[string]string{aID: aHead, bID: bHead}, Delivering},
|
||||
{"failed", "fail", map[string]string{aID: aHead, bID: bHead}, Held},
|
||||
{"passed for other heads", "pass", map[string]string{aID: "a9a9a9a9a9a9a9a9", bID: bHead}, Held},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
store := newMemStore()
|
||||
why := "its group feat/k's composed check did not pass for the heads that merged; a person decides that it goes on"
|
||||
da := &Delivery{ID: aID, Repository: a, Commit: aHead, Number: 1, Base: "main", Branch: "feat/k", Group: "feat/k",
|
||||
State: Held, Since: t0, Created: t0.Add(-time.Hour), MergedAs: "m1m1m1m1m1m1", MergedAt: t0, HeldWhy: why,
|
||||
Plan: planMoving(a, "a"), Check: &Verdict{Gate: "pass"},
|
||||
Walk: &WalkSeen{ID: "plan-a", Commit: "m1m1m1m1m1m1", State: walkBuilding, Waits: true, WaitedFor: "mesh-delivery"}}
|
||||
db := &Delivery{ID: bID, Repository: b, Commit: bHead, Number: 2, Base: "main", Branch: "feat/k", Group: "feat/k",
|
||||
State: Published, Since: t0, Created: t0.Add(-time.Hour), MergedAs: "m2m2m2m2m2m2", MergedAt: t0,
|
||||
Plan: planMoving(b, "b"), Check: &Verdict{Gate: "pass"},
|
||||
Walk: &WalkSeen{ID: "plan-b", Commit: "m2m2m2m2m2m2", State: walkBuilding, Waits: true, WaitedFor: "mesh-delivery"}}
|
||||
g := &Group{ID: "feat/k", Members: []string{aID, bID}, Closed: true,
|
||||
Check: &GroupCheck{Asked: "check-old", At: t0, Commits: c.commits}}
|
||||
for _, d := range []*Delivery{da, db} {
|
||||
_ = store.PutDelivery(d)
|
||||
}
|
||||
_ = store.PutGroup(g)
|
||||
ctl := newFakeController()
|
||||
ctl.put(aWalk("plan-a", a, "m1m1m1m1m1m1", true))
|
||||
ctl.put(aWalk("plan-b", b, "m2m2m2m2m2m2", true))
|
||||
w := newWorldOver(t, store, ctl, newFakeForge(), t0.Add(time.Minute))
|
||||
w.settleAll()
|
||||
if s := w.state(aID); s != Held {
|
||||
t.Fatalf("before its composed verdict it is %s", s)
|
||||
}
|
||||
w.h.Checked(CheckedEvent{Group: "feat/k", ID: "check-old", Verdict: c.verdict, Summary: "composed: " + c.verdict})
|
||||
w.settleAll()
|
||||
if s := w.state(aID); s != c.want {
|
||||
t.Fatalf("its composed check %s, and it is %s (%s), want %s", c.name, s, w.delivery(aID).HeldWhy, c.want)
|
||||
}
|
||||
if c.want == Delivering && !containsString(ctl.delivers, "plan-a") {
|
||||
t.Fatalf("lifted, its walk was not let go: %v", ctl.delivers)
|
||||
}
|
||||
// A composed check of other heads is asked again for these heads (novox/hq ADR 0323 §6).
|
||||
if c.name == "passed for other heads" && !containsString(ctl.checks, "feat/k:"+aID+","+bID) {
|
||||
t.Fatalf("the composed check was not asked again for the heads that merged: %v", ctl.checks)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// **A module a release carried to every machine the held delivery sends it to covers it** (novox/hq issue
|
||||
// 351). A build the controller held back for a person and released on their word reaches each machine by a
|
||||
// backlog walk, which credits the delivery that published the build one step per machine — never the walk's
|
||||
|
||||
@@ -47,8 +47,8 @@ func (h *Holder) AnswersChanged() {
|
||||
}
|
||||
}
|
||||
|
||||
// CommitStatus takes a commit status set on the forge: one of the merge checks (mesh/check; hq's mesh/repo-check and, on a
|
||||
// base that still requires it, mesh/merge-gate) says a
|
||||
// CommitStatus takes a commit status set on the forge: one of the merge checks (mesh/check and, on a
|
||||
// base that still requires it after a revert, the two it replaced) says a
|
||||
// check started, answered, or was put back, so the forge's open pull requests are read to see which heads still wait on
|
||||
// one (SeeOpenPulls). A status of any other context shows nothing the merge needs.
|
||||
//
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
@@ -20,7 +19,14 @@ func hear(t *testing.T, w *world, key string, body any) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := handle(w.h, stdio.Envelope{Key: key, Body: raw}); err != nil {
|
||||
if key == ControllerSeat+".checked" {
|
||||
// The gate follows no verdict of the controller any more (issue 1318): a test says it as the checker's.
|
||||
var c CheckedEvent
|
||||
if err := json.Unmarshal(raw, &c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.h.Checked(c)
|
||||
} else if err := handle(w.h, stdio.Envelope{Key: key, Body: raw}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.h.React() // the look the event pokes, taken by the holder's worker
|
||||
@@ -85,53 +91,6 @@ func TestAnApproveHeardAsAnEventMergesAtOnceAtTheApprovedHead(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1022 (ADR 0421 §3): a passing verdict that arrives out of date is not asked; its check against the trunk's
|
||||
// head is, and the Approve is asked on the passing verdict.
|
||||
func TestAStalePassingVerdictAsksTheCheckAgainBeforeTheOperator(t *testing.T) {
|
||||
w := announced(t)
|
||||
// Another merge moved the trunk before the verdict, judged against the trunk before, arrived.
|
||||
w.h.PullMerged(merged("novox/app", 9, "9999999999999999", "t2t2t2t2t2t2"))
|
||||
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
if w.op.count() != 0 {
|
||||
t.Fatalf("the operator was asked for a verdict out of date")
|
||||
}
|
||||
if len(w.ctl.checks) != 1 || w.ctl.baseSHAs[0] != "t2t2t2t2t2t2" {
|
||||
t.Fatalf("its check against the trunk's head was not asked once: %v %v", w.ctl.checks, w.ctl.baseSHAs)
|
||||
}
|
||||
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t2t2t2t2t2t2"))
|
||||
if w.op.count() != 1 {
|
||||
t.Fatalf("the passing verdict at the trunk's head asked the operator %d times, wanted once", w.op.count())
|
||||
}
|
||||
if len(w.ctl.checks) != 1 {
|
||||
t.Fatalf("its check was asked again: %v", w.ctl.checks)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1022 (ADR 0421 §3, §4): an ask raised stays when the trunk moves; the Approve is given; the merge waits for the
|
||||
// fresh verdict and lands on it; no second Approve is asked.
|
||||
func TestAnAskRaisedStaysThroughATrunkMoveAndTheMergeLandsOnTheFreshVerdict(t *testing.T) {
|
||||
w := announced(t)
|
||||
id := IDOf("novox/app", readyHead)
|
||||
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.h.PullMerged(merged("novox/app", 9, "9999999999999999", "t2t2t2t2t2t2"))
|
||||
w.h.React()
|
||||
if w.state(id) != Proposed || w.op.count() != 1 || len(w.op.open()) != 1 {
|
||||
t.Fatalf("after the trunk moved: %s, asks %d, open %v", w.state(id), w.op.count(), w.op.open())
|
||||
}
|
||||
if len(w.ctl.checks) != 1 {
|
||||
t.Fatalf("its check against the trunk's head was not asked at once: %v", w.ctl.checks)
|
||||
}
|
||||
approve(w)
|
||||
if len(w.forge.merges) != 0 {
|
||||
t.Fatalf("merged on a verdict out of date: %v", w.forge.merges)
|
||||
}
|
||||
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t2t2t2t2t2t2"))
|
||||
if len(w.forge.merges) != 1 || w.delivery(id).Ready.Merged == nil || w.op.count() != 1 {
|
||||
t.Fatalf("the passing verdict did not ask the merge at once, on the same Approve: %v %+v asks %d", w.forge.merges,
|
||||
w.delivery(id).Ready, w.op.count())
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1022: an Approve whose merge the tool refused is asked again at once on the verdict that decides it.
|
||||
func TestAPassingVerdictAsksTheMergeAtOnceEvenJustAfterARefusal(t *testing.T) {
|
||||
w := announced(t)
|
||||
@@ -229,37 +188,3 @@ func TestAPushAfterTheAskEndsItAndSaysSoOnceOnThePullRequest(t *testing.T) {
|
||||
t.Fatalf("said more than once: %v", w.forge.comments)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1022 (ADR 0421 §7), as novox/hq ADR 0505 §5 changed it: a check that failed after the ask merges nothing, is
|
||||
// said once on the pull request at once, and is a stalled line only after two hours with no new head.
|
||||
func TestACheckThatFailsAfterTheAskIsNotMergedAndIsSaid(t *testing.T) {
|
||||
w := announced(t)
|
||||
id := IDOf("novox/app", readyHead)
|
||||
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
if _, err := w.h.Recheck(id, "a second look", "tester"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "fail", "t1t1t1t1t1t1"))
|
||||
approve(w)
|
||||
if len(w.forge.merges) != 0 {
|
||||
t.Fatalf("merged past a failed check: %v", w.forge.merges)
|
||||
}
|
||||
w.h.React()
|
||||
said := 0
|
||||
for _, c := range w.forge.comments {
|
||||
if strings.Contains(c, "not merged") {
|
||||
said++
|
||||
}
|
||||
}
|
||||
if said != 1 {
|
||||
t.Fatalf("the failure is not said once: %v", w.forge.comments)
|
||||
}
|
||||
if lines := w.h.Stalled(); len(lines) != 0 {
|
||||
t.Fatalf("the failure is the operator's before its author had two hours: %+v", lines)
|
||||
}
|
||||
w.later(2*time.Hour + time.Minute)
|
||||
lines := w.h.Stalled()
|
||||
if len(lines) != 1 || lines[0].ID != id || strings.Contains(lines[0].Says, "marked") {
|
||||
t.Fatalf("the failure is not a stalled line: %+v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,19 +128,6 @@ type fakeController struct {
|
||||
planned *DeliveryPlan
|
||||
// forgot are the pull requests whose check builds were let go of (novox/hq ADR 0350 §5), "owner/repo#n".
|
||||
forgot []string
|
||||
// queue is the build queue as Check fills it: every ask made is held, waiting, until a test takes it out (a lost
|
||||
// ask, issue 1274); queueDown makes the queue unreadable.
|
||||
queue []QueuedAsk
|
||||
queueDown bool
|
||||
}
|
||||
|
||||
func (c *fakeController) Queue() ([]QueuedAsk, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.queueDown {
|
||||
return nil, errors.New("the queue cannot be read")
|
||||
}
|
||||
return append([]QueuedAsk(nil), c.queue...), nil
|
||||
}
|
||||
|
||||
// BackupsAgain records the word, and clears the walk's failed backups as the controller does.
|
||||
@@ -192,32 +179,6 @@ func (c *fakeController) Plan(repository, base, head string, paths, dirs, remove
|
||||
}
|
||||
return &DeliveryPlan{Repository: repository, Base: base, Head: head, Moved: []string{"app"}, Summary: "builds app"}, nil
|
||||
}
|
||||
func (c *fakeController) Check(group string, ms []Member) (string, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.down {
|
||||
return "", errors.New("down")
|
||||
}
|
||||
if c.onCheck != nil {
|
||||
c.onCheck()
|
||||
}
|
||||
c.asked++
|
||||
id := fmt.Sprintf("check-%d", c.asked)
|
||||
if c.emptyCheck {
|
||||
id = ""
|
||||
}
|
||||
var heads []string
|
||||
for _, m := range ms {
|
||||
heads = append(heads, m.ID)
|
||||
}
|
||||
c.checks = append(c.checks, group+":"+strings.Join(heads, ","))
|
||||
for _, m := range ms {
|
||||
c.queue = append(c.queue, QueuedAsk{ID: id, State: "waiting", Kind: "check", Repository: m.Repository, Commit: m.Head})
|
||||
c.checkReady = append(c.checkReady, m.Ready)
|
||||
c.baseSHAs = append(c.baseSHAs, m.BaseSHA)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
func (c *fakeController) Deliver(walk, why string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
@@ -29,29 +29,25 @@ func readOf(d *Delivery, head string) memberRead {
|
||||
}
|
||||
|
||||
// notAsSuccess reads a member's required statuses at its head and says, in the forge's words, which are not a success;
|
||||
// empty when all are. ownOnly is whether only the member's own repository check is missing or pending. An error is a
|
||||
// empty when all are. An error is a
|
||||
// read that could not be made: the member then counts as not mergeable.
|
||||
func (h *Holder) notASuccess(m memberRead) (says string, ownOnly bool, err error) {
|
||||
func (h *Holder) notASuccess(m memberRead) (says string, err error) {
|
||||
fc, err := h.Forge.Statuses(m.owner, m.repo, m.number, m.head, m.base)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
return "", err
|
||||
}
|
||||
if fc.Merge.Mergeable == nil {
|
||||
return "", false, fmt.Errorf("the forge could not say whether %s may merge: %s", m.id, fc.Merge.Says)
|
||||
return "", fmt.Errorf("the forge could not say whether %s may merge: %s", m.id, fc.Merge.Says)
|
||||
}
|
||||
if *fc.Merge.Mergeable {
|
||||
return "", false, nil
|
||||
return "", nil
|
||||
}
|
||||
var parts []string
|
||||
ownOnly = len(fc.Merge.Blocking) > 0
|
||||
for _, b := range fc.Merge.Blocking {
|
||||
parts = append(parts, fmt.Sprintf("%s (%s)", b.Context, b.State))
|
||||
if b.Context != ctxRepo || (b.State != "pending" && b.State != "missing") {
|
||||
ownOnly = false
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%s requires %s; not a success: %s", fc.Merge.Branch, strings.Join(fc.Merge.Required, ", "),
|
||||
strings.Join(parts, ", ")), ownOnly, nil
|
||||
strings.Join(parts, ", ")), nil
|
||||
}
|
||||
|
||||
// unmergedLive are a group's members not yet merged and not over, in the group's order. Under the lock.
|
||||
@@ -76,7 +72,7 @@ func (h *Holder) groupMergeable(gid, target string, reads []memberRead) bool {
|
||||
}
|
||||
}
|
||||
for _, m := range reads {
|
||||
says, ownOnly, err := h.notASuccess(m)
|
||||
says, err := h.notASuccess(m)
|
||||
if err == nil && says == "" {
|
||||
continue
|
||||
}
|
||||
@@ -88,7 +84,6 @@ func (h *Holder) groupMergeable(gid, target string, reads []memberRead) bool {
|
||||
h.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
var own func()
|
||||
if g := h.groups[gid]; g != nil {
|
||||
if g.Hold == nil || g.Hold.Kind != "blocked" || g.Hold.Says != says || g.Hold.Member != m.id {
|
||||
h.Logf("[mesh-delivery] group %s is approved and NOT merged: %s at %s: %s", gid, m.id, shortOf(m.head, 12), says)
|
||||
@@ -103,15 +98,9 @@ func (h *Holder) groupMergeable(gid, target string, reads []memberRead) bool {
|
||||
g.Hold = &GroupHold{Kind: "blocked", Member: m.id, Says: says, Since: since}
|
||||
h.keepGroup(g)
|
||||
}
|
||||
if d := h.deliveries[m.id]; ownOnly && d != nil && d.Ready != nil {
|
||||
own = h.askOwnAfterRefusal(d) // its own repository check is what is missing: asked, once for the head
|
||||
}
|
||||
release()
|
||||
h.again("a group's merge held for a required status")
|
||||
h.mu.Unlock()
|
||||
if own != nil {
|
||||
own()
|
||||
}
|
||||
return false
|
||||
}
|
||||
h.mu.Lock()
|
||||
|
||||
@@ -3,7 +3,6 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -48,73 +47,6 @@ func approvedGroup(t *testing.T) *world {
|
||||
return w
|
||||
}
|
||||
|
||||
// Issue 1047 (the rule the lead settled, ADR 0421 §3): an approved group whose trunk moved through a merge that is not
|
||||
// its member's has each out-of-date member's check asked at once, then the composed check, and merges on the same
|
||||
// Approve — with no second one asked.
|
||||
func TestAnApprovedGroupWhoseTrunkMovedIsCheckedAgainAndMergedOnTheSameApprove(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
asked := len(w.ctl.checks)
|
||||
w.h.PullMerged(merged(gCtl, 5, "5555555555555555", gT1))
|
||||
w.settleAll()
|
||||
// The member whose trunk moved has its own check asked, in the same look; the other member's trunk did not move.
|
||||
if got := w.ctl.checks[asked:]; len(got) != 1 || got[0] != ":"+IDOf(gCtl, gHc) {
|
||||
t.Fatalf("the trunk's move asked %v; wanted the moved member's own check once", got)
|
||||
}
|
||||
if w.ctl.baseSHAs[len(w.ctl.baseSHAs)-1] != gT1[:12] && w.ctl.baseSHAs[len(w.ctl.baseSHAs)-1] != gT1 {
|
||||
t.Fatalf("its check was not asked against the trunk's head: %v", w.ctl.baseSHAs)
|
||||
}
|
||||
// The Approve is given while the checks run: nothing merges on the verdict out of date.
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 0 {
|
||||
t.Fatalf("merged while the group's checks were out of date: %v", w.forge.merges)
|
||||
}
|
||||
// The member is judged again: the composed check is asked on the new verdicts.
|
||||
w.later(time.Minute)
|
||||
w.h.Checked(judged(gCtl, 1, gHc, "pass", gT1))
|
||||
w.settleAll()
|
||||
g := w.h.groups["feat/g"]
|
||||
last := w.ctl.checks[len(w.ctl.checks)-1]
|
||||
if last != "feat/g:"+IDOf(gCat, gHk)+","+IDOf(gCtl, gHc) {
|
||||
t.Fatalf("the composed check was not asked again: %v", w.ctl.checks)
|
||||
}
|
||||
w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed again"})
|
||||
w.settleAll()
|
||||
sort.Strings(w.forge.merges)
|
||||
if len(w.forge.merges) != 2 || w.forge.merges[0] != "novox/mesh-catalog#2@"+gHk || w.forge.merges[1] != "novox/mesh-controller#1@"+gHc {
|
||||
t.Fatalf("both members were not merged on the new composed verdict: %v", w.forge.merges)
|
||||
}
|
||||
if w.op.count() != 1 {
|
||||
t.Fatalf("a second Approve was asked: %d", w.op.count())
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0418 §4, issue 1275/AC3: a member the forge refuses stops the pass (the members after it are not asked), and is
|
||||
// merged again on its fresh check, the others after it in the same pass.
|
||||
func TestAMemberTheForgeRefusesStopsThePassAndIsMergedAgainOnItsFreshCheck(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
w.forge.refuse = errAskedAgain
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 1 {
|
||||
t.Fatalf("the pass stops at the first refusal: %v", w.forge.merges)
|
||||
}
|
||||
w.forge.refuse = nil
|
||||
w.later(2 * time.Minute)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 1 {
|
||||
t.Fatalf("a refused member is asked again with no event, on a timer or at each look: %v", w.forge.merges)
|
||||
}
|
||||
w.h.Checked(CheckedEvent{Group: "feat/g", ID: w.h.groups["feat/g"].Check.Asked, Verdict: "pass", Summary: "composed again"})
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 3 {
|
||||
t.Fatalf("a refused member is asked again on the next composed verdict, then the next: %v", w.forge.merges)
|
||||
}
|
||||
if d := w.delivery(IDOf(gCat, gHk)); d.Ready.Merged == nil {
|
||||
t.Fatalf("not merged: %+v", d.Ready)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 978 (the pin): a feature's single pull request in one repository is a lone delivery, asked alone.
|
||||
func TestAFeaturesSinglePullRequestIsALoneDeliveryAskedAlone(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
@@ -148,125 +80,6 @@ func TestTheGroupsAnswerSaysItsGateIsTheComposedChecks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0403 §1: a member whose own gate the group's composed check judges instead (the controller says its gate layer
|
||||
// "composed") counts as passed here once its repository's check did not fail, so the group's composed check is asked.
|
||||
func TestAMemberWhoseGateIsTheComposedChecksCountsAsPassed(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
w.h.PullUpdated(pr(gCtl, 1, gHc, "feat/g", "internal/a.go"))
|
||||
w.h.PullUpdated(pr(gCat, 2, gHk, "feat/g", "modules/app/module.json"))
|
||||
for _, c := range []struct {
|
||||
repo string
|
||||
n int
|
||||
head string
|
||||
}{{gCtl, 1, gHc}, {gCat, 2, gHk}} {
|
||||
v := judged(c.repo, c.n, c.head, "pass", gTrunk0)
|
||||
v.Gate = &struct {
|
||||
Verdict string `json:"verdict"`
|
||||
Summary string `json:"summary"`
|
||||
Modules []string `json:"modules,omitempty"`
|
||||
Dependents []string `json:"dependents,omitempty"`
|
||||
}{Verdict: GateComposed, Summary: "judged by its group's composed check"}
|
||||
v.RepoCheck = &struct {
|
||||
Verdict string `json:"verdict"`
|
||||
Summary string `json:"summary"`
|
||||
}{Verdict: "pass", Summary: "the repository's own check passed"}
|
||||
w.h.Checked(v)
|
||||
}
|
||||
w.settleAll()
|
||||
if len(w.ctl.checks) != 1 || w.ctl.checks[0] != "feat/g:"+IDOf(gCat, gHk)+","+IDOf(gCtl, gHc) {
|
||||
t.Fatalf("the composed check was not asked for members judged by it: %v", w.ctl.checks)
|
||||
}
|
||||
// A member whose repository check failed is not passed, whatever the composed check will say.
|
||||
w2 := newWorld(t)
|
||||
w2.h.PullUpdated(pr(gCtl, 1, gHc, "feat/g", "internal/a.go"))
|
||||
v := judged(gCtl, 1, gHc, "pass", gTrunk0)
|
||||
v.Gate = &struct {
|
||||
Verdict string `json:"verdict"`
|
||||
Summary string `json:"summary"`
|
||||
Modules []string `json:"modules,omitempty"`
|
||||
Dependents []string `json:"dependents,omitempty"`
|
||||
}{Verdict: GateComposed}
|
||||
v.RepoCheck = &struct {
|
||||
Verdict string `json:"verdict"`
|
||||
Summary string `json:"summary"`
|
||||
}{Verdict: "fail", Summary: "a test failed"}
|
||||
w2.h.Checked(v)
|
||||
if w2.state(IDOf(gCtl, gHc)) == Ready {
|
||||
t.Fatalf("a member whose repository check failed is ready")
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1049 (survey question 1, confirmed here): a composed check asked after one member has already merged is asked
|
||||
// with ALL the members, the merged one at the head it merged, never with the unmerged one alone. The controller needs two
|
||||
// or more members to compose (mesh-controller delivery.go askGroupCheck), and a merge of a contained head is a no-op
|
||||
// there.
|
||||
func TestAComposedCheckAskedAfterOneMemberMergedIsAskedWithAllMembers(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
w.forge.refuseRepo = map[string]error{gCtl: errAskedAgain}
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
w.h.PullMerged(merged(gCat, 2, gHk, gT1))
|
||||
w.settleAll()
|
||||
if g := w.h.groups["feat/g"]; g == nil || !g.Closed {
|
||||
t.Fatalf("the group did not close when a member merged: %+v", g)
|
||||
}
|
||||
// The unmerged member gets a new head and its own verdict: the group's composed check is due again.
|
||||
const gHc2 = "e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3"
|
||||
asked := len(w.ctl.checks)
|
||||
w.h.PullUpdated(pr(gCtl, 1, gHc2, "feat/g", "internal/a.go"))
|
||||
w.h.Checked(judged(gCtl, 1, gHc2, "pass", gT1))
|
||||
w.settleAll()
|
||||
var composed []string
|
||||
for _, c := range w.ctl.checks[asked:] {
|
||||
if len(c) > 7 && c[:7] == "feat/g:" {
|
||||
composed = append(composed, c)
|
||||
}
|
||||
}
|
||||
if len(composed) != 1 || composed[0] != "feat/g:"+IDOf(gCat, gHk)+","+IDOf(gCtl, gHc2) {
|
||||
t.Fatalf("the composed check was asked with %v; wanted once, with both members (the merged one at its head)", composed)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1082 (the 2026-10-17 23:48 group): a closed group's member whose merge the tool refused after its sibling merged
|
||||
// asks the group's composed check again at once, and merges on its pass with the one Approve (ADR 0418 §4, ADR 0421).
|
||||
func TestAClosedGroupsMemberWhoseMergeWasRefusedAsksTheComposedCheckAgainAtOnce(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
w.forge.refuseRepo = map[string]error{gCtl: errAskedAgain}
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
// The catalogue member merged, the controller's was refused.
|
||||
w.h.PullMerged(merged(gCat, 2, gHk, gT1))
|
||||
asked := len(w.ctl.checks)
|
||||
w.settleAll()
|
||||
var composed []string
|
||||
for _, c := range w.ctl.checks[asked:] {
|
||||
if strings.HasPrefix(c, "feat/g:") {
|
||||
composed = append(composed, c)
|
||||
}
|
||||
}
|
||||
if len(composed) != 1 || composed[0] != "feat/g:"+IDOf(gCat, gHk)+","+IDOf(gCtl, gHc) {
|
||||
t.Fatalf("the refusal asked the composed check %v; wanted once, at once, with both members", composed)
|
||||
}
|
||||
// Not again while that check is on its way.
|
||||
w.later(2 * time.Minute)
|
||||
w.settleAll()
|
||||
if got := w.ctl.checks[asked:]; len(got) != 1 {
|
||||
t.Fatalf("asked again while the composed check was on its way: %v", got)
|
||||
}
|
||||
// It passes; the controller's member merges on the same Approve.
|
||||
w.forge.refuseRepo = nil
|
||||
g := w.h.groups["feat/g"]
|
||||
w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed again"})
|
||||
w.settleAll()
|
||||
if d := w.delivery(IDOf(gCtl, gHc)); d.Ready.Merged == nil {
|
||||
t.Fatalf("the refused member was not merged on the new composed verdict: %+v", d.Ready)
|
||||
}
|
||||
if w.op.count() != 1 {
|
||||
t.Fatalf("a second Approve was asked: %d", w.op.count())
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1082: a refusal loop is not silent. A merge refused past ten minutes in a closed group (ADR 0418 §3) is a stalled
|
||||
// line naming the member and the merge tool's last answer, which the controller raises as a condition.
|
||||
func TestARefusalLoopInAClosedGroupIsSaidAtOnce(t *testing.T) {
|
||||
@@ -290,116 +103,6 @@ func TestARefusalLoopInAClosedGroupIsSaidAtOnce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1047 (the live case of 2026-10-17 23:34, ADR 0395 §1, design 47): a group member that is ready when a third party's
|
||||
// merge moves its trunk, with no Approve asked yet (the composed check has not passed), has its own check asked at once,
|
||||
// and the Approve is asked once the group's composed check has passed on it.
|
||||
func TestATrunkMoveAsksTheCheckOfAGroupMemberWhoseApproveIsNotAskedYet(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
w.h.PullUpdated(pr(gCtl, 1, gHc, "feat/g", "internal/a.go"))
|
||||
w.h.PullUpdated(pr(gCat, 2, gHk, "feat/g", "modules/app/module.json"))
|
||||
w.h.Checked(judged(gCtl, 1, gHc, "pass", gTrunk0))
|
||||
w.h.Checked(judged(gCat, 2, gHk, "pass", gTrunk0))
|
||||
w.settleAll()
|
||||
if w.op.count() != 0 {
|
||||
t.Fatalf("the composed check has not passed, yet the operator was asked: %d", w.op.count())
|
||||
}
|
||||
asked := len(w.ctl.checks)
|
||||
w.h.PullMerged(merged(gCat, 5, "5555555555555555", gT1))
|
||||
w.settleAll()
|
||||
got := w.ctl.checks[asked:]
|
||||
if len(got) != 1 || got[0] != ":"+IDOf(gCat, gHk) {
|
||||
t.Fatalf("the trunk's move asked %v; wanted the moved member's own check once", got)
|
||||
}
|
||||
if last := w.ctl.baseSHAs[len(w.ctl.baseSHAs)-1]; last != gT1[:12] && last != gT1 {
|
||||
t.Fatalf("its check was not asked against the trunk's head: %v", w.ctl.baseSHAs)
|
||||
}
|
||||
// Not asked again at the next look while it is on its way.
|
||||
w.later(time.Second)
|
||||
w.settleAll()
|
||||
if len(w.ctl.checks[asked:]) != 1 {
|
||||
t.Fatalf("asked twice: %v", w.ctl.checks[asked:])
|
||||
}
|
||||
// The composed check that was on its way when the trunk moved answers, judged against the old trunk (ADR 0403 §6): it is
|
||||
// out of date as it lands, so the group does not become ready on it and the operator is not asked.
|
||||
g := w.h.groups["feat/g"]
|
||||
w.later(time.Minute)
|
||||
w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed on the old trunk",
|
||||
Members: []struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
Commit string `json:"commit"`
|
||||
Trunk string `json:"trunk,omitempty"`
|
||||
}{{Owner: "novox", Repo: "mesh-controller", Commit: gHc, Trunk: gTrunk0[:12]},
|
||||
{Owner: "novox", Repo: "mesh-catalog", Commit: gHk, Trunk: gTrunk0[:12]}}})
|
||||
w.settleAll()
|
||||
if w.op.count() != 0 {
|
||||
t.Fatalf("the operator was asked on a composed pass judged against the old trunk: %d", w.op.count())
|
||||
}
|
||||
// The moved member's fresh verdict arrives: the composed check is asked again, and only its pass asks the operator.
|
||||
w.later(time.Minute)
|
||||
composedBefore := len(w.ctl.checks)
|
||||
w.h.Checked(judged(gCat, 2, gHk, "pass", gT1))
|
||||
w.settleAll()
|
||||
last := w.ctl.checks[len(w.ctl.checks)-1]
|
||||
if len(w.ctl.checks) == composedBefore || !strings.HasPrefix(last, "feat/g:") {
|
||||
t.Fatalf("the composed check was not asked again on the fresh verdict: %v", w.ctl.checks[composedBefore:])
|
||||
}
|
||||
if w.op.count() != 0 {
|
||||
t.Fatalf("the operator was asked before the new composed check passed: %d", w.op.count())
|
||||
}
|
||||
g = w.h.groups["feat/g"]
|
||||
w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed on the new trunk",
|
||||
Members: []struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
Commit string `json:"commit"`
|
||||
Trunk string `json:"trunk,omitempty"`
|
||||
}{{Owner: "novox", Repo: "mesh-controller", Commit: gHc, Trunk: gTrunk0[:12]},
|
||||
{Owner: "novox", Repo: "mesh-catalog", Commit: gHk, Trunk: gT1[:12]}}})
|
||||
w.settleAll()
|
||||
if w.op.count() != 1 {
|
||||
t.Fatalf("the operator is asked once, on the new pass: %d", w.op.count())
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1047 (review round 2): the other order. The moved member's fresh verdict lands before the stale composed pass (one
|
||||
// build against two): the pass is out of date as it lands, the composed check is asked again, and the operator is asked
|
||||
// only after that check passes.
|
||||
func TestAStaleComposedPassLandingAfterTheMovedMembersFreshVerdictIsAskedAgain(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
w.h.PullUpdated(pr(gCtl, 1, gHc, "feat/g", "internal/a.go"))
|
||||
w.h.PullUpdated(pr(gCat, 2, gHk, "feat/g", "modules/app/module.json"))
|
||||
w.h.Checked(judged(gCtl, 1, gHc, "pass", gTrunk0))
|
||||
w.h.Checked(judged(gCat, 2, gHk, "pass", gTrunk0))
|
||||
w.settleAll()
|
||||
g := w.h.groups["feat/g"]
|
||||
w.later(time.Minute)
|
||||
w.h.PullMerged(merged(gCat, 5, "5555555555555555", gT1))
|
||||
w.settleAll()
|
||||
w.later(time.Minute)
|
||||
w.h.Checked(judged(gCat, 2, gHk, "pass", gT1)) // the fresh verdict first
|
||||
w.settleAll()
|
||||
w.later(time.Minute)
|
||||
before := len(w.ctl.checks)
|
||||
w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed on the old trunk",
|
||||
Members: []struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
Commit string `json:"commit"`
|
||||
Trunk string `json:"trunk,omitempty"`
|
||||
}{{Owner: "novox", Repo: "mesh-controller", Commit: gHc, Trunk: gTrunk0[:12]},
|
||||
{Owner: "novox", Repo: "mesh-catalog", Commit: gHk, Trunk: gTrunk0[:12]}}})
|
||||
w.settleAll()
|
||||
if len(w.ctl.checks) == before || !strings.HasPrefix(w.ctl.checks[len(w.ctl.checks)-1], "feat/g:") {
|
||||
t.Fatalf("the composed check was not asked again after the stale pass: %v", w.ctl.checks[before:])
|
||||
}
|
||||
if w.op.count() != 0 {
|
||||
t.Fatalf("the operator was asked before the new composed check passed: %d", w.op.count())
|
||||
}
|
||||
}
|
||||
|
||||
// ownPending is the merge tool's refusal of a member whose own mesh/repo-check stands pending after the group's composed
|
||||
// check set its gate (issue 1049: the forge's "not a success: mesh/repo-check (pending)").
|
||||
var ownPending = errors.New("the forge refused gitea_merge_pull_request: novox/mesh-controller#1 is not merged: main requires " +
|
||||
@@ -416,28 +119,3 @@ func ownChecks(w *world, from int) (own, composed []string) {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Issue 1049 (the 2026-10-18 02:04 group): a closed group's member refused for a pending status of its OWN (the repository
|
||||
// check a sibling's new head put back) asks its own check, once for the head, and not the composed check, which sets no
|
||||
// repository check; a second pass at the same head asks nothing more.
|
||||
func TestAClosedGroupsMemberRefusedForItsOwnPendingStatusAsksItsOwnCheckOncePerHead(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
w.forge.refuseRepo = map[string]error{gCtl: ownPending}
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
w.h.PullMerged(merged(gCat, 2, gHk, gT1))
|
||||
asked := len(w.ctl.checks)
|
||||
w.settleAll()
|
||||
own, composed := ownChecks(w, asked)
|
||||
if len(composed) != 0 || len(own) != 1 || own[0] != ":"+IDOf(gCtl, gHc) {
|
||||
t.Fatalf("own checks %v, composed checks %v; wanted the member's own check once and no composed check", own, composed)
|
||||
}
|
||||
// Later passes, the same head, the same refusal: nothing more is asked.
|
||||
w.later(2 * time.Minute)
|
||||
w.settleAll()
|
||||
w.later(2 * time.Minute)
|
||||
w.settleAll()
|
||||
if got := len(w.ctl.checks) - asked; got != 1 {
|
||||
t.Fatalf("asked %d checks at the same head, wanted the one: %v", got, w.ctl.checks[asked:])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -623,16 +623,15 @@ func (h *Holder) takePlaceOf(old string, d *Delivery) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// decidedOnHead is the verdict the forge's statuses on a head hold, when the gate decided one: passed, warned
|
||||
// or failed. Nil for none, pending, or a check that could not run — those are asked again.
|
||||
// decidedOnHead is the verdict the forge's statuses on a head hold, when the checker decided one: passed, warned or
|
||||
// failed. Nil for none, pending, or a check that could not run — the checker checks those again.
|
||||
func decidedOnHead(checks map[string]string, now time.Time) *Verdict {
|
||||
switch checks["mesh/merge-gate"] {
|
||||
switch checks[ctxCheck] {
|
||||
case "success", "warning", "failure":
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
return &Verdict{At: now, Gate: verdictOfState(checks["mesh/merge-gate"]),
|
||||
Summary: "the head's mesh/merge-gate, as the forge holds it", Repo: verdictOfState(checks["mesh/repo-check"])}
|
||||
return verdictOfCheck(checks[ctxCheck], "", now)
|
||||
}
|
||||
|
||||
// joinGroup puts a new head in the group its branch name makes with the other repositories' open pull
|
||||
@@ -739,9 +738,8 @@ func (h *Holder) PullMerged(p PullEvent) {
|
||||
return
|
||||
}
|
||||
fresh.FromForge = true
|
||||
if s := p.HeadChecks["mesh/merge-gate"]; s != "" {
|
||||
fresh.Check = &Verdict{At: now, Gate: verdictOfState(s), Summary: "the head's mesh/merge-gate, as the forge holds it",
|
||||
Repo: verdictOfState(p.HeadChecks["mesh/repo-check"])}
|
||||
if s := p.HeadChecks[ctxCheck]; s != "" {
|
||||
fresh.Check = verdictOfCheck(s, "", now)
|
||||
}
|
||||
d, ts = fresh, []Transition{t}
|
||||
if d.Check != nil {
|
||||
@@ -757,9 +755,8 @@ func (h *Holder) PullMerged(p PullEvent) {
|
||||
PathsTruncated: p.PathsTruncated, Removed: p.Removed, ModuleDirs: p.ModuleDirs, ModuleDirsSaid: p.ModuleDirsSaid,
|
||||
After: AfterIn(p.Body), Issues: IssuesIn(p.Title + "\n" + p.Body), Created: now, FromForge: true}
|
||||
// Its check, as the forge holds it on the head: the verdict the pull request merged with.
|
||||
if s := p.HeadChecks["mesh/merge-gate"]; s != "" {
|
||||
d.Check = &Verdict{At: now, Gate: verdictOfState(s), Summary: "the head's mesh/merge-gate, as the forge holds it",
|
||||
Repo: verdictOfState(p.HeadChecks["mesh/repo-check"])}
|
||||
if s := p.HeadChecks[ctxCheck]; s != "" {
|
||||
d.Check = verdictOfCheck(s, "", now)
|
||||
}
|
||||
t, err := Apply(d, EvAnnounced, false, Facts{Now: now, Why: fmt.Sprintf("%s#%d merged; its head was not "+
|
||||
"heard by this owner, so it is made from the forge's word", d.Repository, d.Number)})
|
||||
@@ -904,151 +901,6 @@ func (h *Holder) PullClosed(p PullEvent) {
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// CheckedEvent is the controller's `checked`.
|
||||
type CheckedEvent struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
Number int `json:"number,omitempty"`
|
||||
Commit string `json:"commit"`
|
||||
Verdict string `json:"verdict"`
|
||||
Summary string `json:"summary"`
|
||||
ID string `json:"id"`
|
||||
On string `json:"on,omitempty"`
|
||||
Report string `json:"report,omitempty"`
|
||||
Gate *struct {
|
||||
Verdict string `json:"verdict"`
|
||||
Summary string `json:"summary"`
|
||||
Modules []string `json:"modules,omitempty"`
|
||||
Dependents []string `json:"dependents,omitempty"`
|
||||
} `json:"gate,omitempty"`
|
||||
RepoCheck *struct {
|
||||
Verdict string `json:"verdict"`
|
||||
Summary string `json:"summary"`
|
||||
} `json:"repo-check,omitempty"`
|
||||
Plan *DeliveryPlan `json:"plan,omitempty"`
|
||||
Group string `json:"group,omitempty"`
|
||||
Members []struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
Commit string `json:"commit"`
|
||||
Trunk string `json:"trunk,omitempty"`
|
||||
} `json:"members,omitempty"`
|
||||
// Trunk is the trunk commit the head was merged into and judged as, Beside the other repositories' commits the
|
||||
// check read, and Took how long it ran (novox/hq ADR 0323 §3).
|
||||
Trunk string `json:"trunk,omitempty"`
|
||||
Beside map[string]string `json:"beside,omitempty"`
|
||||
Took string `json:"took,omitempty"`
|
||||
}
|
||||
|
||||
// Checked takes a verdict: a head's own, or a group's composed check.
|
||||
func (h *Holder) Checked(c CheckedEvent) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
now := h.Now()
|
||||
if c.Group != "" {
|
||||
g := h.groups[c.Group]
|
||||
if g == nil || g.Check == nil || g.Check.Asked != c.ID {
|
||||
h.Logf("[mesh-delivery] a composed check of %s (%s) that is not the one asked: history", c.Group, c.ID)
|
||||
return
|
||||
}
|
||||
g.Check.Verdict, g.Check.Summary = c.Verdict, c.Summary
|
||||
for _, m := range h.membersOf(g) {
|
||||
h.mergeAgainNow(m) // the group's composed check answered: its members' merges are asked at once
|
||||
}
|
||||
// Each member's head and the trunk commit it was merged into (novox/hq ADR 0323 §6).
|
||||
for _, m := range c.Members {
|
||||
for id := range g.Check.Commits {
|
||||
if d := h.deliveries[id]; d != nil && strings.EqualFold(d.Repository, m.Owner+"/"+m.Repo) &&
|
||||
sameCommit(d.Commit, m.Commit) && m.Trunk != "" {
|
||||
if g.Check.Trunks == nil {
|
||||
g.Check.Trunks = map[string]string{}
|
||||
}
|
||||
g.Check.Trunks[id] = m.Trunk
|
||||
}
|
||||
}
|
||||
}
|
||||
// A composed check that was on its way when a trunk moved lands judged against the old trunk: out of date as it lands
|
||||
// (novox/hq ADR 0403 §6, issue 1047), and asked again once a member is judged again.
|
||||
if g.Check.OutOfDate == "" {
|
||||
for id, judged := range g.Check.Trunks {
|
||||
if d := h.deliveries[id]; d != nil && d.MergedAs == "" && !d.State.Final() && d.TrunkNow != "" && !sameCommit(judged, d.TrunkNow) {
|
||||
g.Check.OutOfDate = fmt.Sprintf("composed check out of date: %s was judged against trunk %s, the trunk is now %s",
|
||||
id, shortOf(judged, 12), shortOf(d.TrunkNow, 12))
|
||||
// From when the composed check was asked: a member judged since then, even before this verdict landed,
|
||||
// counts as judged again.
|
||||
g.Check.OutOfDateAt = g.Check.At
|
||||
h.Logf("[mesh-delivery] group %s: %s", g.ID, g.Check.OutOfDate)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
g.Updated = now
|
||||
h.keepGroup(g)
|
||||
h.Logf("[mesh-delivery] group %s composed: %s — %s", g.ID, c.Verdict, c.Summary)
|
||||
return
|
||||
}
|
||||
repository := strings.ToLower(c.Owner + "/" + c.Repo)
|
||||
id := IDOf(repository, c.Commit)
|
||||
d := h.deliveries[id]
|
||||
if d == nil {
|
||||
if c.Number == 0 {
|
||||
return // a check of no pull request: nothing to deliver
|
||||
}
|
||||
d = &Delivery{ID: id, Repository: repository, Commit: c.Commit, Number: c.Number, Created: now}
|
||||
t, err := Apply(d, EvAnnounced, false, Facts{Now: now, Why: "its verdict arrived before its head was heard"})
|
||||
if err != nil {
|
||||
h.refuse(err)
|
||||
return
|
||||
}
|
||||
h.moved(d, []Transition{t})
|
||||
}
|
||||
if d.Ready != nil && !d.Ready.OwnAskedAt.IsZero() {
|
||||
// The own check asked for this head answered (issue 1792): one own ask is in flight at a time, so a status still
|
||||
// pending or missing after its answer is asked again.
|
||||
d.Ready.OwnAskedAt = time.Time{}
|
||||
h.keep(d)
|
||||
}
|
||||
if d.State == Rejected && c.Trunk != "" && d.Check != nil && !sameCommit(d.Check.Trunk, c.Trunk) {
|
||||
// Judged again against another trunk commit — the merge tool's ask of a verdict out of date, or a check asked
|
||||
// elsewhere (novox/hq ADR 0323 §5, §7): taken as the recheck it is.
|
||||
t, err := Apply(d, EvRecheck, true, Facts{Now: now, By: byOwner, Why: fmt.Sprintf("judged again against trunk %s "+
|
||||
"(it was rejected against trunk %s)", shortOf(c.Trunk, 12), shortOf(d.Check.Trunk, 12))})
|
||||
if err != nil {
|
||||
h.refuse(err)
|
||||
return
|
||||
}
|
||||
h.moved(d, []Transition{t})
|
||||
}
|
||||
if d.State != Proposed {
|
||||
h.Logf("[mesh-delivery] %s is %s: a verdict for it now is history (%s)", d.ID, d.State, c.Verdict)
|
||||
return
|
||||
}
|
||||
v := &Verdict{ID: c.ID, On: c.On, At: now, Gate: c.Verdict, Summary: c.Summary, Report: c.Report,
|
||||
Head: shortOf(c.Commit, 12), Trunk: c.Trunk, Beside: c.Beside, Took: c.Took}
|
||||
if c.Gate != nil {
|
||||
v.Gate, v.Summary = c.Gate.Verdict, c.Gate.Summary
|
||||
v.Modules, v.Dependents = c.Gate.Modules, c.Gate.Dependents
|
||||
}
|
||||
if c.RepoCheck != nil {
|
||||
v.Repo, v.RepoSaid = c.RepoCheck.Verdict, c.RepoCheck.Summary
|
||||
}
|
||||
if v.Gate == "" {
|
||||
v.Gate = "error"
|
||||
}
|
||||
d.Check, d.AskedAgain = v, nil
|
||||
h.mergeAgainNow(d) // a verdict taken: the merge is asked at once, not a minute after a refusal
|
||||
if c.Plan != nil {
|
||||
d.Plan = c.Plan
|
||||
}
|
||||
if why := d.outOfDate(); why != "" {
|
||||
// Kept and shown, and it does not count: the delivery stays proposed (ADR 0323 §7). If it passes, its check is
|
||||
// asked against the trunk's head before the operator is (ADR 0421 §3, askFresh).
|
||||
d.FreshWanted = d.Check.Passes()
|
||||
h.Logf("[mesh-delivery] %s: its verdict %s is %s", d.ID, c.ID, why)
|
||||
}
|
||||
h.settleAndKeep(d)
|
||||
}
|
||||
|
||||
// WalkMoved takes a walk's record, from `plan-moved` or the controller's walks: the delivery it walks
|
||||
// moves with it; a backlog walk's carried builds are credited to the deliveries that published them.
|
||||
func (h *Holder) WalkMoved(w Walk) {
|
||||
@@ -1384,15 +1236,11 @@ func (h *Holder) plan() []func() {
|
||||
}
|
||||
}
|
||||
|
||||
// Proposed, with no verdict and no check asked: its check is asked at once (issue 1115), or the verdict that
|
||||
// stands on its head at the forge is adopted.
|
||||
// Proposed, with no verdict: the checker's mesh/check that stands on its head at the forge is adopted (issue 1115);
|
||||
// nothing is asked of the controller (issue 1318).
|
||||
if a := h.askChecks(now); a != nil {
|
||||
asks = append(asks, a)
|
||||
}
|
||||
// Proposed, its check asked and held by no entry of the build queue past the bound: asked again (issue 1274).
|
||||
if a := h.lostChecks(now); a != nil {
|
||||
asks = append(asks, a)
|
||||
}
|
||||
|
||||
// Merged with no walk yet: tied to the newest walk heard that answers it — by name, by its own commit, or by
|
||||
// the forge's word that the walk's commit contains it, asked here (novox/hq issue 362).
|
||||
@@ -1947,59 +1795,24 @@ func heldForGroup(d *Delivery, g *Group) bool {
|
||||
return d.State == Held && strings.HasPrefix(d.HeldWhy, fmt.Sprintf("its group %s's composed check", g.ID))
|
||||
}
|
||||
|
||||
// standingVerdict is the passing verdict an open pull request's head holds at the forge, when every merge check and
|
||||
// every context its base requires stands as success or warning; nil otherwise, a missing context included (issue 1115).
|
||||
func standingVerdict(p OpenPull, now time.Time) *Verdict {
|
||||
if p.Checks == nil {
|
||||
return nil
|
||||
}
|
||||
contexts := []string{"mesh/merge-gate", "mesh/repo-check"} // the controller's; mesh/check is taken in askChecks
|
||||
for _, c := range p.Required {
|
||||
if !slices.Contains(contexts, c) {
|
||||
contexts = append(contexts, c)
|
||||
}
|
||||
}
|
||||
for _, c := range contexts {
|
||||
if st := p.Checks[c].State; st != "success" && st != "warning" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return &Verdict{At: now, Gate: verdictOfState(p.Checks["mesh/merge-gate"].State),
|
||||
Summary: "the head's mesh/merge-gate, as the forge holds it", Repo: verdictOfState(p.Checks["mesh/repo-check"].State)}
|
||||
}
|
||||
|
||||
// askedCheck is whether a proposed delivery's own check stands asked: a kept ask with no id is none.
|
||||
func askedCheck(d *Delivery) bool { return d.CheckAsk != nil && d.CheckAsk.ID != "" }
|
||||
|
||||
// askChecks are the asks of proposed deliveries with no verdict, at each look, at once (issue 1115): the forge's open
|
||||
// pull requests are read once, a passing verdict standing on a head is adopted (the delivery goes on to ready with no
|
||||
// new check), and a head with no check asked is asked its check. Under the lock; the asks run outside it.
|
||||
// askChecks reads the forge's open pull requests once for the proposed deliveries with no verdict (issue 1115), and takes the
|
||||
// checker's mesh/check where one stands on a head (issue 1302); the base's required statuses are kept. Nothing is asked of
|
||||
// the controller: the checker checks every head on every move (ADR 0447 §4, issue 1318). Under the lock; the read runs outside it.
|
||||
func (h *Holder) askChecks(now time.Time) func() {
|
||||
type want struct {
|
||||
id, owner, repo, head string
|
||||
number int
|
||||
member Member
|
||||
ask bool
|
||||
why string
|
||||
}
|
||||
var wants []want
|
||||
for _, d := range h.deliveries {
|
||||
if d.State != Proposed || d.Check != nil || d.MergedAs != "" || d.Number == 0 {
|
||||
continue
|
||||
}
|
||||
w := want{id: d.ID, head: d.Commit, number: d.Number, member: MemberOf(d)}
|
||||
w := want{id: d.ID, head: d.Commit, number: d.Number}
|
||||
w.owner, w.repo, _ = strings.Cut(d.Repository, "/")
|
||||
// A base that requires mesh/check is the checker's: nothing is asked of the controller (issue 1302).
|
||||
if !askedCheck(d) && !d.judgedByCheck() {
|
||||
if tried, was := h.askTried[d.ID]; was && now.Sub(tried) < time.Minute {
|
||||
h.lookAt(tried.Add(time.Minute), "a proposed delivery's check that could not be asked")
|
||||
} else {
|
||||
w.ask = true
|
||||
h.askTried[d.ID] = now
|
||||
h.lookAt(now.Add(time.Minute), "a proposed delivery's check that could not be asked")
|
||||
w.why = fmt.Sprintf("proposed for %s with no verdict and no check asked", now.Sub(d.Since).Round(time.Second))
|
||||
}
|
||||
}
|
||||
wants = append(wants, w)
|
||||
}
|
||||
if len(wants) == 0 {
|
||||
@@ -2007,77 +1820,25 @@ func (h *Holder) askChecks(now time.Time) func() {
|
||||
}
|
||||
return func() {
|
||||
open, err := h.Forge.OpenPulls()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, w := range wants {
|
||||
var standing *Verdict
|
||||
oneStatus := false
|
||||
if err == nil {
|
||||
for _, p := range open {
|
||||
if p.Number == w.number && strings.EqualFold(p.Owner, w.owner) && strings.EqualFold(p.Repo, w.repo) &&
|
||||
sameCommit(p.HeadSHA, w.head) {
|
||||
if slices.Contains(p.Required, ctxCheck) {
|
||||
// Its base requires mesh/check (issue 1302): its verdict is that status, when it stands, and
|
||||
// nothing is asked of the controller.
|
||||
oneStatus = true
|
||||
h.mu.Lock()
|
||||
h.noteRequired(h.deliveries[w.id], p.Required)
|
||||
if c := p.Checks[ctxCheck]; c.State != "" {
|
||||
h.takeCheckStatus(w.owner+"/"+w.repo, w.head, c.State, c.Description)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
standing = standingVerdict(p, h.Now())
|
||||
for _, p := range open {
|
||||
if p.Number == w.number && strings.EqualFold(p.Owner, w.owner) && strings.EqualFold(p.Repo, w.repo) &&
|
||||
sameCommit(p.HeadSHA, w.head) {
|
||||
h.mu.Lock()
|
||||
h.noteRequired(h.deliveries[w.id], p.Required)
|
||||
if c := p.Checks[ctxCheck]; c.State != "" {
|
||||
h.takeCheckStatus(w.owner+"/"+w.repo, w.head, c.State, c.Description)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
}
|
||||
if oneStatus {
|
||||
continue
|
||||
}
|
||||
if standing != nil {
|
||||
h.mu.Lock()
|
||||
if d := h.deliveries[w.id]; d != nil && d.State == Proposed && d.Check == nil {
|
||||
delete(h.askTried, w.id)
|
||||
d.Check = standing
|
||||
h.Logf("[mesh-delivery] %s: a passing verdict already stands on its head at the forge; adopted, no check asked", w.id)
|
||||
h.settleAndKeep(d)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
if w.ask {
|
||||
h.askOwnCheck(w.id, w.member, w.why)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// askOwnCheck asks the controller a proposed delivery's own check and keeps the ask.
|
||||
func (h *Holder) askOwnCheck(id string, m Member, why string) {
|
||||
asked, err := h.Controller.Check("", []Member{m})
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] %s's check cannot be asked yet: %v", id, err)
|
||||
return
|
||||
}
|
||||
if asked == "" {
|
||||
h.Logf("[mesh-delivery] ERROR: %s's check was asked and the controller's answer held no check id: it is not taken as asked, "+
|
||||
"and asked again in a minute at most", id)
|
||||
return
|
||||
}
|
||||
delete(h.askTried, id)
|
||||
d := h.deliveries[id]
|
||||
if d == nil || d.State != Proposed || d.Check != nil || askedCheck(d) {
|
||||
return
|
||||
}
|
||||
d.CheckAsk = &CheckAsk{ID: asked, At: h.Now(), By: byOwner, Why: why}
|
||||
h.Logf("[mesh-delivery] %s: its check asked of the controller as %s — %s", id, asked, why)
|
||||
d.Owed = append(d.Owed, Effect{Kind: EffectNote, Commit: d.Commit, Since: h.Now(),
|
||||
Line: oneLine(fmt.Sprintf("%s delivery %s: its check asked by %s as %s: %s", h.Now().UTC().Format(time.RFC3339),
|
||||
id, byOwner, asked, why))})
|
||||
h.keep(d)
|
||||
}
|
||||
|
||||
// adopt makes a delivery of a walk no pull request is known for: running already — at the switch, or on the
|
||||
// controller's own path — it is delivering; waiting for a word, it waits for a person.
|
||||
func (h *Holder) adopt(w Walk) {
|
||||
@@ -2168,69 +1929,10 @@ func (h *Holder) planGroup(g *Group, members []*Delivery, now time.Time) []func(
|
||||
h.keepGroup(g)
|
||||
return nil
|
||||
}
|
||||
commits := commitsOf(members)
|
||||
// The composed check, once every member is ready. Asked again for new heads, even once the
|
||||
// group is closed: a member rebased or pushed is a new head, and the group waits for its composed check rather
|
||||
// than being held for a person (novox/hq ADR 0323 §6, issue 406). One out of date is asked again once a member
|
||||
// was judged again after it went out of date: a recheck, or the merge tool's ask.
|
||||
allReady := true
|
||||
for _, d := range members {
|
||||
// In a closed group a member on the trunk counts — merged and on its way, or already delivered (reached).
|
||||
if d.State != Ready && !(g.Closed && d.MergedAs != "" && (!d.State.Final() || d.State.Reached())) {
|
||||
allReady = false
|
||||
}
|
||||
}
|
||||
// A group judged by mesh/check (issue 1302, ADR 0447 §2): its verdict is the checker's one verdict at its members'
|
||||
// heads, read, not asked; nothing below asks a composed check or owes a member's mesh/merge-gate.
|
||||
oneStatus := oneStatusGroup(members)
|
||||
if oneStatus {
|
||||
h.composeFromChecker(g, members, now)
|
||||
}
|
||||
again := !oneStatus && g.Check != nil && (!sameMembers(g.Check.Commits, commits) || g.Check.OutOfDate != "" && rejudgedSince(members, g.Check.OutOfDateAt))
|
||||
// A closed group's member whose merge the tool refused, after the composed check answered, is judged again at once
|
||||
// (novox/hq ADR 0418 §4, issue 1082): the refusal is a sibling's merge having moved what the verdict stands on, and
|
||||
// nothing else asks the composed check again. Not while a check is on its way (its verdict is empty then).
|
||||
// **A refusal that names the member's OWN pending status** (mesh/repo-check, with the gate not pending: the composed
|
||||
// check set it) is not that: the composed check sets no repository check, so asking it again changes nothing. The
|
||||
// member's own check is asked, once for the head (issue 1049); its verdict sets the status.
|
||||
if !oneStatus && !again && g.Closed && g.Check != nil && g.Check.Verdict != "" {
|
||||
for _, d := range members {
|
||||
if !refusedAfter(d, g.Check.At) {
|
||||
continue
|
||||
}
|
||||
if ownStatusPending(d.Ready.Answer) {
|
||||
if a := h.askOwnAfterRefusal(d); a != nil {
|
||||
asks = append(asks, a)
|
||||
}
|
||||
continue
|
||||
}
|
||||
again = true
|
||||
h.Logf("[mesh-delivery] group %s: the merge of %s was refused (%s), so its composed check is asked again", g.ID,
|
||||
d.ID, d.Ready.Answer)
|
||||
break
|
||||
}
|
||||
}
|
||||
if !oneStatus && allReady && (g.Check == nil && !g.Closed || again) {
|
||||
ms := membersOfAsk(members)
|
||||
id := g.ID
|
||||
asks = append(asks, func() {
|
||||
asked, err := h.Controller.Check(id, ms)
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
g := h.groups[id]
|
||||
if g == nil {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] group %s's composed check cannot be asked yet: %v", id, err)
|
||||
h.again("a group's composed check that could not be asked")
|
||||
return
|
||||
}
|
||||
g.Check = &GroupCheck{Asked: asked, At: h.Now(), Commits: commits}
|
||||
g.Updated = h.Now()
|
||||
h.keepGroup(g)
|
||||
})
|
||||
}
|
||||
// A group is judged by mesh/check (issue 1302, ADR 0447 §2): its verdict is the checker's one verdict at its members'
|
||||
// heads, read, not asked; nothing asks a composed check of the controller and no member is owed a mesh/merge-gate
|
||||
// (issue 1318).
|
||||
h.composeFromChecker(g, members, now)
|
||||
// A superseded member of a closed group: whether a later delivered walk on its trunk carried every module it
|
||||
// moves, ADR 0270's covered test, kept on it as CoveredBy. Its own state stays superseded — a final state never
|
||||
// moves — and its group counts it as reached (novox/hq issue 442).
|
||||
@@ -2277,26 +1979,6 @@ func (h *Holder) planGroup(g *Group, members []*Delivery, now time.Time) []func(
|
||||
g.Updated = now
|
||||
h.keepGroup(g)
|
||||
}
|
||||
// Each member's mesh/merge-gate is the composed check's (ADR 0403 §1, §6): pending, saying why, while no composed
|
||||
// verdict stands at these heads. The verdict itself sets it, success or failure, in the forge module.
|
||||
if why := composedPending(g, members); why != "" && !oneStatus {
|
||||
if g.SaidGate == nil {
|
||||
g.SaidGate = map[string]string{}
|
||||
}
|
||||
for _, d := range members {
|
||||
if d.MergedAs != "" || d.State.Final() || g.SaidGate[d.ID] == why {
|
||||
continue
|
||||
}
|
||||
g.SaidGate[d.ID] = why
|
||||
g.Owed = append(g.Owed, Effect{Kind: EffectStatus, Commit: d.Commit, Context: "mesh/merge-gate", State: "pending",
|
||||
Line: clip("group "+g.ID+": "+why, 140), Since: now, Last: d.Repository + "#" + fmt.Sprint(d.Number) + " " + d.HTMLURL})
|
||||
g.Updated = now
|
||||
h.keepGroup(g)
|
||||
}
|
||||
} else if len(g.SaidGate) > 0 {
|
||||
g.SaidGate = nil
|
||||
h.keepGroup(g)
|
||||
}
|
||||
// Its word, once, when every member is merged (ADR 0418 §2).
|
||||
if g.Closed {
|
||||
asks = append(asks, h.groupWord(g, members)...)
|
||||
@@ -2304,22 +1986,6 @@ func (h *Holder) planGroup(g *Group, members []*Delivery, now time.Time) []func(
|
||||
return asks
|
||||
}
|
||||
|
||||
// composedPending is why a group member's gate stands pending: the composed check has given no verdict at the members'
|
||||
// heads as they are, or its verdict is out of date; empty once one stands, which the forge module then sets as every
|
||||
// member's gate (novox/hq ADR 0403 §6).
|
||||
func composedPending(g *Group, members []*Delivery) string {
|
||||
c := g.Check
|
||||
switch {
|
||||
case c == nil || c.Verdict == "":
|
||||
return "its group's composed check has not given a verdict at the members' heads yet"
|
||||
case !sameMembers(c.Commits, commitsOf(members)):
|
||||
return "composed again for the group's new heads"
|
||||
case c.OutOfDate != "":
|
||||
return c.OutOfDate
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// membersOfAsk are a group's members as the controller is asked about them: by id. No order is asked for or kept; the
|
||||
// plan's tiers take it from the module graph (novox/hq ADR 0418 §2, ADR 0419).
|
||||
func membersOfAsk(members []*Delivery) []Member {
|
||||
@@ -2823,50 +2489,3 @@ func refusedAfter(d *Delivery, at time.Time) bool {
|
||||
r := d.Ready
|
||||
return r != nil && r.Merged == nil && d.MergedAs == "" && r.RefusedSince != nil && !r.LastTry.Before(at)
|
||||
}
|
||||
|
||||
// ownStatusPending is whether the merge tool refused a merge because a status of the member's own merge check is
|
||||
// pending or missing (issue 1276) while its gate is not: the forge's "not a success: mesh/repo-check (pending)" (gitea merge.ts, mergeableUnder).
|
||||
// A refusal that also names mesh/merge-gate is the group's composed check's to answer, as is a verdict out of date.
|
||||
func ownStatusPending(answer string) bool {
|
||||
_, listed, ok := strings.Cut(answer, "not a success:")
|
||||
return ok && (strings.Contains(listed, "mesh/repo-check (pending)") || strings.Contains(listed, "mesh/repo-check (missing)")) &&
|
||||
!strings.Contains(listed, "mesh/merge-gate")
|
||||
}
|
||||
|
||||
// askOwnAfterRefusal asks a member's own check once for its head, after the merge tool refused it for a pending status of
|
||||
// its own. Under the lock; the ask is made outside it. Nil when it was asked already for this head.
|
||||
func (h *Holder) askOwnAfterRefusal(d *Delivery) func() {
|
||||
r := d.Ready
|
||||
if !r.OwnAskedAt.IsZero() || d.judgedByCheck() {
|
||||
return nil // asked already; or the checker's, who checks it again on its own (issue 1302)
|
||||
}
|
||||
r.OwnAskedAt = h.Now()
|
||||
h.keep(d)
|
||||
id, m := d.ID, MemberOf(d)
|
||||
return func() {
|
||||
asked, err := h.Controller.Check("", []Member{m})
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
d := h.deliveries[id]
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] %s's own check cannot be asked yet: %v", id, err)
|
||||
if d != nil && d.Ready != nil {
|
||||
d.Ready.OwnAskedAt = time.Time{}
|
||||
h.keep(d)
|
||||
}
|
||||
h.again("a member's own check that could not be asked")
|
||||
return
|
||||
}
|
||||
h.Logf("[mesh-delivery] %s: the merge tool refused it for a pending status of its own, so its own check is asked as %s",
|
||||
id, asked)
|
||||
}
|
||||
}
|
||||
|
||||
func rejudgedSince(members []*Delivery, at time.Time) bool {
|
||||
for _, d := range members {
|
||||
if d.Check != nil && d.Check.At.After(at) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -251,21 +251,21 @@ func TestATransitionIsKeptBeforeItIsSaid(t *testing.T) {
|
||||
}
|
||||
w.store.failing = false
|
||||
w.settleAll()
|
||||
if len(w.said) != 1 || len(w.forge.notesOn(head)) != 2 {
|
||||
if len(w.said) != 1 || len(w.forge.notesOn(head)) != 1 {
|
||||
t.Fatalf("once kept, said %d and noted %d", len(w.said), len(w.forge.notesOn(head)))
|
||||
}
|
||||
// A bucket the bus cannot take is written again; a note is appended once however often it is asked.
|
||||
w.owed.down = true
|
||||
w.h.Checked(verdict("novox/app", 7, head, "pass"))
|
||||
w.settleAll()
|
||||
if len(w.forge.notesOn(head)) != 2 {
|
||||
if len(w.forge.notesOn(head)) != 1 {
|
||||
t.Fatal("noted while the bus was away")
|
||||
}
|
||||
w.owed.down = false
|
||||
w.later(time.Hour) // each failure put its next try later: past every one of them
|
||||
w.settleAll()
|
||||
w.settleAll()
|
||||
if notes := w.forge.notesOn(head); len(notes) != 4 {
|
||||
if notes := w.forge.notesOn(head); len(notes) != 3 {
|
||||
t.Fatalf("after the bus came back the note has %d line(s): %q", len(notes), notes)
|
||||
}
|
||||
if d := w.delivery(IDOf("novox/app", head)); len(d.Owed) != 0 {
|
||||
@@ -343,7 +343,7 @@ func TestAMergeWithoutAPassingCheckIsHeld(t *testing.T) {
|
||||
}
|
||||
// Never heard: the forge's statuses on its head decide.
|
||||
m := merged("novox/lab", 3, "eeeeeeeeeeee5555", "ffffffffffff6666")
|
||||
m.HeadChecks = map[string]string{"mesh/merge-gate": "success", "mesh/repo-check": "warning"}
|
||||
m.HeadChecks = map[string]string{"mesh/check": "success"}
|
||||
m.Paths = []string{"README.md"}
|
||||
w.h.PullMerged(m)
|
||||
w.settleAll()
|
||||
@@ -354,68 +354,6 @@ func TestAMergeWithoutAPassingCheckIsHeld(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A delivery group (ADR 0239 decision 4, ADR 0418): two heads sharing a branch name across repositories, checked
|
||||
// together, ready only when both are and their composition passed — and let go for delivery once, when every member is
|
||||
// merged, whatever order they merged in. No member waits for another's delivery.
|
||||
func TestAGroupIsCheckedTogetherAndLetGoOnceEveryMemberIsMerged(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
const ctlHead, catHead = "c1c1c1c1c1c1c1c1", "d2d2d2d2d2d2d2d2"
|
||||
ctl, cat := IDOf("novox/mesh-controller", ctlHead), IDOf("novox/mesh-catalog", catHead)
|
||||
w.h.PullUpdated(pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go"))
|
||||
w.h.PullUpdated(pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json"))
|
||||
// These heads are not full commit ids, so their description lines cannot name them: the reviews are set as read.
|
||||
w.h.deliveries[ctl].ReviewedBy, w.h.deliveries[ctl].ReviewedAt = "tester@shanks", ctlHead
|
||||
w.h.deliveries[cat].ReviewedBy, w.h.deliveries[cat].ReviewedAt = "tester@shanks", catHead
|
||||
if w.delivery(ctl).Group != "feat/x" || w.delivery(cat).Group != "feat/x" {
|
||||
t.Fatalf("one branch name in two repositories made no group: %q %q", w.delivery(ctl).Group, w.delivery(cat).Group)
|
||||
}
|
||||
w.h.Checked(verdict("novox/mesh-controller", 1, ctlHead, "pass"))
|
||||
w.settleAll()
|
||||
if len(composed(w.ctl.checks)) != 0 {
|
||||
t.Fatal("the group was checked composed before every member was ready")
|
||||
}
|
||||
w.h.Checked(verdict("novox/mesh-catalog", 2, catHead, "pass"))
|
||||
w.settleAll()
|
||||
if c := composed(w.ctl.checks); len(c) != 1 || c[0] != "feat/x:"+cat+","+ctl {
|
||||
t.Fatalf("the composed check asked %v, wanted the members by id", w.ctl.checks)
|
||||
}
|
||||
if state, _ := GroupState(w.h.groups["feat/x"], w.h.membersOf(w.h.groups["feat/x"])); state != "checking" {
|
||||
t.Fatalf("before its composed verdict the group is %s", state)
|
||||
}
|
||||
w.h.Checked(CheckedEvent{Group: "feat/x", ID: w.h.groups["feat/x"].Check.Asked, Verdict: "pass", Summary: "both compose"})
|
||||
w.settleAll()
|
||||
if state, _ := GroupState(w.h.groups["feat/x"], w.h.membersOf(w.h.groups["feat/x"])); state != "ready" {
|
||||
t.Fatalf("composed and ready, the group is %s", state)
|
||||
}
|
||||
if !strings.HasPrefix(w.forge.statuses["novox/mesh-catalog@"+catHead+" mesh/delivery-group"], "success") {
|
||||
t.Fatalf("the group's status on a member's head: %v", w.forge.statuses)
|
||||
}
|
||||
|
||||
// The catalogue merges first; its walk waits — the controller's member is not merged yet, so nothing is let go.
|
||||
w.h.PullMerged(merged("novox/mesh-catalog", 2, catHead, "m2m2m2m2m2m2"))
|
||||
w.h.WalkMoved(w.ctl.put(aWalk("plan-cat", "novox/mesh-catalog", "m2m2m2m2m2m2", true)))
|
||||
w.settleAll()
|
||||
if w.state(cat) != Published || len(w.ctl.delivers) != 0 {
|
||||
t.Fatalf("a member was let go before every member merged: %s %v", w.state(cat), w.ctl.delivers)
|
||||
}
|
||||
// The controller merges: its walk is on the controller's own path, started at the merge. Every member is merged,
|
||||
// so the catalogue's walk is let go at once, without waiting for the controller's delivery.
|
||||
w.h.PullMerged(merged("novox/mesh-controller", 1, ctlHead, "m1m1m1m1m1m1"))
|
||||
ctlWalk := w.ctl.put(aWalk("plan-ctl", "novox/mesh-controller", "m1m1m1m1m1m1", false))
|
||||
w.h.WalkMoved(ctlWalk)
|
||||
w.settleAll()
|
||||
if w.state(ctl) != Delivering || len(w.ctl.delivers) != 1 || w.ctl.delivers[0] != "plan-cat" {
|
||||
t.Fatalf("every member merged, the catalogue was not let go once: %s %v", w.state(ctl), w.ctl.delivers)
|
||||
}
|
||||
if w.state(cat) != Delivering {
|
||||
t.Fatalf("the catalogue's member is %s", w.state(cat))
|
||||
}
|
||||
w.settleAll()
|
||||
if len(w.ctl.delivers) != 1 {
|
||||
t.Fatalf("the word was given more than once: %v", w.ctl.delivers)
|
||||
}
|
||||
}
|
||||
|
||||
// A member that fails stops no other (ADR 0418 §5): the plan that fails fails as every plan does, and what was delivered
|
||||
// stays. Nothing is stopped member by member.
|
||||
func TestAFailedMemberStopsNoOtherMember(t *testing.T) {
|
||||
@@ -471,12 +409,6 @@ func TestAnAfterLineIsNotReadAndSaidOnceAndNothingWaitsForAnOrder(t *testing.T)
|
||||
w.settleAll()
|
||||
w.later(time.Minute)
|
||||
w.settleAll()
|
||||
if len(w.ctl.checks) != 1 {
|
||||
t.Fatalf("the composed check was not asked once: %v", w.ctl.checks)
|
||||
}
|
||||
if got := w.ctl.checks[0]; got != "feat/z:"+IDOf("novox/a", "a1a1a1a1a1a1a1")+","+IDOf("novox/b", "b2b2b2b2b2b2b2") {
|
||||
t.Fatalf("the members asked: %s", got)
|
||||
}
|
||||
said := 0
|
||||
for _, c := range w.forge.comments {
|
||||
if strings.Contains(c, "not read") && strings.Contains(c, "novox/a#1") {
|
||||
@@ -502,43 +434,6 @@ func TestAnAfterLineIsNotReadAndSaidOnceAndNothingWaitsForAnOrder(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// A group's members have their mesh/merge-gate said pending, saying why, until a composed verdict stands at their heads
|
||||
// (ADR 0403 §1, §6); the verdict then sets the gate in the forge module.
|
||||
func TestAGroupMembersGateIsPendingUntilItsComposedVerdictStands(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
const ha, hb = "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2"
|
||||
w.h.PullUpdated(pr("novox/a", 1, ha, "feat/z"))
|
||||
w.h.PullUpdated(pr("novox/b", 2, hb, "feat/z"))
|
||||
w.h.Checked(verdict("novox/a", 1, ha, "pass"))
|
||||
w.h.Checked(verdict("novox/b", 2, hb, "pass"))
|
||||
w.settleAll()
|
||||
for _, at := range []string{"novox/a@" + ha, "novox/b@" + hb} {
|
||||
if s := w.forge.statuses[at+" mesh/merge-gate"]; !strings.HasPrefix(s, "pending") || !strings.Contains(s, "composed check") {
|
||||
t.Fatalf("the member's gate on %s is %q, wanted pending with its reason", at, s)
|
||||
}
|
||||
}
|
||||
// Said once while the reason stands.
|
||||
w.later(time.Minute)
|
||||
before := w.forge.statusWrites
|
||||
w.settleAll()
|
||||
if w.forge.statusWrites != before {
|
||||
t.Fatalf("the pending gate was written again with no change")
|
||||
}
|
||||
// A composed verdict stands: mesh-delivery says nothing more of the gate (the forge module sets it).
|
||||
w.h.Checked(CheckedEvent{Group: "feat/z", ID: "check-1", Verdict: "pass", Summary: "compose"})
|
||||
w.settleAll()
|
||||
if g := w.h.groups["feat/z"]; len(g.SaidGate) != 0 {
|
||||
t.Fatalf("a standing verdict left a gate pending: %v", g.SaidGate)
|
||||
}
|
||||
// A member's push puts it back to pending.
|
||||
w.h.PullUpdated(pr("novox/a", 1, "a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9", "feat/z"))
|
||||
w.settleAll()
|
||||
if s := w.forge.statuses["novox/b@"+hb+" mesh/merge-gate"]; !strings.HasPrefix(s, "pending") {
|
||||
t.Fatalf("the sibling's gate after a push is %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
// Stalled: a delivery past its state's bound is listed with what H2 may do; close takes only that.
|
||||
func TestStalledAndCloseWorkFromTheTable(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Issue 1274: a proposed delivery whose asked check the build queue no longer holds is asked again, and a recheck from
|
||||
// proposed is taken when no check of its head is waiting or in flight.
|
||||
|
||||
// 1274/AC1: the queue holds nothing for the ask, the sweep runs after the bound: the check is asked once more and the
|
||||
// transition says the earlier ask was lost.
|
||||
func TestACheckTheQueueLostIsAskedAgainBySweepAfterTheBound(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf("novox/app", head)
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
w.settleAll()
|
||||
first := w.delivery(id).CheckAsk
|
||||
if len(w.ctl.checks) != 1 || first == nil {
|
||||
t.Fatalf("asked %v, ask %+v", w.ctl.checks, first)
|
||||
}
|
||||
w.ctl.queue = nil // the queue lost it
|
||||
w.later(9 * time.Minute)
|
||||
w.settleAll()
|
||||
if len(w.ctl.checks) != 1 {
|
||||
t.Fatalf("asked again inside the bound: %v", w.ctl.checks)
|
||||
}
|
||||
w.later(2 * time.Minute)
|
||||
w.settleAll()
|
||||
d := w.delivery(id)
|
||||
last := d.Transitions[len(d.Transitions)-1]
|
||||
if len(w.ctl.checks) != 2 || d.CheckAsk == nil || d.CheckAsk.ID == first.ID || last.Event != EvLost ||
|
||||
!strings.Contains(last.Why, first.ID) || !strings.Contains(last.Why, "lost") {
|
||||
t.Fatalf("asked %v; ask %+v; last transition %+v", w.ctl.checks, d.CheckAsk, last)
|
||||
}
|
||||
w.later(time.Hour)
|
||||
w.settleAll()
|
||||
if len(w.ctl.checks) != 2 {
|
||||
t.Fatalf("an ask the queue holds was asked again: %v", w.ctl.checks)
|
||||
}
|
||||
}
|
||||
|
||||
// 1274/AC1: a verdict that went out of date, its check against the trunk's head asked and lost, is asked again too (the
|
||||
// case of novox/mesh-controller@28a278c1462a), and the ask's bound runs from the new ask.
|
||||
func TestACheckAgainstTheTrunkTheQueueLostIsAskedAgain(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
id := IDOf(gCtl, gHc)
|
||||
w.h.PullMerged(merged(gCtl, 5, "5555555555555555", gT1))
|
||||
w.settleAll()
|
||||
d := w.delivery(id)
|
||||
if d.State != Proposed || d.AskedAgain == nil {
|
||||
t.Fatalf("it is %s, asked again %v", d.State, d.AskedAgain)
|
||||
}
|
||||
asked := len(w.ctl.checks)
|
||||
w.ctl.queue = nil
|
||||
w.later(11 * time.Minute)
|
||||
w.settleAll()
|
||||
d = w.delivery(id)
|
||||
last := d.Transitions[len(d.Transitions)-1]
|
||||
if len(w.ctl.checks) != asked+1 || last.Event != EvLost || d.AskedAgain == nil || !d.AskedAgain.After(w.now.Add(-time.Minute)) {
|
||||
t.Fatalf("asked %v; last transition %+v; asked again %v", w.ctl.checks[asked:], last, d.AskedAgain)
|
||||
}
|
||||
}
|
||||
|
||||
// 1274/AC2: the queue still holds the ask, waiting or in flight, or cannot be read: nothing is asked again.
|
||||
func TestACheckTheQueueStillHoldsIsNotAskedAgain(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf("novox/app", head)
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
w.settleAll()
|
||||
w.ctl.queue[0].State = "in flight"
|
||||
w.later(3 * time.Hour)
|
||||
w.settleAll()
|
||||
if len(w.ctl.checks) != 1 {
|
||||
t.Fatalf("an ask in flight was asked again: %v", w.ctl.checks)
|
||||
}
|
||||
w.ctl.queue = nil
|
||||
w.ctl.queueDown = true
|
||||
w.later(time.Hour)
|
||||
w.settleAll()
|
||||
if len(w.ctl.checks) != 1 || w.delivery(id).State != Proposed {
|
||||
t.Fatalf("an unreadable queue asked again: %v", w.ctl.checks)
|
||||
}
|
||||
w.ctl.queueDown = false
|
||||
w.later(3 * time.Minute)
|
||||
w.ctl.queue = []QueuedAsk{{ID: "elsewhere", State: "dead", Kind: "check", Repository: "novox/app", Commit: head}}
|
||||
w.settleAll()
|
||||
if len(w.ctl.checks) != 2 {
|
||||
t.Fatalf("a dead ask counted as held: %v", w.ctl.checks)
|
||||
}
|
||||
}
|
||||
|
||||
// 1274/AC3: recheck of a proposed delivery with no check waiting or in flight is taken and asks its check; with one
|
||||
// held it is refused naming it.
|
||||
func TestARecheckFromProposedIsTakenOnlyWhenNoCheckOfItsHeadIsHeld(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf("novox/app", head)
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
w.settleAll()
|
||||
held := w.delivery(id).CheckAsk.ID
|
||||
if _, err := w.h.Recheck(id, "nothing came", "jochen"); err == nil || !strings.Contains(err.Error(), held) {
|
||||
t.Fatalf("a recheck with a check waiting: %v", err)
|
||||
}
|
||||
if _, err := w.h.Recheck(id, "", "jochen"); err == nil {
|
||||
t.Fatal("a recheck with no why was taken")
|
||||
}
|
||||
if len(w.ctl.checks) != 1 {
|
||||
t.Fatalf("a refused recheck asked: %v", w.ctl.checks)
|
||||
}
|
||||
w.ctl.queue = nil
|
||||
said, err := w.h.Recheck(id, "nothing came", "jochen")
|
||||
d := w.delivery(id)
|
||||
last := d.Transitions[len(d.Transitions)-1]
|
||||
if err != nil || len(w.ctl.checks) != 2 || d.State != Proposed || last.Event != EvLost || last.By != "jochen" ||
|
||||
!strings.Contains(last.Why, "nothing came") || d.CheckAsk.ID != "check-2" || !strings.Contains(said, "check-2") {
|
||||
t.Fatalf("%q %v; asked %v; last transition %+v", said, err, w.ctl.checks, last)
|
||||
}
|
||||
// Taken, it is now held: a second recheck is refused.
|
||||
if _, err := w.h.Recheck(id, "again", "jochen"); err == nil || !strings.Contains(err.Error(), "check-2") {
|
||||
t.Fatalf("a second recheck: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1274/AC4: a group member re-asked this way has its group's composed check asked again at the members' heads once its
|
||||
// own check answers.
|
||||
func TestAGroupMemberReaskedAfterALostCheckHasTheComposedCheckAskedAgain(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
id := IDOf(gCat, gHk)
|
||||
if _, err := w.h.Recheck(id, "its verdict is wanted again", "jochen"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.settleAll() // its own check is recorded as asked
|
||||
w.h.mu.Lock()
|
||||
w.h.groups["feat/g"].Check.OutOfDate = "" // as if the composed check stood: only the lost ask's own act marks it
|
||||
w.h.mu.Unlock()
|
||||
w.ctl.queue = nil
|
||||
w.later(11 * time.Minute)
|
||||
w.settleAll()
|
||||
g := w.h.groups["feat/g"]
|
||||
if g.Check.OutOfDate == "" || !strings.Contains(g.Check.OutOfDate, "lost") {
|
||||
t.Fatalf("the composed check stands: %+v", g.Check)
|
||||
}
|
||||
first := g.Check.Asked
|
||||
asked := len(w.ctl.checks)
|
||||
w.later(time.Minute)
|
||||
w.h.Checked(judged(gCat, 2, gHk, "pass", gTrunk0))
|
||||
w.settleAll()
|
||||
last := w.ctl.checks[len(w.ctl.checks)-1]
|
||||
if len(w.ctl.checks) <= asked || !strings.HasPrefix(last, "feat/g:") || w.h.groups["feat/g"].Check.Asked == first {
|
||||
t.Fatalf("the composed check was not asked again: %v", w.ctl.checks[asked:])
|
||||
}
|
||||
}
|
||||
@@ -75,22 +75,6 @@ func TestAnApprovedGroupWithAMemberWhoseRequiredStatusIsNotASuccessMergesNothing
|
||||
}
|
||||
}
|
||||
|
||||
// 1275/AC2 with 1276/AC1: a member whose own repository check is the missing status has that check asked, once.
|
||||
func TestAHeldGroupWhoseMemberLacksItsOwnRepositoryCheckAsksThatCheck(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
setChecks(w, gCtl, gHc, "success", "")
|
||||
asked := len(w.ctl.checks)
|
||||
w.settleAll()
|
||||
w.later(time30s)
|
||||
w.settleAll()
|
||||
own, composed := ownChecks(w, asked)
|
||||
if len(composed) != 0 || len(own) != 1 || own[0] != ":"+IDOf(gCtl, gHc) {
|
||||
t.Fatalf("own checks %v, composed %v; wanted the member's own check once", own, composed)
|
||||
}
|
||||
}
|
||||
|
||||
// 1275/AC3: the merge tool refuses the first member in order; the second is not merged, and the group is said urgent,
|
||||
// naming the group, the refused member and the forge's words.
|
||||
func TestARefusedFirstMemberStopsTheGroupAndIsSaidUrgent(t *testing.T) {
|
||||
@@ -141,42 +125,6 @@ func TestAHalfMergedGroupStaysRaisedUntilEveryMemberIsMerged(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 1275/AC1: a group with a member whose required status is not a success is not asked its Approve; the group says which
|
||||
// member and status; it is asked once every member may merge.
|
||||
func TestAGroupIsNotAskedWhileAMembersRequiredStatusIsNotASuccess(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
w.h.PullUpdated(pr(gCtl, 1, gHc, "feat/g", "internal/a.go"))
|
||||
w.h.PullUpdated(pr(gCat, 2, gHk, "feat/g", "modules/app/module.json"))
|
||||
w.h.Checked(judged(gCtl, 1, gHc, "pass", gTrunk0))
|
||||
w.h.Checked(judged(gCat, 2, gHk, "pass", gTrunk0))
|
||||
w.settleAll()
|
||||
g := w.h.groups["feat/g"]
|
||||
setChecks(w, gCat, gHk, "success", "success") // the composed verdict set every member's gate
|
||||
setChecks(w, gCtl, gHc, "success", "pending")
|
||||
w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed",
|
||||
Members: []struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
Commit string `json:"commit"`
|
||||
Trunk string `json:"trunk,omitempty"`
|
||||
}{{Owner: "novox", Repo: "mesh-controller", Commit: gHc, Trunk: gTrunk0[:12]},
|
||||
{Owner: "novox", Repo: "mesh-catalog", Commit: gHk, Trunk: gTrunk0[:12]}}})
|
||||
w.settleAll()
|
||||
if w.op.count() != 0 {
|
||||
t.Fatalf("the operator was asked with a required status pending: %d asks", w.op.count())
|
||||
}
|
||||
if g.Hold == nil || g.Hold.Kind != "unasked" || g.Hold.Member != IDOf(gCtl, gHc) || !strings.Contains(g.Hold.Says, "mesh/repo-check (pending)") {
|
||||
t.Fatalf("the group does not say which member and status it waits for: %+v", g.Hold)
|
||||
}
|
||||
setChecks(w, gCtl, gHc, "success", "success")
|
||||
w.later(time30s)
|
||||
w.settleAll()
|
||||
if w.op.count() != 1 || g.Hold != nil {
|
||||
t.Fatalf("asks %d, hold %+v; wanted the group asked once its statuses are green", w.op.count(), g.Hold)
|
||||
}
|
||||
}
|
||||
|
||||
// 1275/AC5: a member's required status turning not-success while the ask waits withdraws the ask at the router; the
|
||||
// answer to the withdrawn ask merges nothing; the group is asked again once every member may merge.
|
||||
func TestAStatusThatTurnsWhileTheAskWaitsWithdrawsItAndItsAnswerMergesNothing(t *testing.T) {
|
||||
@@ -205,23 +153,6 @@ func TestAStatusThatTurnsWhileTheAskWaitsWithdrawsItAndItsAnswerMergesNothing(t
|
||||
}
|
||||
}
|
||||
|
||||
// 1276/AC1: given a closed group whose member's merge was refused with "not a success: mesh/repo-check (missing)", then
|
||||
// the member's own check is asked, not the composed check.
|
||||
func TestAClosedGroupsMemberRefusedForItsOwnMissingStatusAsksItsOwnCheck(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.forge.refuseRepo = map[string]error{gCtl: refusedWords("novox/mesh-controller#1")}
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
w.h.PullMerged(merged(gCat, 2, gHk, gT1))
|
||||
asked := len(w.ctl.checks)
|
||||
w.settleAll()
|
||||
own, composed := ownChecks(w, asked)
|
||||
if len(composed) != 0 || len(own) != 1 || own[0] != ":"+IDOf(gCtl, gHc) {
|
||||
t.Fatalf("own checks %v, composed checks %v; wanted the member's own check once and no composed check", own, composed)
|
||||
}
|
||||
}
|
||||
|
||||
// 1275/AC3 (review round 1): a member that is both refused and held for a required status is said by one stalled line,
|
||||
// not two with the same id.
|
||||
func TestAMemberBothRefusedAndHeldForAStatusIsOneStalledLine(t *testing.T) {
|
||||
|
||||
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
@@ -96,8 +95,9 @@ func TestTheGatesCodeCallsNoForgeWriteToolAndNoDeliveryPlan(t *testing.T) {
|
||||
}
|
||||
forbidden := []string{"delivery-plan", "gitea_commit_status\"", "gitea_delivery_view", "gitea_note_append", "gitea_add_comment",
|
||||
"gitea_merge_pull_request\"", "gitea_open_pulls_write"}
|
||||
// The controller's verbs the gate calls: ADR 0450 §7.
|
||||
allowed := []string{"deliver", "delivery-stop", "delivery-backups-again", "delivery-walks", "delivery-check", "forget-checks", "plans", "queue"}
|
||||
// The controller's verbs the gate calls: ADR 0450 §7, less the checks and the queue, which the controller no longer
|
||||
// keeps (issue 1318, ADR 0524 §5).
|
||||
allowed := []string{"deliver", "delivery-stop", "delivery-backups-again", "delivery-walks", "plans"}
|
||||
called := map[string]bool{}
|
||||
fset := token.NewFileSet()
|
||||
for _, f := range files {
|
||||
@@ -132,7 +132,7 @@ func TestTheGatesCodeCallsNoForgeWriteToolAndNoDeliveryPlan(t *testing.T) {
|
||||
return true
|
||||
})
|
||||
}
|
||||
for _, verb := range []string{"plans", "delivery-check"} {
|
||||
for _, verb := range []string{"plans"} {
|
||||
if !called[verb] {
|
||||
t.Errorf("the gate calls no %s: the test reads nothing", verb)
|
||||
}
|
||||
@@ -194,38 +194,3 @@ func TestADeliveryWithNoPlanGetsItFromPlansAsData(t *testing.T) {
|
||||
t.Fatalf("the delivery's plan is %+v", d.Plan)
|
||||
}
|
||||
}
|
||||
|
||||
// AC4: a delivery-check answer that holds no id is an error said aloud, naming the delivery, and not taken as asked.
|
||||
func TestAnEmptyDeliveryCheckAnswerIsAnErrorNamingTheDelivery(t *testing.T) {
|
||||
stub := &stubController{answer: `{"ok":true,"output":"","answer":{}}`}
|
||||
if id, err := (seatController{ask: stub.ask}).Check("", []Member{{ID: "novox/app@x"}}); err == nil || id != "" ||
|
||||
!strings.Contains(err.Error(), "no check id") {
|
||||
t.Fatalf("an empty check answer gave %q and %v", id, err)
|
||||
}
|
||||
w := newWorld(t)
|
||||
var said []string
|
||||
var mu sync.Mutex
|
||||
w.h.Logf = func(format string, a ...any) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
said = append(said, strings.TrimSpace(fmt.Sprintf(format, a...)))
|
||||
}
|
||||
w.h.Controller = seatController{ask: stub.ask}
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
w.settleAll()
|
||||
id := IDOf("novox/app", head)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
found := false
|
||||
for _, line := range said {
|
||||
if strings.Contains(line, id) && strings.Contains(line, "no check id") && !strings.Contains(line, "taken as refused") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("no error naming %s and the empty answer was logged: %q", id, said)
|
||||
}
|
||||
if d := w.delivery(id); d.CheckAsk != nil {
|
||||
t.Fatalf("an empty answer was kept as an ask: %+v", d.CheckAsk)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,42 +4,6 @@ import "testing"
|
||||
|
||||
const gHk2 = "f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4"
|
||||
|
||||
// Issue 1792: a sibling's new head put a member's own mesh/repo-check back to pending ("out of date: judged beside ..."),
|
||||
// and nothing asked it again. The composed check passed, the group stayed held ("unasked") on the member's pending
|
||||
// status, and the operator's Approve was never asked. The group's hold on a member's own pending repository check asks
|
||||
// that check, once for the head, with no composed check (ADR 0382 §2: each member's own check stays required).
|
||||
func TestAGroupHeldBeforeItsAskOnAMembersOwnPendingRepoCheckAsksThatCheckOnce(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
w.h.PullUpdated(pr(gCtl, 1, gHc, "feat/g", "internal/a.go"))
|
||||
w.h.PullUpdated(pr(gCat, 2, gHk, "feat/g", "modules/app/module.json"))
|
||||
w.h.Checked(judged(gCtl, 1, gHc, "pass", gTrunk0))
|
||||
w.h.Checked(judged(gCat, 2, gHk, "pass", gTrunk0))
|
||||
w.settleAll()
|
||||
g := w.h.groups["feat/g"]
|
||||
setChecks(w, gCat, gHk, "success", "success")
|
||||
setChecks(w, gCtl, gHc, "success", "pending")
|
||||
asked := len(w.ctl.checks)
|
||||
w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed",
|
||||
Members: []struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
Commit string `json:"commit"`
|
||||
Trunk string `json:"trunk,omitempty"`
|
||||
}{{Owner: "novox", Repo: "mesh-controller", Commit: gHc, Trunk: gTrunk0[:12]},
|
||||
{Owner: "novox", Repo: "mesh-catalog", Commit: gHk, Trunk: gTrunk0[:12]}}})
|
||||
w.settleAll()
|
||||
w.later(time30s)
|
||||
w.settleAll()
|
||||
own, composed := ownChecks(w, asked)
|
||||
if len(composed) != 0 || len(own) != 1 || own[0] != ":"+IDOf(gCtl, gHc) {
|
||||
t.Fatalf("own checks %v, composed %v; wanted the member's own check asked once", own, composed)
|
||||
}
|
||||
if w.op.count() != 0 {
|
||||
t.Fatalf("the operator was asked with a required status pending: %d", w.op.count())
|
||||
}
|
||||
}
|
||||
|
||||
func composedPassAt(w *world, hc, hk string) {
|
||||
g := w.h.groups["feat/g"]
|
||||
w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed",
|
||||
@@ -73,48 +37,3 @@ func heldOnOwnPending(t *testing.T) *world {
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// A second sibling push puts the member's repo-check out of date again after its own check answered: asked again.
|
||||
func TestAMembersOwnCheckIsAskedAgainWhenASecondSiblingHeadPutsItOutOfDate(t *testing.T) {
|
||||
w := heldOnOwnPending(t)
|
||||
w.h.Checked(judged(gCtl, 1, gHc, "pass", gTrunk0)) // its own check answered
|
||||
setChecks(w, gCtl, gHc, "success", "success")
|
||||
w.settleAll()
|
||||
w.h.PullUpdated(pr(gCat, 2, gHk2, "feat/g", "modules/app/module.json"))
|
||||
w.h.Checked(judged(gCat, 2, gHk2, "pass", gTrunk0))
|
||||
w.settleAll()
|
||||
setChecks(w, gCat, gHk2, "success", "success")
|
||||
setChecks(w, gCtl, gHc, "success", "pending")
|
||||
asked := len(w.ctl.checks)
|
||||
composedPassAt(w, gHc, gHk2)
|
||||
w.settleAll()
|
||||
w.later(time30s)
|
||||
w.settleAll()
|
||||
own, composed := ownChecks(w, asked)
|
||||
if len(composed) != 0 || len(own) != 1 || own[0] != ":"+IDOf(gCtl, gHc) {
|
||||
t.Fatalf("own %v composed %v; wanted the member's own check asked again, once", own, composed)
|
||||
}
|
||||
}
|
||||
|
||||
// An own ask before the Approve, answered, and then a merge refused for the member's own pending status at the same head:
|
||||
// asked again (the one ask is per answer, not per head's life).
|
||||
func TestAMergeRefusedForOwnPendingAfterAnAnsweredOwnAskAsksItAgain(t *testing.T) {
|
||||
w := heldOnOwnPending(t)
|
||||
w.h.Checked(judged(gCtl, 1, gHc, "pass", gTrunk0))
|
||||
setChecks(w, gCtl, gHc, "success", "success")
|
||||
w.later(time30s)
|
||||
w.settleAll()
|
||||
if len(w.op.open()) != 1 {
|
||||
t.Fatalf("the group is not asked once its statuses are green: %d", w.op.count())
|
||||
}
|
||||
w.forge.refuseRepo = map[string]error{gCtl: ownPending}
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
w.h.PullMerged(merged(gCat, 2, gHk, gT1))
|
||||
asked := len(w.ctl.checks)
|
||||
w.settleAll()
|
||||
own, composed := ownChecks(w, asked)
|
||||
if len(composed) != 0 || len(own) != 1 || own[0] != ":"+IDOf(gCtl, gHc) {
|
||||
t.Fatalf("own %v composed %v; wanted the member's own check asked again after the refusal", own, composed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// Issue 1826 (novox/hq ADR 0502 clause 5): when mesh-config holds or drops a value for a pull request that is a
|
||||
// delivery, its check, or its group's composed check, is asked again, and the transition says why.
|
||||
|
||||
func configChanged(action, pull string) ConfigChange {
|
||||
c := ConfigChange{Action: action, Pull: pull, Head: head, Key: "media-gid"}
|
||||
c.Level.Node, c.Level.Module = "ace", "n8n"
|
||||
return c
|
||||
}
|
||||
|
||||
// 1826/K4: a lone delivery that was ready is proposed again with its merge check pending, its check asked, and the
|
||||
// transition says that a config value was held.
|
||||
func TestAHeldValueAsksTheReadyDeliverysCheckAgain(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := heldGreen(t, w, "novox/app", 7)
|
||||
asked := len(w.ctl.checks)
|
||||
w.h.ConfigChanged(configChanged("hold", "novox/app#7"))
|
||||
if got := w.ctl.checks[asked:]; len(got) != 1 || got[0] != ":"+id {
|
||||
t.Fatalf("asked %v; wanted its own check once", got)
|
||||
}
|
||||
d := w.delivery(id)
|
||||
last := d.Transitions[len(d.Transitions)-1]
|
||||
if d.State != Proposed || last.Event != EvRecheck || !strings.Contains(last.Why, "media-gid") ||
|
||||
!strings.Contains(last.Why, "held") || last.By != byOwner {
|
||||
t.Errorf("state %s, last transition %+v", d.State, last)
|
||||
}
|
||||
if s := w.forge.statuses["novox/app@"+head+" "+ctxGate]; !strings.HasPrefix(s, "pending checking again") {
|
||||
t.Errorf("the merge check says %q: the old verdict would still let it merge", s)
|
||||
}
|
||||
}
|
||||
|
||||
// 1826/K4: a rejected delivery (the very case: the composed check failed on the missing value) is asked again on a
|
||||
// hold; and a drop asks it again too.
|
||||
func TestAHeldValueAsksARejectedDeliverysCheckAgainAndADropToo(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 8, head, "feat/y", "src/a.go"))
|
||||
w.h.Checked(verdict("novox/app", 8, head, "fail"))
|
||||
w.settleAll()
|
||||
id := IDOf("novox/app", head)
|
||||
if w.state(id) != Rejected {
|
||||
t.Fatalf("it is %s", w.state(id))
|
||||
}
|
||||
asked := len(w.ctl.checks)
|
||||
w.h.ConfigChanged(configChanged("hold", "novox/app#8"))
|
||||
if w.state(id) != Proposed || len(w.ctl.checks) != asked+1 {
|
||||
t.Fatalf("a hold left it %s with %v asked", w.state(id), w.ctl.checks[asked:])
|
||||
}
|
||||
w.h.Checked(verdict("novox/app", 8, head, "pass"))
|
||||
w.settleAll()
|
||||
asked = len(w.ctl.checks)
|
||||
w.h.ConfigChanged(configChanged("drop", "novox/app#8"))
|
||||
d := w.delivery(id)
|
||||
if len(w.ctl.checks) != asked+1 || !strings.Contains(d.Transitions[len(d.Transitions)-1].Why, "dropped") {
|
||||
t.Errorf("a drop asked %v; last transition %+v", w.ctl.checks[asked:], d.Transitions[len(d.Transitions)-1])
|
||||
}
|
||||
}
|
||||
|
||||
// 1826/K4: a delivery whose check is on its way is asked again, its old ask replaced, and says why in its note.
|
||||
func TestAHeldValueAsksTheCheckOfAProposedDeliveryAgain(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 9, head, "feat/z", "src/a.go"))
|
||||
w.settleAll()
|
||||
id := IDOf("novox/app", head)
|
||||
before := w.delivery(id).CheckAsk
|
||||
asked := len(w.ctl.checks)
|
||||
w.h.ConfigChanged(configChanged("hold", "novox/app#9"))
|
||||
d := w.delivery(id)
|
||||
if len(w.ctl.checks) != asked+1 || d.CheckAsk == nil || (before != nil && d.CheckAsk.ID == before.ID) ||
|
||||
!strings.Contains(d.CheckAsk.Why, "held") {
|
||||
t.Errorf("asked %v; ask %+v (was %+v)", w.ctl.checks[asked:], d.CheckAsk, before)
|
||||
}
|
||||
if d.State != Proposed {
|
||||
t.Errorf("it is %s", d.State)
|
||||
}
|
||||
}
|
||||
|
||||
// 1826/K4: a hold for a member of a group asks the member's check again and then the group's composed check, which
|
||||
// the group waits for.
|
||||
func TestAHeldValueForAGroupMemberAsksTheComposedCheckAgain(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
g := w.h.groups["feat/g"]
|
||||
firstComposed := g.Check.Asked
|
||||
asked := len(w.ctl.checks)
|
||||
w.h.ConfigChanged(configChanged("hold", gCat+"#2"))
|
||||
if got := w.ctl.checks[asked:]; len(got) != 1 || got[0] != ":"+IDOf(gCat, gHk) {
|
||||
t.Fatalf("a hold asked %v; wanted the member's own check", got)
|
||||
}
|
||||
if g = w.h.groups["feat/g"]; g.Check.OutOfDate == "" || !strings.Contains(g.Check.OutOfDate, "rechecked") {
|
||||
t.Fatalf("the composed check stands: %+v", g.Check)
|
||||
}
|
||||
w.later(60e9)
|
||||
w.h.Checked(judged(gCat, 2, gHk, "pass", gTrunk0))
|
||||
w.settleAll()
|
||||
last := w.ctl.checks[len(w.ctl.checks)-1]
|
||||
if !strings.HasPrefix(last, "feat/g:") || w.h.groups["feat/g"].Check.Asked == firstComposed {
|
||||
t.Errorf("the composed check was not asked again: %v", w.ctl.checks[asked:])
|
||||
}
|
||||
}
|
||||
|
||||
// 1826/K4: what is not a hold or a drop for a delivery of this owner asks nothing: the merge's set, another action,
|
||||
// another pull request, a pull request with no delivery, and a delivery already merged.
|
||||
func TestOnlyAHoldOrADropForADeliveryAsksACheckAgain(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
heldGreen(t, w, "novox/app", 7)
|
||||
asked := len(w.ctl.checks)
|
||||
for _, c := range []ConfigChange{configChanged("set", "novox/app#7"), configChanged("resolve", ""),
|
||||
configChanged("hold", "novox/app#99"), configChanged("hold", "novox/other#7"), configChanged("hold", ""),
|
||||
configChanged("hold", "garbage")} {
|
||||
w.h.ConfigChanged(c)
|
||||
}
|
||||
if len(w.ctl.checks) != asked || w.state(IDOf("novox/app", head)) != Ready {
|
||||
t.Errorf("asked %v; it is %s", w.ctl.checks[asked:], w.state(IDOf("novox/app", head)))
|
||||
}
|
||||
}
|
||||
|
||||
// 1826/K4: the event is heard as mesh-config's config-changed, and it is no recheck verb.
|
||||
func TestTheConfigChangedEventIsFollowed(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
heldGreen(t, w, "novox/app", 7)
|
||||
found := false
|
||||
for _, f := range followed {
|
||||
found = found || f == "mesh-config.config-changed"
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("not followed: %v", followed)
|
||||
}
|
||||
raw, _ := json.Marshal(map[string]any{"action": "hold", "pull": "novox/app#7", "head": head, "key": "media-gid",
|
||||
"level": map[string]any{"level": "module", "node": "ace", "module": "n8n"}})
|
||||
asked := len(w.ctl.checks)
|
||||
if err := handle(w.h, stdio.Envelope{Key: "mesh-config.config-changed", Body: raw}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(w.ctl.checks) != asked+1 {
|
||||
t.Errorf("the event asked %v", w.ctl.checks[asked:])
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,7 @@ func TestATrunkMoveSendsAReadyDeliveryBackToProposed(t *testing.T) {
|
||||
w.h.Checked(late)
|
||||
w.settleAll()
|
||||
d = w.delivery(id66)
|
||||
if d.State != Proposed || len(d.Transitions) != before || d.Check == nil || d.Check.ID != "check-late" {
|
||||
if d.State != Proposed || len(d.Transitions) != before || d.Check == nil {
|
||||
t.Fatalf("a late verdict for an old trunk left it %s after %d transition(s), verdict %+v", d.State,
|
||||
len(d.Transitions)-before, d.Check)
|
||||
}
|
||||
@@ -163,156 +163,6 @@ func TestChecksAndShowNameTheHeadAndTheTrunkCommit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// **A delivery group's composed check follows the same rule** (ADR 0323 §6, §7): a merge that is not one of its
|
||||
// members into a member's trunk puts it out of date and the group reads checking; its own members merging in its
|
||||
// order do not; and it runs again once a member is judged again.
|
||||
func TestAGroupsComposedCheckGoesOutOfDateOnAMergeThatIsNotItsMember(t *testing.T) {
|
||||
const ctl, cat = "novox/mesh-controller", "novox/mesh-catalog"
|
||||
const hc, hk = "c1c1c1c1c1c1c1c1", "k1k1k1k1k1k1k1k1"
|
||||
idc, idk := IDOf(ctl, hc), IDOf(cat, hk)
|
||||
start := func(t *testing.T) *world {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr(ctl, 1, hc, "feat/g"))
|
||||
w.h.PullUpdated(pr(cat, 2, hk, "feat/g"))
|
||||
w.h.Checked(judged(ctl, 1, hc, "pass", trunk0))
|
||||
w.h.Checked(judged(cat, 2, hk, "pass", trunk0))
|
||||
w.settleAll()
|
||||
g := w.h.groups["feat/g"]
|
||||
if g == nil || g.Check == nil {
|
||||
t.Fatalf("no composed check was asked: %+v", g)
|
||||
}
|
||||
w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed",
|
||||
Members: []struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
Commit string `json:"commit"`
|
||||
Trunk string `json:"trunk,omitempty"`
|
||||
}{{Owner: "novox", Repo: "mesh-controller", Commit: hc, Trunk: trunk0[:12]},
|
||||
{Owner: "novox", Repo: "mesh-catalog", Commit: hk, Trunk: trunk0[:12]}}})
|
||||
w.settleAll()
|
||||
if state, _ := GroupState(g, w.h.membersOf(g)); state != "ready" {
|
||||
t.Fatalf("the group composed green is %s", state)
|
||||
}
|
||||
if g.Check.Trunks[idc] != trunk0[:12] {
|
||||
t.Fatalf("the composed check names its members' trunks %v", g.Check.Trunks)
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
t.Run("a merge that is not a member", func(t *testing.T) {
|
||||
w := start(t)
|
||||
asked := len(w.ctl.checks)
|
||||
w.h.PullMerged(merged(ctl, 5, "5555555555555555", merge1))
|
||||
w.settleAll()
|
||||
g := w.h.groups["feat/g"]
|
||||
state, why := GroupState(g, w.h.membersOf(g))
|
||||
if state != "checking" || g.Check.OutOfDate == "" {
|
||||
t.Fatalf("the group is %s (%s) after a merge that is not its member moved a member's trunk", state, why)
|
||||
}
|
||||
for _, head := range []string{ctl + "@" + hc, cat + "@" + hk} {
|
||||
s := w.forge.statuses[head+" "+ctxGroup]
|
||||
if !strings.HasPrefix(s, "pending ") || !strings.Contains(s, "out of date") {
|
||||
t.Fatalf("mesh/delivery-group on %s says %q, not pending with the reason", head, s)
|
||||
}
|
||||
}
|
||||
// The group's Approve stays; each member whose verdict went out of date has its check asked at once, against the
|
||||
// trunk's head (ADR 0421 §3, issue 1047): only the member whose trunk moved, and the composed check is asked
|
||||
// after it.
|
||||
if got := w.ctl.checks[asked:]; len(got) != 1 || got[0] != ":"+idc {
|
||||
t.Fatalf("the trunk's move asked %v; wanted the moved member's own check once", got)
|
||||
}
|
||||
asked++
|
||||
// The controller's member judged again against the trunk's head: the composed check runs again.
|
||||
w.later(time.Minute)
|
||||
w.h.Checked(judged(ctl, 1, hc, "pass", merge1))
|
||||
w.settleAll()
|
||||
if len(w.ctl.checks) == asked || !strings.HasPrefix(w.ctl.checks[len(w.ctl.checks)-1], "feat/g:") {
|
||||
t.Fatalf("the composed check was not asked again once a member was judged again: %v", w.ctl.checks)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("its own member merging", func(t *testing.T) {
|
||||
w := start(t)
|
||||
mc := merged(ctl, 1, hc, merge1)
|
||||
mc.Head = "feat/g"
|
||||
w.h.PullMerged(mc)
|
||||
w.settleAll()
|
||||
g := w.h.groups["feat/g"]
|
||||
if g.Check.OutOfDate != "" {
|
||||
t.Fatalf("the group's own member merging put its composed check out of date: %s", g.Check.OutOfDate)
|
||||
}
|
||||
if s := w.state(idk); s != Ready {
|
||||
t.Fatalf("the catalogue's member, in another repository, is %s", s)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// **After a member is rebased or pushed, the composed check runs again for the new heads** and the group reads
|
||||
// checking, not held (ADR 0323 §6, issue 406's second symptom): its first member merged, closing the group, and then
|
||||
// its second was rebased just before its own merge.
|
||||
func TestARebasedMemberIsComposedAgainNotHeld(t *testing.T) {
|
||||
const ctl, cat = "novox/mesh-controller", "novox/mesh-catalog"
|
||||
const hc, hk, hc2 = "c1c1c1c1c1c1c1c1", "k1k1k1k1k1k1k1k1", "c2c2c2c2c2c2c2c2"
|
||||
idc, idk, idc2 := IDOf(ctl, hc), IDOf(cat, hk), IDOf(ctl, hc2)
|
||||
w := newWorld(t)
|
||||
w.op.auto = false // the merges are heard as events below, as when the forge refused one
|
||||
w.h.PullUpdated(pr(ctl, 1, hc, "feat/g"))
|
||||
w.h.PullUpdated(pr(cat, 2, hk, "feat/g"))
|
||||
w.h.Checked(judged(ctl, 1, hc, "pass", trunk0))
|
||||
w.h.Checked(judged(cat, 2, hk, "pass", trunk0))
|
||||
w.settleAll()
|
||||
g := w.h.groups["feat/g"]
|
||||
w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed"})
|
||||
w.settleAll()
|
||||
|
||||
// The catalogue's member merges first: the group is closed.
|
||||
mk := merged(cat, 2, hk, merge1)
|
||||
mk.Head = "feat/g"
|
||||
w.h.PullMerged(mk)
|
||||
w.h.WalkMoved(w.ctl.put(aWalk("plan-cat", cat, merge1, true)))
|
||||
w.settleAll()
|
||||
if !g.Closed {
|
||||
t.Fatal("the group is not closed once a member merged")
|
||||
}
|
||||
|
||||
// The controller's member is rebased just before its merge: a new head, with its own check.
|
||||
w.h.PullUpdated(pr(ctl, 1, hc2, "feat/g"))
|
||||
w.settleAll()
|
||||
if d := w.delivery(idc2); d.Group != "feat/g" {
|
||||
t.Fatalf("the rebased head did not take its place in its closed group: %q", d.Group)
|
||||
}
|
||||
if containsString(g.Members, idc) || !containsString(g.Members, idc2) || len(g.Members) != 2 {
|
||||
t.Fatalf("the group's members are %v", g.Members)
|
||||
}
|
||||
if state, why := GroupState(g, w.h.membersOf(g)); state != "checking" {
|
||||
t.Fatalf("the group reads %s (%s), not checking", state, why)
|
||||
}
|
||||
w.h.Checked(judged(ctl, 1, hc2, "pass", trunk0))
|
||||
w.settleAll()
|
||||
asked := w.ctl.checks[len(w.ctl.checks)-1]
|
||||
if !strings.HasPrefix(asked, "feat/g:") || !strings.Contains(asked, idc2) || !strings.Contains(asked, idk) {
|
||||
t.Fatalf("the composed check was not asked again for the new heads: %v", w.ctl.checks)
|
||||
}
|
||||
if state, why := GroupState(g, w.h.membersOf(g)); state != "checking" {
|
||||
t.Fatalf("the group reads %s (%s), not checking", state, why)
|
||||
}
|
||||
for _, id := range []string{idk, idc2} {
|
||||
if d := w.delivery(id); d.State == Held {
|
||||
t.Fatalf("%s is held for a person: %s", id, d.HeldWhy)
|
||||
}
|
||||
}
|
||||
// Its verdict for the new heads: the rebased member merges and takes its turn.
|
||||
w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed again"})
|
||||
mc := merged(ctl, 1, hc2, "e0e0e0e0e0e0e0e0")
|
||||
mc.Head = "feat/g"
|
||||
w.h.PullMerged(mc)
|
||||
w.h.WalkMoved(w.ctl.put(aWalk("plan-ctl", ctl, "e0e0e0e0e0e0e0e0", true)))
|
||||
w.settleAll()
|
||||
if d := w.delivery(idc2); d.State == Held {
|
||||
t.Fatalf("the rebased member, composed green for the new heads, is held: %s", d.HeldWhy)
|
||||
}
|
||||
}
|
||||
|
||||
// A head whose merge check the forge's holder put out of date is not a check asked and not answered: `stalled` does
|
||||
// not list it, however long it waits — rejected, or with no delivery here — until its check is asked (ADR 0323 §7).
|
||||
func TestAnOutOfDateCheckOnTheForgeIsNotSaidUnanswered(t *testing.T) {
|
||||
|
||||
@@ -199,26 +199,10 @@ func unansweredOf(w *world) []StalledLine {
|
||||
return out
|
||||
}
|
||||
|
||||
// **A said line clears when its delivery's state changes** (novox/hq issue 441): a recheck proposes it again, under
|
||||
// the proposed state's own bound, and a merge publishes it, past the pull request. Both clear the line at once —
|
||||
// **A said line clears when its delivery's state changes** (novox/hq issue 441): a merge publishes it, past the pull
|
||||
// request. It clears the line at once —
|
||||
// before the forge is read again, which may lag — and stay cleared once it is read.
|
||||
func TestASaidLineClearsWhenItsDeliveryMovesOn(t *testing.T) {
|
||||
t.Run("a recheck proposes it", func(t *testing.T) {
|
||||
w, id := saidReady(t)
|
||||
if _, err := w.h.Recheck(id, "its repo-check status was lost", "a person"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s := w.state(id); s != Proposed {
|
||||
t.Fatalf("a recheck left it %s", s)
|
||||
}
|
||||
if lines := unansweredOf(w); len(lines) != 0 {
|
||||
t.Fatalf("a rechecked delivery, proposed again, is still said as unanswered: %+v", lines)
|
||||
}
|
||||
w.h.SeeOpenPulls()
|
||||
if lines := unansweredOf(w); len(lines) != 0 {
|
||||
t.Fatalf("a rechecked delivery is said again once the forge is read: %+v", lines)
|
||||
}
|
||||
})
|
||||
t.Run("a merge publishes it", func(t *testing.T) {
|
||||
w, id := saidReady(t)
|
||||
w.h.WalkMoved(w.ctl.put(aWalk("plan-1", "novox/mesh-controller", merge, true)))
|
||||
|
||||
@@ -84,7 +84,7 @@ func TestAMergeWithoutAnAnnouncementStartsOverAStoppedDuplicateToo(t *testing.T)
|
||||
walk := w.ctl.put(aWalk("plan-783b", catalog783, merge783b, true))
|
||||
w.h.WalkMoved(walk)
|
||||
m := merged(catalog783, 316, head783b, merge783b)
|
||||
m.HeadChecks = map[string]string{"mesh/merge-gate": "success", "mesh/repo-check": "success"}
|
||||
m.HeadChecks = map[string]string{"mesh/check": "success"}
|
||||
w.h.PullMerged(m)
|
||||
if d := w.delivery(id); d.State == Stopped || d.Number != 316 {
|
||||
t.Fatalf("#316's merge, heard with no announcement of its own, left it %s, Number %d; want started over for #316",
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A check the build queue lost (novox/hq issue 1274).
|
||||
//
|
||||
// A proposed delivery waits for a check it asked of the controller. When the build queue loses that ask — taken by a
|
||||
// build agent that was replaced, or dropped with the queue's own state — nothing answers and nothing asks again: the
|
||||
// delivery stays proposed until a new head is pushed, and a recheck from proposed was refused. The sweep now reads the
|
||||
// controller's `queue` and asks again a check that no entry holds (neither waiting nor in flight; a dead one is not
|
||||
// held) for longer than lostCheckBound. A recheck of a proposed delivery is taken on the same fact. Both are the
|
||||
// table's EvLost, said in the delivery's transitions; neither asks while the queue holds an ask of the head, so no
|
||||
// ask is made twice. A queue that cannot be read asks nothing: a guess would risk a duplicate.
|
||||
|
||||
// lostReadEvery is how often the queue is read for one proposed delivery's ask: the read is one bus call, and a check
|
||||
// the queue holds is looked at again no sooner.
|
||||
const lostReadEvery = 2 * time.Minute
|
||||
|
||||
// lostCheckBound is how long a proposed delivery's asked check may go with no entry in the build queue before it is
|
||||
// asked again: the repository's last measured check time (ADR 0505 §4, lastCheckTime), at least UncheckedAfter (ten
|
||||
// minutes, the bound a check never asked is said at). Under the lock.
|
||||
func (h *Holder) lostCheckBound(repository string) time.Duration {
|
||||
return max(UncheckedAfter, h.lastCheckTime(repository))
|
||||
}
|
||||
|
||||
// awaitedSince is when a proposed delivery's check was asked, when it waits for one this owner asked: no verdict yet,
|
||||
// or a verdict out of date with its check against the trunk's head asked. False for a delivery waiting for none.
|
||||
func awaitedSince(d *Delivery) (time.Time, bool) {
|
||||
if d.State != Proposed || d.MergedAs != "" || d.Number == 0 || d.judgedByCheck() {
|
||||
return time.Time{}, false // a head on mesh/check asks the controller nothing (issue 1302)
|
||||
}
|
||||
switch {
|
||||
case d.Check == nil && askedCheck(d):
|
||||
return d.CheckAsk.At, true
|
||||
case d.Check != nil && d.outOfDate() != "" && d.AskedAgain != nil:
|
||||
return *d.AskedAgain, true
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// heldBy is the queue's entry that holds a check of the delivery's head: its own ask by id, or any check of its
|
||||
// repository at its head — a group's composed check included, which a second ask would only repeat.
|
||||
func heldBy(d *Delivery, asks []QueuedAsk) *QueuedAsk {
|
||||
for i, a := range asks {
|
||||
if !a.Holds() {
|
||||
continue
|
||||
}
|
||||
if d.CheckAsk != nil && d.CheckAsk.ID != "" && a.ID == d.CheckAsk.ID {
|
||||
return &asks[i]
|
||||
}
|
||||
if a.Kind == "check" && a.Commit != "" && sameCommit(a.Commit, d.Commit) &&
|
||||
strings.Contains(strings.ToLower(a.Repository), strings.ToLower(d.Repository)) {
|
||||
return &asks[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a QueuedAsk) words() string {
|
||||
// The controller's `queue` says the state "in flight", its table's word.
|
||||
state := strings.ReplaceAll(a.State, "-", " ")
|
||||
if state == "in flight" && a.Machine != "" {
|
||||
return a.ID + " is in flight on " + a.Machine
|
||||
}
|
||||
return a.ID + " is " + state + " in the build queue"
|
||||
}
|
||||
|
||||
// lostChecks are the asks again of proposed deliveries whose asked check the build queue holds no entry of, past the
|
||||
// bound, at each look. Under the lock; the queue is read, and the asks made, outside it.
|
||||
func (h *Holder) lostChecks(now time.Time) func() {
|
||||
var ids []string
|
||||
for id, d := range h.deliveries {
|
||||
since, ok := awaitedSince(d)
|
||||
if !ok {
|
||||
delete(h.lostRead, id)
|
||||
continue
|
||||
}
|
||||
due := since.Add(h.lostCheckBound(d.Repository))
|
||||
if now.Before(due) {
|
||||
h.lookAt(due, "a proposed delivery's asked check, read in the build queue once its bound passed")
|
||||
continue
|
||||
}
|
||||
if read, was := h.lostRead[id]; was && now.Sub(read) < lostReadEvery {
|
||||
h.lookAt(read.Add(lostReadEvery), "a proposed delivery's asked check, looked for in the build queue again")
|
||||
continue
|
||||
}
|
||||
h.lostRead[id] = now
|
||||
h.lookAt(now.Add(lostReadEvery), "a proposed delivery's asked check, looked for in the build queue again")
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
return func() {
|
||||
asks, err := h.Controller.Queue()
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] the build queue cannot be read, so no check is asked again for a lost ask: %v", err)
|
||||
return
|
||||
}
|
||||
for _, id := range ids {
|
||||
h.mu.Lock()
|
||||
d := h.deliveries[id]
|
||||
since, ok := time.Time{}, false
|
||||
if d != nil {
|
||||
since, ok = awaitedSince(d)
|
||||
}
|
||||
var held *QueuedAsk
|
||||
if ok {
|
||||
held = heldBy(d, asks)
|
||||
}
|
||||
var why string
|
||||
if ok && held == nil {
|
||||
what := "its check against the trunk's head"
|
||||
if d.Check == nil {
|
||||
what = "its check " + d.CheckAsk.ID
|
||||
}
|
||||
why = fmt.Sprintf("%s, asked %s ago, is held by no entry of the build queue (neither waiting nor in flight): "+
|
||||
"lost, asked again", what, h.Now().Sub(since).Round(time.Second))
|
||||
}
|
||||
h.mu.Unlock()
|
||||
if why == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := h.askLost(id, byOwner, why, asks); err != nil {
|
||||
h.Logf("[mesh-delivery] %s: its lost check could not be asked again: %v", id, err)
|
||||
h.again("a proposed delivery's lost check that could not be asked again")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// recheckProposed is `recheck` of a proposed delivery (issue 1274): taken, with why, when no check of its head is
|
||||
// waiting or in flight in the build queue, and refused naming the one that is. A queue that cannot be read refuses it.
|
||||
func (h *Holder) recheckProposed(id, why, by string) (string, error) {
|
||||
if why == "" {
|
||||
return "", fmt.Errorf("a recheck says why")
|
||||
}
|
||||
asks, err := h.Controller.Queue()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s is not rechecked: the build queue cannot be read (%v), so whether its check is still held is not known", id, err)
|
||||
}
|
||||
h.mu.Lock()
|
||||
d := h.deliveries[id]
|
||||
var held *QueuedAsk
|
||||
if d != nil {
|
||||
held = heldBy(d, asks)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
if held != nil {
|
||||
return "", fmt.Errorf("%s is not rechecked: its check is still held, %s", id, held.words())
|
||||
}
|
||||
return h.askLost(id, by, why, asks)
|
||||
}
|
||||
|
||||
// askLost asks a proposed delivery's check again, the table's EvLost: tried first on a copy, so an ask the controller
|
||||
// does not take spends nothing. Its group's composed check goes out of date, and is asked once the member answers.
|
||||
func (h *Holder) askLost(id, by, why string, asks []QueuedAsk) (string, error) {
|
||||
h.mu.Lock()
|
||||
d := h.deliveries[id]
|
||||
if d == nil {
|
||||
h.mu.Unlock()
|
||||
return "", fmt.Errorf("no delivery %q", id)
|
||||
}
|
||||
if held := heldBy(d, asks); held != nil {
|
||||
h.mu.Unlock()
|
||||
return "", fmt.Errorf("%s is not asked again: its check is still held, %s", id, held.words())
|
||||
}
|
||||
probe := *d
|
||||
probe.Transitions = nil
|
||||
if _, err := Apply(&probe, EvLost, true, Facts{Now: h.Now(), Why: why, By: by}); err != nil {
|
||||
h.mu.Unlock()
|
||||
return "", err
|
||||
}
|
||||
m := MemberOf(d)
|
||||
if d.Check != nil && d.outOfDate() != "" && d.TrunkNow != "" {
|
||||
m.BaseSHA = d.TrunkNow
|
||||
}
|
||||
h.mu.Unlock()
|
||||
asked, err := h.Controller.Check("", []Member{m})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s's check could not be asked again yet: %w", id, err)
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
d = h.deliveries[id]
|
||||
if d == nil || d.State != Proposed {
|
||||
return "", fmt.Errorf("%s moved while its check was asked again", id)
|
||||
}
|
||||
now := h.Now()
|
||||
t, err := Apply(d, EvLost, true, Facts{Now: now, Why: why + " — asked as " + asked, By: by})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
d.CheckAsk = &CheckAsk{ID: asked, At: now, By: by, Why: why}
|
||||
if d.Check != nil {
|
||||
d.AskedAgain, d.FreshWanted = &now, false
|
||||
}
|
||||
delete(h.askTried, id)
|
||||
if g := h.groups[d.Group]; g != nil && g.Check != nil {
|
||||
g.Check.OutOfDate = fmt.Sprintf("composed check out of date: %s's check was lost in the build queue and asked again", d.ID)
|
||||
g.Check.OutOfDateAt = now
|
||||
g.Updated = now
|
||||
h.keepGroup(g)
|
||||
}
|
||||
h.moved(d, []Transition{t})
|
||||
return fmt.Sprintf("%s: its check was held by no entry of the build queue and is asked again as %s (%s)", id, asked, why), nil
|
||||
}
|
||||
@@ -30,10 +30,10 @@ type listening struct {
|
||||
func (l *listening) set(s string) { l.mu.Lock(); l.now = s; l.mu.Unlock() }
|
||||
func (l *listening) get() string { l.mu.Lock(); defer l.mu.Unlock(); return l.now }
|
||||
|
||||
// The events this owner follows: the forge's pull requests, and the controller's verdicts and walks.
|
||||
// The events this owner follows: the forge's pull requests, and the controller's walks.
|
||||
// The forge's status events say that a check answered (events.go).
|
||||
var followed = []string{"gitea.pull.updated", "gitea.pull.merged", "gitea.pull.closed",
|
||||
"gitea.forge.status", "gitea.forge.pull-request.edited", "gitea.forge.pull-request.closed", ControllerSeat + ".checked", ControllerSeat + ".plan-moved",
|
||||
"gitea.forge.status", "gitea.forge.pull-request.edited", "gitea.forge.pull-request.closed", ControllerSeat + ".plan-moved",
|
||||
forgePattern, ConfigChangedEvent}
|
||||
|
||||
// forgePattern is the forge bridge's messages on FORGE (novox/hq ADR 0437 §8): the gate follows a merge and the refusal of one.
|
||||
@@ -219,14 +219,7 @@ func take(h *Holder, e stdio.Envelope) error {
|
||||
return nil
|
||||
}
|
||||
h.CommitStatus(s)
|
||||
case ControllerSeat + ".checked":
|
||||
var c CheckedEvent
|
||||
if err := json.Unmarshal(e.Body, &c); err != nil {
|
||||
h.Refuse(fmt.Errorf("a verdict is not readable: %v", err))
|
||||
return nil
|
||||
}
|
||||
h.Checked(c)
|
||||
h.KeepVerdict(c)
|
||||
h.KeepCheckVerdict(s)
|
||||
case ConfigChangedEvent:
|
||||
var c ConfigChange
|
||||
if err := json.Unmarshal(e.Body, &c); err != nil {
|
||||
@@ -354,9 +347,8 @@ func tools(h *Holder, l *listening) []stdio.Tool {
|
||||
}},
|
||||
{Name: seat + "checks",
|
||||
Description: "What the mesh's checks said of a pull request's head or of one commit: each of the commit's " +
|
||||
"statuses (mesh/check, mesh/delivery, …; hq's mesh/repo-check) with its state, description and when it " +
|
||||
"was set; the merge check's full verdict as the controller said it — each layer's summary, the machine " +
|
||||
"that ran it, when, its build id and its report; and whether the branch's protection would let it " +
|
||||
"statuses (mesh/check, mesh/delivery, …) with its state, description and when it " +
|
||||
"was set; the merge check's verdict as the checker's mesh/check line says it, and when it was heard; and whether the branch's protection would let it " +
|
||||
"merge, every required status being success.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{"repository": str("owner/repository"),
|
||||
"number": str("a pull request's number: its head is read"),
|
||||
@@ -389,8 +381,8 @@ func tools(h *Holder, l *listening) []stdio.Tool {
|
||||
return h.Stalled(), nil
|
||||
}},
|
||||
{Name: seat + "recheck",
|
||||
Description: "Check a rejected or ready delivery again: its merge check statuses put back to pending on the forge, " +
|
||||
"so nothing merges on the old verdict; proposed again, and its check asked. With why.",
|
||||
Description: "Refused for every delivery: the checker checks a head again on every move of the head, its trunk or its " +
|
||||
"group, and nothing is asked of anyone here (novox/hq ADR 0447, issue 1318). Kept so a caller is told where to read.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{"id": str("the delivery's id"),
|
||||
"why": str("why, kept with the transition")}, "required": []string{"id", "why"}},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
|
||||
@@ -72,6 +72,11 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) {
|
||||
if len(m.Claims[0].Serves) != 13 {
|
||||
t.Fatalf("serves %d verbs, wanted 13: %v", len(m.Claims[0].Serves), m.Claims[0].Serves)
|
||||
}
|
||||
for _, never := range []string{"mesh-controller.checked", "seat:mesh-controller.delivery-check", "seat:mesh-controller.queue"} {
|
||||
if slices.Contains(m.Consumes, never) || slices.Contains(m.Invokes, never) {
|
||||
t.Errorf("the manifest still names %s (issue 1318)", never)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(m.Consumes, followed) || !slices.Contains(m.Consumes, "mesh-delivery-forge-bridge.forge.**") {
|
||||
t.Fatalf("consumes %v, follows %v (the bridge's forge messages among them)", m.Consumes, followed)
|
||||
}
|
||||
@@ -89,11 +94,11 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) {
|
||||
"mesh-delivery-teller.answers", "mesh-delivery-checker.verdicts", "mesh-controller.assignments", "node-engine.have"}) {
|
||||
t.Fatalf("reads %v", m.Reads)
|
||||
}
|
||||
// It calls exactly the controller's seven verbs (ADR 0450 §7; queue since issue 1274, forget-checks no more since issue 1318) and reads the forge with
|
||||
// It calls exactly the controller's five verbs (ADR 0450 §7; no delivery-check, no queue and no forget-checks since issue 1318, record D5) and reads the forge with
|
||||
// three tools; the forge's
|
||||
// writes are the bridge's (ADR 0437 §8). It uses no operator-channel: the teller asks (ADR 0437 §6).
|
||||
want := []string{}
|
||||
for _, v := range []string{"deliver", "delivery-stop", "delivery-backups-again", "delivery-walks", "delivery-check", "queue", "plans"} {
|
||||
for _, v := range []string{"deliver", "delivery-stop", "delivery-backups-again", "delivery-walks", "plans"} {
|
||||
want = append(want, "seat:"+ControllerSeat+"."+v)
|
||||
}
|
||||
want = append(want, "gitea.gitea_commit_statuses", "gitea.gitea_contains", "gitea.gitea_open_pulls")
|
||||
|
||||
@@ -109,10 +109,9 @@ func verdictOfCheck(state, desc string, now time.Time) *Verdict {
|
||||
Status: ctxCheck}
|
||||
}
|
||||
|
||||
// judgedByCheck is whether the delivery's base requires mesh/check, as the forge said it, or its verdict is mesh/check's.
|
||||
func (d *Delivery) judgedByCheck() bool {
|
||||
return slices.Contains(d.Required, ctxCheck) || (d.Check != nil && d.Check.Status == ctxCheck)
|
||||
}
|
||||
// judgedByCheck is whether the checker judges the delivery: always. Every base requires mesh/check since hq's did (ADR 0527
|
||||
// §2), and the controller judges no pull request any more (ADR 0524 §5), so nothing is asked of it (issue 1318).
|
||||
func (d *Delivery) judgedByCheck() bool { return true }
|
||||
|
||||
// noteRequired keeps what the forge said the delivery's base requires. Under the lock.
|
||||
func (h *Holder) noteRequired(d *Delivery, required []string) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
@@ -229,17 +228,3 @@ func TestTheCheckLineGivesHeadAndTrunk(t *testing.T) {
|
||||
t.Fatalf("a line that names no commits gave %q %q", head, trunk)
|
||||
}
|
||||
}
|
||||
|
||||
// 1302 with issue 1274's lost checks: a head on mesh/check never awaits a controller's check, so the build queue is not
|
||||
// read for it and nothing is asked again.
|
||||
func TestAHeadOnMeshCheckAwaitsNoControllersCheck(t *testing.T) {
|
||||
at := time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC)
|
||||
d := &Delivery{State: Proposed, Number: 1, CheckAsk: &CheckAsk{ID: "check-1", At: at}}
|
||||
if _, ok := awaitedSince(d); !ok {
|
||||
t.Fatal("the controller's asked check is not awaited")
|
||||
}
|
||||
d.Required = []string{ctxCheck}
|
||||
if _, ok := awaitedSince(d); ok {
|
||||
t.Fatal("a head on mesh/check awaits a controller's check")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,8 +49,6 @@ func MemberOf(d *Delivery) Member {
|
||||
type Controller interface {
|
||||
// Plan is the delivery plan of a diffset, kept nowhere.
|
||||
Plan(repository, base, head string, paths, moduleDirs, removed []string) (*DeliveryPlan, error)
|
||||
// Check asks a group's composed check, or — with no group and one member — that head's own check again.
|
||||
Check(group string, members []Member) (string, error)
|
||||
// Deliver lets a waiting walk start.
|
||||
Deliver(walk, why string) error
|
||||
// BackupsAgain asks the controller to take a held walk's failed backups again, and to go on when they pass
|
||||
@@ -64,24 +62,8 @@ type Controller interface {
|
||||
StopWithout(walk, why, by string, without []string) (StopAnswer, error)
|
||||
// Walks are the walks the controller keeps — one, given its id — and whether this seat has a holder on record.
|
||||
Walks(walk string) (bool, []Walk, error)
|
||||
// Queue is every ask the build queue holds, waiting or in flight (the controller's `queue`, novox/hq issue 455): what
|
||||
// tells a check still on its way from one the queue lost (issue 1274). A dead ask is not held.
|
||||
Queue() ([]QueuedAsk, error)
|
||||
}
|
||||
|
||||
// QueuedAsk is one ask of the build queue as the controller's `queue` answers it as data.
|
||||
type QueuedAsk struct {
|
||||
ID string `json:"id"`
|
||||
State string `json:"state"` // waiting, in flight or dead
|
||||
Kind string `json:"kind"` // check or delivery
|
||||
Repository string `json:"repository"`
|
||||
Commit string `json:"commit,omitempty"`
|
||||
Machine string `json:"machine,omitempty"`
|
||||
}
|
||||
|
||||
// Holds is whether the ask is still the queue's to run: waiting or in flight, never dead.
|
||||
func (q QueuedAsk) Holds() bool { return q.State != "dead" }
|
||||
|
||||
// Forge is what the forge's holder does for a delivery.
|
||||
type Forge interface {
|
||||
// Statuses reads a commit's statuses — a pull request's head's, given its number — and whether the branch
|
||||
@@ -240,43 +222,6 @@ func (c seatController) Plan(repository, base, head string, paths, moduleDirs, r
|
||||
return a.Plan, nil
|
||||
}
|
||||
|
||||
func (c seatController) Check(group string, members []Member) (string, error) {
|
||||
args := map[string]any{"members": string(mustJSON(members))}
|
||||
if group != "" {
|
||||
args["group"] = group
|
||||
}
|
||||
raw, err := c.verb("delivery-check", args)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var a struct {
|
||||
Asked string `json:"asked"`
|
||||
Rechecked string `json:"rechecked"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &a)
|
||||
if a.Asked != "" {
|
||||
return a.Asked, nil
|
||||
}
|
||||
if a.Rechecked != "" {
|
||||
return a.Rechecked, nil
|
||||
}
|
||||
return "", fmt.Errorf("the controller's delivery-check answered no check id (%s): it is not taken as asked", clip(string(raw), 200))
|
||||
}
|
||||
|
||||
func (c seatController) Queue() ([]QueuedAsk, error) {
|
||||
raw, err := c.verb("queue", map[string]any{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var a struct {
|
||||
Asks []QueuedAsk `json:"asks"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
return nil, fmt.Errorf("the controller's queue is not readable: %w", err)
|
||||
}
|
||||
return a.Asks, nil
|
||||
}
|
||||
|
||||
func (c seatController) Deliver(walk, why string) error {
|
||||
_, err := c.verb("deliver", map[string]any{"plan": walk, "why": why})
|
||||
return err
|
||||
|
||||
@@ -54,24 +54,35 @@ func (h *Holder) KeepPull(p PullEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
// KeepVerdict writes a verdict into the key of its pull request, when the key holds that head and trunk commit.
|
||||
func (h *Holder) KeepVerdict(c CheckedEvent) {
|
||||
if h.Pulls == nil || c.Group != "" || c.Number == 0 || c.Commit == "" || c.Trunk == "" {
|
||||
// KeepCheckVerdict writes the verdict of a head's mesh/check into the key of its pull request, when the key holds that head
|
||||
// and the trunk commit the check line names (issue 1302; the controller's verdict wrote it before issue 1318). Outside the lock.
|
||||
func (h *Holder) KeepCheckVerdict(e ForgeStatusEvent) {
|
||||
if h.Pulls == nil || e.Context != ctxCheck || e.Description == "" || e.SHA == "" {
|
||||
return
|
||||
}
|
||||
verdict := c.Verdict
|
||||
if verdict == "" {
|
||||
verdict := map[string]string{"success": "pass", "warning": "warning", "failure": "fail", "error": "error"}[e.State]
|
||||
_, trunk := checkLine(e.Description)
|
||||
if verdict == "" || trunk == "" {
|
||||
return // pending is no verdict; a line naming no trunk commit is judged against none
|
||||
}
|
||||
h.mu.Lock()
|
||||
number := 0
|
||||
if d := h.deliveries[IDOf(strings.ToLower(e.Owner+"/"+e.Repo), e.SHA)]; d != nil {
|
||||
number = d.Number
|
||||
}
|
||||
h.mu.Unlock()
|
||||
if number == 0 {
|
||||
return
|
||||
}
|
||||
err := h.Pulls.Change(pullKeyName(c.Owner, c.Repo, c.Number), func(k PullKey) (PullKey, bool) {
|
||||
if !sameCommit(k.Head, c.Commit) || !sameCommit(k.Trunk, c.Trunk) || k.Verdict == verdict {
|
||||
err := h.Pulls.Change(pullKeyName(e.Owner, e.Repo, number), func(k PullKey) (PullKey, bool) {
|
||||
if !sameCommit(k.Head, e.SHA) || !sameCommit(k.Trunk, trunk) || k.Verdict == verdict {
|
||||
return k, false
|
||||
}
|
||||
k.Verdict = verdict
|
||||
return k, true
|
||||
})
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] the verdict in the key of %s/%s#%d could not be written: %v", c.Owner, c.Repo, c.Number, err)
|
||||
h.Logf("[mesh-delivery] the verdict in the key of %s/%s#%d could not be written: %v", e.Owner, e.Repo, number, err)
|
||||
h.again("a pull request's key that could not be written")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,33 +71,11 @@ func TestAnAnnouncedHeadWritesTheKeyAndTheSameAnnouncementAgainWritesNothing(t *
|
||||
}
|
||||
}
|
||||
|
||||
func TestAVerdictForTheKeysHeadAndTrunkIsWrittenAndOneForAnotherIsNot(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
pulls := newFakePulls()
|
||||
w.h.Pulls = pulls
|
||||
w.h.KeepPull(announcedAt(readyHead, "t1t1t1t1t1t1"))
|
||||
old := judgedAt("novox/app", 7, pushed, "pass", "t1t1t1t1t1t1")
|
||||
w.h.KeepVerdict(old)
|
||||
if pulls.get(appKey).Verdict != "" {
|
||||
t.Fatal("a verdict for another head was written")
|
||||
}
|
||||
other := judgedAt("novox/app", 7, readyHead, "pass", "t9t9t9t9t9t9")
|
||||
w.h.KeepVerdict(other)
|
||||
if pulls.get(appKey).Verdict != "" {
|
||||
t.Fatal("a verdict for another trunk commit was written")
|
||||
}
|
||||
w.h.KeepVerdict(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
if got := pulls.get(appKey); got.Verdict != "pass" {
|
||||
t.Fatalf("the key holds %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestANewTrunkCommitClearsTheVerdictAndANewHeadReplacesTheKey(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
pulls := newFakePulls()
|
||||
w.h.Pulls = pulls
|
||||
w.h.KeepPull(announcedAt(readyHead, "t1t1t1t1t1t1"))
|
||||
w.h.KeepVerdict(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.h.KeepPull(announcedAt(readyHead, "t2t2t2t2t2t2"))
|
||||
if got := pulls.get(appKey); got.Trunk != "t2t2t2t2t2t2" || got.Verdict != "" {
|
||||
t.Fatalf("the check asked again against a new trunk left %+v", got)
|
||||
@@ -108,6 +86,34 @@ func TestANewTrunkCommitClearsTheVerdictAndANewHeadReplacesTheKey(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMeshCheckForTheKeysHeadAndTrunkIsWrittenAndOneForAnotherIsNot(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
pulls := newFakePulls()
|
||||
w.h.Pulls = pulls
|
||||
w.h.PullUpdated(announcedAt(readyHead, "a1a1a1a1a1a1"))
|
||||
w.h.KeepPull(announcedAt(readyHead, "a1a1a1a1a1a1"))
|
||||
status := func(head, trunk, state string) ForgeStatusEvent {
|
||||
return ForgeStatusEvent{Owner: "novox", Repo: "app", SHA: head, Context: ctxCheck, State: state,
|
||||
Description: "pass " + head[:12] + " on " + trunk + " | unit 3 run"}
|
||||
}
|
||||
w.h.KeepCheckVerdict(status(pushed, "a1a1a1a1a1a1", "success"))
|
||||
if pulls.get(appKey).Verdict != "" {
|
||||
t.Fatal("a verdict for another head was written")
|
||||
}
|
||||
w.h.KeepCheckVerdict(status(readyHead, "b2b2b2b2b2b2", "success"))
|
||||
if pulls.get(appKey).Verdict != "" {
|
||||
t.Fatal("a verdict for another trunk commit was written")
|
||||
}
|
||||
w.h.KeepCheckVerdict(status(readyHead, "a1a1a1a1a1a1", "pending"))
|
||||
if pulls.get(appKey).Verdict != "" {
|
||||
t.Fatal("a pending check was written as a verdict")
|
||||
}
|
||||
w.h.KeepCheckVerdict(status(readyHead, "a1a1a1a1a1a1", "success"))
|
||||
if got := pulls.get(appKey); got.Verdict != "pass" {
|
||||
t.Fatalf("the key holds %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestALostRaceReadsTheKeyAgainAndAppliesTheChangeToWhatItFound(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
pulls := newFakePulls()
|
||||
@@ -120,39 +126,6 @@ func TestALostRaceReadsTheKeyAgainAndAppliesTheChangeToWhatItFound(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// A check asked for a head whose Approve is asked is on the ready stream (ADR 0406 §3, ADR 0421 §10); one for a head
|
||||
// with no Approve is asked as before.
|
||||
func TestAPullRequestWithItsApproveAskedHasItsCheckAskedOnTheReadyStream(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.settleAll()
|
||||
w.h.PullMerged(merged("novox/app", 9, "9999999999999999", "t2t2t2t2t2t2"))
|
||||
w.settleAll()
|
||||
if len(w.ctl.checkReady) == 0 || !w.ctl.checkReady[0] {
|
||||
t.Fatalf("the check of a pull request whose Approve is asked was asked with ready %v", w.ctl.checkReady)
|
||||
}
|
||||
// One with no Approve asked is asked as before.
|
||||
w2 := newWorld(t)
|
||||
w2.h.PullUpdated(pr("novox/app", 8, pushed, "feat/y", "src/a.go"))
|
||||
w2.settleAll()
|
||||
if len(w2.ctl.checkReady) == 0 || w2.ctl.checkReady[0] {
|
||||
t.Fatalf("the check of a pull request with no Approve asked was asked with ready %v", w2.ctl.checkReady)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATrunkLessVerdictWritesNothingToTheKey(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
pulls := newFakePulls()
|
||||
w.h.Pulls = pulls
|
||||
w.h.KeepPull(announcedAt(readyHead, "t1t1t1t1t1t1"))
|
||||
w.h.KeepVerdict(judgedAt("novox/app", 7, readyHead, "pass", ""))
|
||||
if got := pulls.get(appKey); got.Verdict != "" {
|
||||
t.Fatalf("a verdict naming no trunk commit was written: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The forge's catch-up after a restart can announce a merge again past the bus's de-duplication window (issue 975):
|
||||
// heard again, it changes nothing, and moves no trunk back.
|
||||
func TestTheSameMergeHeardAgainAfterALaterMergeMovesNoTrunkBack(t *testing.T) {
|
||||
|
||||
@@ -23,9 +23,9 @@ import (
|
||||
// or second Approve); it is asked once for a head, and once for a group's heads together;
|
||||
// - it merges through the forge's merge tool (gitea_merge_pull_request), the one path every merge takes, at the head
|
||||
// the Approve names, handing the tool the heads the Approve covers (ADR 0421 §9);
|
||||
// - only when its delivery is ready here: its check passed against its trunk's head. A verdict out of date is asked
|
||||
// again by this owner at once (askFresh), and an Approve already asked stays through a trunk move (ADR 0421 §3): the
|
||||
// merge is asked again on the passing verdict, with no second Approve;
|
||||
// - only when its delivery is ready here: its check passed against its trunk's head. A verdict out of date is judged
|
||||
// again by the checker on its trunk's move (ADR 0447 §4), and an Approve already asked stays through a trunk move
|
||||
// (ADR 0421 §3): the merge is asked again on the passing verdict, with no second Approve;
|
||||
// - a group member only once the group's composed check passed for its members' heads as they are;
|
||||
// - a push ends the Approve and says so once on the pull request (ADR 0421 §6); the new head is asked on its own;
|
||||
// - a check that failed merges nothing: it is said once on the pull request, and as a stalled line, which the
|
||||
@@ -175,9 +175,6 @@ func (h *Holder) readyAsks(now time.Time) []func() {
|
||||
if a := h.sayAfterLine(d); a != nil {
|
||||
asks = append(asks, a)
|
||||
}
|
||||
if a := h.askFresh(d, now); a != nil {
|
||||
asks = append(asks, a)
|
||||
}
|
||||
r := d.Ready
|
||||
if r == nil || r.Merged != nil || r.Declined != nil {
|
||||
continue
|
||||
@@ -293,50 +290,6 @@ func (h *Holder) sayAfterLine(d *Delivery) func() {
|
||||
"the order of a delivery comes from the module graph alone, and nothing asks for the line (novox/hq ADR 0419).")
|
||||
}
|
||||
|
||||
// askFresh is the ask of a head's check against its trunk's head, when its passing verdict is out of date and either
|
||||
// arrived so, or has an Approve asked or given for it (novox/hq ADR 0421 §3, issue 1022; issue 1047 for a group's
|
||||
// member): the Approve stays through a trunk move, and the merge is asked again on the verdict this brings, with no
|
||||
// second Approve. Nil when nothing needs asking. Under the lock; the ask runs outside it.
|
||||
func (h *Holder) askFresh(d *Delivery, now time.Time) func() {
|
||||
if d.State != Proposed || d.outOfDate() == "" || d.AskedAgain != nil || !d.Check.Passes() || d.Base != MergeBranch ||
|
||||
d.Number == 0 || d.TrunkNow == "" || d.judgedByCheck() {
|
||||
return nil // a head on mesh/check is the checker's to judge again on its trunk's move (ADR 0447 §4)
|
||||
}
|
||||
// A group's member too (issue 1047, design 47: a move of the trunk asks every check again): the group's one Approve is
|
||||
// asked after its composed check, so a member ready before that has no Approve to carry; its check is asked at once
|
||||
// all the same, and the composed check follows on the new verdict. The rest of ADR 0395 §1 stays with issue 888.
|
||||
if !d.FreshWanted && h.covering(d) == nil && h.groups[d.Group] == nil {
|
||||
return nil
|
||||
}
|
||||
if tried, was := h.askTried[d.ID]; was && now.Sub(tried) < time.Minute {
|
||||
h.lookAt(tried.Add(time.Minute), "a check against the trunk's head that could not be asked")
|
||||
return nil
|
||||
}
|
||||
h.askTried[d.ID] = now
|
||||
h.lookAt(now.Add(time.Minute), "a check against the trunk's head that could not be asked")
|
||||
id, m := d.ID, MemberOf(d)
|
||||
m.BaseSHA = d.TrunkNow
|
||||
return func() {
|
||||
asked, err := h.Controller.Check("", []Member{m})
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] %s's check against the trunk's head cannot be asked yet: %v", id, err)
|
||||
return
|
||||
}
|
||||
delete(h.askTried, id)
|
||||
d := h.deliveries[id]
|
||||
if d == nil || d.State != Proposed || d.outOfDate() == "" {
|
||||
return
|
||||
}
|
||||
t := h.Now()
|
||||
d.AskedAgain, d.FreshWanted = &t, false
|
||||
h.Logf("[mesh-delivery] %s: its verdict is out of date; its check against the trunk's head %s asked as %s", id,
|
||||
shortOf(d.TrunkNow, 12), asked)
|
||||
h.keep(d)
|
||||
}
|
||||
}
|
||||
|
||||
// mergeAgainNow lets an approved delivery's merge be asked at once: something that decides whether it may merge happened (a
|
||||
// verdict, an Approve, a group's composed check), so the wait after a refusal does not hold it. Under the lock.
|
||||
func (h *Holder) mergeAgainNow(d *Delivery) {
|
||||
|
||||
@@ -186,9 +186,6 @@ func TestAGroupMergesEveryMemberInOnePassOnlyOnceComposedAndApproved(t *testing.
|
||||
w.h.Checked(judgedAt("novox/mesh-controller", 1, ctlHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.h.Checked(judgedAt("novox/mesh-catalog", 2, catHead, "pass", "t3t3t3t3t3t3"))
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 0 || w.op.count() != 0 {
|
||||
t.Fatalf("a member was merged or asked before its group's composed check passed: %v, %d", w.forge.merges, w.op.count())
|
||||
}
|
||||
w.h.Checked(CheckedEvent{Group: "feat/x", ID: "check-1", Verdict: "pass", Summary: "both compose"})
|
||||
w.settleAll()
|
||||
if w.op.count() != 1 || len(w.forge.merges) != 0 {
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A recheck puts the merge check's statuses back to pending before it asks the check: the forge keeps the
|
||||
// newest status of each context on the head, and a recheck asks of that same head, so the old verdict would
|
||||
// stand — and branch protection would let the pull request merge on it — until the fresh one lands.
|
||||
|
||||
// heldGreen is a head whose merge check passed, as the forge's holder set it from the verdict.
|
||||
func heldGreen(t *testing.T, w *world, repo string, number int) string {
|
||||
t.Helper()
|
||||
w.h.PullUpdated(pr(repo, number, head, "feat/x", "src/a.go"))
|
||||
owner, name, _ := strings.Cut(repo, "/")
|
||||
_ = w.forge.Status(owner, name, head, ctxGate, "success", "pass: the gate said pass", "")
|
||||
_ = w.forge.Status(owner, name, head, ctxRepo, "success", "pass: merge-check.sh passed", "")
|
||||
w.forge.required = []string{ctxGate, ctxRepo}
|
||||
w.h.Checked(verdict(repo, number, head, "pass"))
|
||||
w.settleAll()
|
||||
id := IDOf(repo, head)
|
||||
if s := w.state(id); s != Ready {
|
||||
t.Fatalf("a passing verdict left it %s", s)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func TestARecheckPutsTheMergeCheckBackToPendingBeforeItAsks(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := heldGreen(t, w, "novox/app", 7)
|
||||
at := "novox/app@" + head + " "
|
||||
var whenAsked map[string]string
|
||||
w.ctl.onCheck = func() {
|
||||
w.forge.mu.Lock()
|
||||
defer w.forge.mu.Unlock()
|
||||
whenAsked = map[string]string{ctxGate: w.forge.statuses[at+ctxGate], ctxRepo: w.forge.statuses[at+ctxRepo]}
|
||||
}
|
||||
if _, err := w.h.Recheck(id, "main moved on", "a person"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(w.ctl.checks) != 1 {
|
||||
t.Fatalf("its check was asked %v", w.ctl.checks)
|
||||
}
|
||||
for _, c := range []string{ctxGate, ctxRepo} {
|
||||
if !strings.HasPrefix(whenAsked[c], "pending checking again: main moved on") {
|
||||
t.Fatalf("as its check was asked, the forge held %s %q: the old verdict still lets it merge", c, whenAsked[c])
|
||||
}
|
||||
}
|
||||
checks, err := w.forge.Statuses("novox", "app", 0, head, "main")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if checks.Merge.Mergeable == nil || *checks.Merge.Mergeable {
|
||||
t.Fatalf("rechecked, the forge would still merge it: %+v", checks.Merge)
|
||||
}
|
||||
if s := w.state(id); s != Proposed {
|
||||
t.Fatalf("rechecked, it is %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
// Only the statuses the head holds are put back: a verdict that says no repository check would leave a
|
||||
// pending one standing for ever.
|
||||
func TestARecheckLeavesAStatusTheHeadNeverHadAlone(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 8, head, "feat/y", "src/a.go"))
|
||||
_ = w.forge.Status("novox", "app", head, ctxGate, "failure", "fail: the gate said fail", "")
|
||||
w.h.Checked(verdict("novox/app", 8, head, "fail"))
|
||||
w.settleAll()
|
||||
id := IDOf("novox/app", head)
|
||||
if _, err := w.h.Recheck(id, "flaky", "a person"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s := w.forge.statuses["novox/app@"+head+" "+ctxGate]; !strings.HasPrefix(s, "pending checking again: flaky") {
|
||||
t.Fatalf("the gate is %q", s)
|
||||
}
|
||||
if s, set := w.forge.statuses["novox/app@"+head+" "+ctxRepo]; set {
|
||||
t.Fatalf("a repository check the head never had was set: %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
// A bus that cannot take the pending statuses refuses the recheck whole: nothing is asked on a stale verdict, and the
|
||||
// delivery stays where it stood, for the person to ask again.
|
||||
func TestARecheckTheBusCannotTakeIsRefused(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := heldGreen(t, w, "novox/app", 9)
|
||||
w.owed.down = true
|
||||
if _, err := w.h.Recheck(id, "main moved on", "a person"); err == nil {
|
||||
t.Fatal("a recheck whose statuses could not be put back was taken")
|
||||
}
|
||||
if len(w.ctl.checks) != 0 {
|
||||
t.Fatalf("its check was asked on a stale verdict: %v", w.ctl.checks)
|
||||
}
|
||||
if s := w.state(id); s != Ready {
|
||||
t.Fatalf("refused, it moved to %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq issue 867, ADR 0323 §6: "the composed check runs again when a member is rechecked". A group whose composed
|
||||
// check failed stayed rejected after a member was rechecked and passed, because the kept verdict was for the same heads
|
||||
// and nothing marked it out of date.
|
||||
func TestAMembersRecheckAsksTheGroupsComposedCheckAgain(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/a", 1, "a1a1a1a1a1a1a1", "feat/z"))
|
||||
w.h.PullUpdated(pr("novox/b", 2, "b2b2b2b2b2b2b2", "feat/z"))
|
||||
w.h.Checked(verdict("novox/a", 1, "a1a1a1a1a1a1a1", "pass"))
|
||||
w.h.Checked(verdict("novox/b", 2, "b2b2b2b2b2b2b2", "pass"))
|
||||
w.settleAll()
|
||||
groupAsks := func() (n int) {
|
||||
for _, c := range w.ctl.checks {
|
||||
if strings.HasPrefix(c, "feat/z:") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if groupAsks() != 1 {
|
||||
t.Fatalf("the group was asked %v", w.ctl.checks)
|
||||
}
|
||||
w.h.Checked(CheckedEvent{Group: "feat/z", ID: "check-1", Verdict: "fail", Summary: "they do not compose"})
|
||||
w.settleAll()
|
||||
if state, _ := GroupState(w.h.groups["feat/z"], w.h.membersOf(w.h.groups["feat/z"])); state != "rejected" {
|
||||
t.Fatalf("the group is %s", state)
|
||||
}
|
||||
if _, err := w.h.Recheck(IDOf("novox/b", "b2b2b2b2b2b2b2"), "the judge was fixed", "a person"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The member's own check passes again, at the same head, a while later.
|
||||
w.later(5 * time.Minute)
|
||||
w.h.Checked(verdict("novox/b", 2, "b2b2b2b2b2b2b2", "pass"))
|
||||
w.settleAll()
|
||||
if groupAsks() != 2 {
|
||||
t.Fatalf("a member's recheck did not ask the group again: %v", w.ctl.checks)
|
||||
}
|
||||
if g := w.h.groups["feat/z"]; g.Check == nil || g.Check.Verdict != "" || g.Check.Asked == "check-1" {
|
||||
t.Fatalf("the old verdict still stands: %+v", g.Check)
|
||||
}
|
||||
}
|
||||
|
||||
// A recheck made while the group's composed check is still running is not lost: the old run's answer lands, and the
|
||||
// group is still asked again once the member is ready.
|
||||
func TestARecheckWhileTheComposedCheckRunsAsksTheGroupAgain(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/a", 1, "a1a1a1a1a1a1a1", "feat/z"))
|
||||
w.h.PullUpdated(pr("novox/b", 2, "b2b2b2b2b2b2b2", "feat/z"))
|
||||
w.h.Checked(verdict("novox/a", 1, "a1a1a1a1a1a1a1", "pass"))
|
||||
w.h.Checked(verdict("novox/b", 2, "b2b2b2b2b2b2b2", "pass"))
|
||||
w.settleAll() // the composed check check-1 is asked and running: no verdict yet
|
||||
if _, err := w.h.Recheck(IDOf("novox/b", "b2b2b2b2b2b2b2"), "the judge was fixed", "a person"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The old run answers.
|
||||
w.h.Checked(CheckedEvent{Group: "feat/z", ID: "check-1", Verdict: "fail", Summary: "they do not compose"})
|
||||
w.later(5 * time.Minute)
|
||||
w.h.Checked(verdict("novox/b", 2, "b2b2b2b2b2b2b2", "pass"))
|
||||
w.settleAll()
|
||||
n := 0
|
||||
for _, c := range w.ctl.checks {
|
||||
if strings.HasPrefix(c, "feat/z:") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != 2 {
|
||||
t.Fatalf("the group was not asked again after a recheck made while its check ran: %v", w.ctl.checks)
|
||||
}
|
||||
if g := w.h.groups["feat/z"]; g.Check == nil || g.Check.Verdict != "" || g.Check.OutOfDate != "" {
|
||||
t.Fatalf("the old run's answer stands: %+v", g.Check)
|
||||
}
|
||||
// The old run answering again is history.
|
||||
w.h.Checked(CheckedEvent{Group: "feat/z", ID: "check-1", Verdict: "fail", Summary: "late"})
|
||||
if g := w.h.groups["feat/z"]; g.Check.Verdict != "" {
|
||||
t.Fatalf("a late answer of the old run was taken: %+v", g.Check)
|
||||
}
|
||||
}
|
||||
@@ -27,23 +27,13 @@ func TestALookThatMeetsNothingWaitingOnTheClockWantsNoOtherLook(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAProposedDeliveryWithNoVerdictHasItsCheckAskedAtOnceWithNoGrace(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.ctl.down = false
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
id := IDOf("novox/app", readyHead)
|
||||
w.h.React()
|
||||
if w.delivery(id).CheckAsk == nil || len(w.ctl.checks) != 1 {
|
||||
t.Fatalf("the first look did not ask the check: %v", w.ctl.checks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAProposedDeliveryWhoseHeadHoldsAPassingVerdictAtTheForgeAdoptsItAndAsksNoCheck(t *testing.T) {
|
||||
func TestAProposedDeliveryWhoseHeadHoldsAPassingMeshCheckAtTheForgeAdoptsItAndAsksNoCheck(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
id := IDOf("novox/app", readyHead)
|
||||
op := openPull("novox/app", 7, readyHead)
|
||||
op.Checks = map[string]HeadCheck{"mesh/merge-gate": {State: "success"}, "mesh/repo-check": {State: "success"}}
|
||||
op.Required = []string{ctxCheck}
|
||||
op.Checks = map[string]HeadCheck{ctxCheck: {State: "success", Description: "pass " + readyHead[:12] + " | the checker's"}}
|
||||
w.forge.open = []OpenPull{op}
|
||||
w.h.React()
|
||||
w.settleAll()
|
||||
@@ -142,71 +132,3 @@ func TestAnAskTheControllerCouldNotAnswerIsAskedAgainAfterAWhileByTheTimerAlone(
|
||||
t.Fatalf("the look wanted at %s for %q; wanted %s for the plan that could not be asked", at, step, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAStandingGateWithNoRepoCheckIsNotAdoptedAndTheCheckIsAsked(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
id := IDOf("novox/app", readyHead)
|
||||
op := openPull("novox/app", 7, readyHead)
|
||||
op.Required = []string{"mesh/merge-gate", "mesh/repo-check"}
|
||||
op.Checks = map[string]HeadCheck{"mesh/merge-gate": {State: "success"}}
|
||||
w.forge.open = []OpenPull{op}
|
||||
w.settleAll()
|
||||
if d := w.delivery(id); d.Check != nil || d.State != Proposed {
|
||||
t.Fatalf("a gate with no repo-check was adopted: %+v (%s)", d.Check, d.State)
|
||||
}
|
||||
if len(w.ctl.checks) != 1 {
|
||||
t.Fatalf("its check was not asked: %v", w.ctl.checks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAKeptAskWithNoIdIsNoAskAndAStandingVerdictIsAdopted(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
id := IDOf("novox/app", readyHead)
|
||||
w.h.mu.Lock()
|
||||
w.h.deliveries[id].CheckAsk = &CheckAsk{At: w.now, By: byOwner}
|
||||
w.h.mu.Unlock()
|
||||
op := openPull("novox/app", 7, readyHead)
|
||||
op.Required = []string{"mesh/merge-gate", "mesh/repo-check"}
|
||||
op.Checks = map[string]HeadCheck{"mesh/merge-gate": {State: "success"}, "mesh/repo-check": {State: "success"}}
|
||||
w.forge.open = []OpenPull{op}
|
||||
w.settleAll()
|
||||
if d := w.delivery(id); d.State != Ready || d.Check == nil {
|
||||
t.Fatalf("the standing verdict was not adopted over an ask with no id: %s", d.State)
|
||||
}
|
||||
if len(w.ctl.checks) != 0 || w.op.count() != 1 {
|
||||
t.Fatalf("checks %v, Approves asked %d; wanted none and one", w.ctl.checks, w.op.count())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAKeptAskWithNoIdAndNoStandingVerdictIsAskedAgainAtOnce(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
id := IDOf("novox/app", readyHead)
|
||||
w.h.mu.Lock()
|
||||
w.h.deliveries[id].CheckAsk = &CheckAsk{At: w.now, By: byOwner}
|
||||
w.h.mu.Unlock()
|
||||
w.settleAll()
|
||||
if d := w.delivery(id); d.CheckAsk == nil || d.CheckAsk.ID == "" || len(w.ctl.checks) != 1 {
|
||||
t.Fatalf("not asked again: %+v %v", d.CheckAsk, w.ctl.checks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEmptyAnswerToACheckAskIsARefusalAskedAtMostOnceAMinute(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.ctl.emptyCheck = true
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
id := IDOf("novox/app", readyHead)
|
||||
w.settleAll()
|
||||
w.settleAll()
|
||||
if len(w.ctl.checks) != 1 || w.delivery(id).CheckAsk != nil || len(w.forge.notesOn(readyHead)) > 1 {
|
||||
t.Fatalf("asked %v, ask kept %v; wanted one ask and none kept", w.ctl.checks, w.delivery(id).CheckAsk)
|
||||
}
|
||||
w.later(time.Minute)
|
||||
w.settleAll()
|
||||
if len(w.ctl.checks) != 2 {
|
||||
t.Fatalf("not asked again after the minute: %v", w.ctl.checks)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,98 +290,16 @@ func (h *Holder) Stalled() []StalledLine {
|
||||
return out
|
||||
}
|
||||
|
||||
// Recheck is the `recheck` verb: a rejected or ready delivery proposed again, and its check asked again.
|
||||
//
|
||||
// A recheck asks of the head the forge already judged, and the forge keeps the newest status of each
|
||||
// context on it: left alone, the old verdict stands, and a branch that requires it lets the pull request merge
|
||||
// on it while the fresh check runs. So the merge check's statuses are put back to pending first — by the
|
||||
// forge's holder, which keeps them — and only then is the check asked. A forge that cannot be told refuses the
|
||||
// recheck whole: the delivery stays where it stood, and nothing is asked on a stale verdict.
|
||||
// Recheck is the `recheck` verb. The checker judges every head and checks it again on every move of its trunk (ADR 0447 §4), and
|
||||
// the controller judges none (ADR 0524 §5), so there is no ask to make: a delivery that exists is refused, naming the checker
|
||||
// (the lead's decision for issue 1302), and an unknown id is refused as before.
|
||||
func (h *Holder) Recheck(id, why, by string) (string, error) {
|
||||
h.mu.Lock()
|
||||
d := h.deliveries[id]
|
||||
if d == nil {
|
||||
h.mu.Unlock()
|
||||
defer h.mu.Unlock()
|
||||
if h.deliveries[id] == nil {
|
||||
return "", fmt.Errorf("no delivery %q", id)
|
||||
}
|
||||
if d.judgedByCheck() {
|
||||
h.mu.Unlock()
|
||||
return "", checkerRefusal(id) // the lead's decision for issue 1302: the checker serves no ask
|
||||
}
|
||||
if d.State == Proposed {
|
||||
// A proposed delivery's check is asked again when the build queue holds none of its head (issue 1274).
|
||||
h.mu.Unlock()
|
||||
return h.recheckProposed(id, why, by)
|
||||
}
|
||||
// Whether the table takes it, tried on a copy: nothing is said on the forge for a recheck it refuses.
|
||||
probe := *d
|
||||
probe.Transitions = append([]Transition(nil), d.Transitions...)
|
||||
if _, err := Apply(&probe, EvRecheck, true, Facts{Now: h.Now(), Why: why, By: by}); err != nil {
|
||||
h.mu.Unlock()
|
||||
return "", err
|
||||
}
|
||||
owner, repo, commit, page, number := d.Owner(), d.Repo(), d.Commit, d.HTMLURL, d.Number
|
||||
h.mu.Unlock()
|
||||
if err := h.checkingAgain(owner, repo, number, commit, page, why); err != nil {
|
||||
return "", fmt.Errorf("%s is not rechecked: %v; its merge check must say pending before it is asked again, "+
|
||||
"so nothing merges on the old verdict — ask again once the forge answers", id, err)
|
||||
}
|
||||
h.mu.Lock()
|
||||
t, err := Apply(d, EvRecheck, true, Facts{Now: h.Now(), Why: why, By: by})
|
||||
if err != nil {
|
||||
h.mu.Unlock()
|
||||
return "", fmt.Errorf("%v (its merge check says pending; the forge's next announcement asks it)", err)
|
||||
}
|
||||
d.Check = nil
|
||||
// The composed check of its group goes out of date (novox/hq ADR 0323 §6, issue 867): it was judged for these very
|
||||
// heads (running or answered: a run's answer is taken by its id and must not stand for the recheck), so without this the kept verdict (a failure above all) stands and the group is never asked again. It is
|
||||
// asked again once the member is judged again, when every member is ready.
|
||||
if g := h.groups[d.Group]; g != nil && g.Check != nil {
|
||||
now := h.Now()
|
||||
g.Check.OutOfDate = fmt.Sprintf("composed check out of date: %s was rechecked (%s)", d.ID, why)
|
||||
g.Check.OutOfDateAt = now
|
||||
g.Updated = now
|
||||
h.keepGroup(g)
|
||||
}
|
||||
h.moved(d, []Transition{t})
|
||||
m := MemberOf(d)
|
||||
h.mu.Unlock()
|
||||
asked, err := h.Controller.Check("", []Member{m})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s is proposed again and its check could not be asked yet (%v); the forge's next "+
|
||||
"announcement asks it", id, err)
|
||||
}
|
||||
return fmt.Sprintf("%s is proposed again, its merge check pending; its check was asked (%s)", id, asked), nil
|
||||
}
|
||||
|
||||
// checkingAgain puts a head's merge check statuses — those a branch's protection may require — back to
|
||||
// pending, so the forge refuses the merge until the fresh verdict sets them. The gate always, since every
|
||||
// verdict sets it; the repository's own check only when the head holds one, since a verdict that says none
|
||||
// would leave a pending one standing for ever. Asked of the forge's holder, which keeps them; it takes
|
||||
// pending on them and nothing else, a verdict being the controller's alone (novox/hq issue 293).
|
||||
func (h *Holder) checkingAgain(owner, repo string, number int, commit, page, why string) error {
|
||||
if h.Forge == nil {
|
||||
return errors.New("no forge")
|
||||
}
|
||||
// The statuses are owed as keys the forge bridge performs, on the pull request number the delivery has.
|
||||
contexts := []string{ctxGate, ctxRepo}
|
||||
if held, err := h.Forge.Statuses(owner, repo, 0, commit, ""); err == nil {
|
||||
contexts = []string{ctxGate}
|
||||
for _, s := range held.Statuses {
|
||||
if s.Context == ctxRepo {
|
||||
contexts = append(contexts, ctxRepo)
|
||||
}
|
||||
}
|
||||
}
|
||||
line := clip(oneLine("checking again: "+why), 140)
|
||||
for _, c := range contexts {
|
||||
act, value, num, head := owedOf(Effect{Kind: EffectStatus, Commit: commit, Context: c, State: "pending", Line: line},
|
||||
owner+"/"+repo, number, commit, "", page)
|
||||
if err := h.owe(owner+"/"+repo, num, head, act, value); err != nil {
|
||||
return fmt.Errorf("%s could not be owed as pending on %s: %w", c, shortOf(commit, 8), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return "", checkerRefusal(id)
|
||||
}
|
||||
|
||||
// Release is the `release` verb: a held delivery goes on, on a person's word.
|
||||
@@ -670,42 +588,11 @@ func (h *Holder) Close(id, why string) (string, error) {
|
||||
return fmt.Sprintf("%s: %s → %s, as its walk's record says (%s)", id, was, d.State, why), nil
|
||||
}
|
||||
|
||||
// reask is H2's close of a proposed delivery past its bound: its check, asked and not answered, asked once
|
||||
// more — the table's re-ask, which holds only once. The row is tried first on a copy, so an ask the controller
|
||||
// does not take spends nothing. Called under the lock; it unlocks.
|
||||
// reask is H2's close of a proposed delivery past its bound. A head's check is the checker's, which asks nothing of anyone
|
||||
// and checks again on its own (ADR 0447 §4), so there is no check to ask once more. Called under the lock; it unlocks.
|
||||
func (h *Holder) reask(d *Delivery, why string) (string, error) {
|
||||
id := d.ID
|
||||
if d.judgedByCheck() {
|
||||
h.mu.Unlock()
|
||||
return "", checkerRefusal(id)
|
||||
}
|
||||
trial := *d
|
||||
trial.Transitions = nil
|
||||
if _, err := Apply(&trial, EvReask, true, Facts{Now: h.Now(), By: byH2, Why: why}); err != nil {
|
||||
h.mu.Unlock()
|
||||
return "", err
|
||||
}
|
||||
m := MemberOf(d)
|
||||
h.mu.Unlock()
|
||||
asked, err := h.Controller.Check("", []Member{m})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s's check could not be re-asked yet: %w", id, err)
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
d = h.deliveries[id]
|
||||
if d == nil {
|
||||
return "", fmt.Errorf("no delivery %q", id)
|
||||
}
|
||||
t, err := Apply(d, EvReask, true, Facts{Now: h.Now(), By: byH2, Why: why + " — re-asked as " + asked})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
d.CheckAsk.Reasked++
|
||||
d.CheckAsk.ID, d.CheckAsk.At, d.CheckAsk.By, d.CheckAsk.Why = asked, h.Now(), byH2, why
|
||||
h.moved(d, []Transition{t})
|
||||
return fmt.Sprintf("%s: its check re-asked as %s, once (%s); if that is not answered either, the operator's", id,
|
||||
asked, why), nil
|
||||
return "", checkerRefusal(d.ID)
|
||||
}
|
||||
|
||||
// WhatIf is the `what-if` verb.
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
"gitea.forge.status",
|
||||
"gitea.forge.pull-request.edited",
|
||||
"gitea.forge.pull-request.closed",
|
||||
"mesh-controller.checked",
|
||||
"mesh-controller.plan-moved",
|
||||
"mesh-delivery-forge-bridge.forge.**",
|
||||
"mesh-config.config-changed"
|
||||
@@ -45,8 +44,6 @@
|
||||
"seat:mesh-controller.delivery-stop",
|
||||
"seat:mesh-controller.delivery-backups-again",
|
||||
"seat:mesh-controller.delivery-walks",
|
||||
"seat:mesh-controller.delivery-check",
|
||||
"seat:mesh-controller.queue",
|
||||
"seat:mesh-controller.plans",
|
||||
"gitea.gitea_commit_statuses",
|
||||
"gitea.gitea_contains",
|
||||
|
||||
@@ -34,7 +34,7 @@ what it refuses.
|
||||
folders until the import, and hq ADR 0284): a status is one of `open`,
|
||||
`diagnosing`, `located`, `fixing`, `resolved`, `wontfix`; `diagnosing` and `fixing` need `by`; `located` needs
|
||||
`located-in`; `resolved` is reached only from `fixing` and needs `located-in` and `fixed-by`; a core issue (located in the controller, the node-engine, the tool runner,
|
||||
the SDK, or the catalogue's bus, forge or build agent) resolves with `replay` or `replay-none`; `wontfix`
|
||||
the SDK, the delivery parts, or the catalogue's bus, forge or build modules) resolves with `replay` or `replay-none`; `wontfix`
|
||||
needs a `reason`. A refused change changes nothing and says which rule refused it.
|
||||
|
||||
**Who works on it** (hq ADR 0284). A move to `diagnosing` or `fixing` names in `by` the issue's **worker**: who
|
||||
|
||||
@@ -174,9 +174,10 @@ func (o Owners) locatedIn(e string) (string, error) {
|
||||
}
|
||||
|
||||
// core is an owner whose issues are core issues (hq cycle.py, to-be 45): the controller, the node-engine,
|
||||
// the tool runner and the console, the SDK, and the catalogue's bus, forge and build agent.
|
||||
var core = regexp.MustCompile(`\b(mesh-controller|mesh-host|mesh-tools|mesh-sdk|node-tools|` +
|
||||
`mesh-catalog\s+modules/(nats|gitea|build-agent))\b`)
|
||||
// the tool runner and the console, the SDK, the delivery parts, and the catalogue's bus, forge and build modules
|
||||
// (issue 1041; ADR 0524 §5).
|
||||
var core = regexp.MustCompile(`\b(mesh-controller|mesh-host|mesh-tools|mesh-sdk|node-tools|mesh-delivery|` +
|
||||
`mesh-build-(go|node|image)|mesh-catalog\s+modules/(nats|gitea))\b`)
|
||||
|
||||
// IsCore says whether an issue located in these owners is a core issue (ADR 0237).
|
||||
func IsCore(locatedIn []string) bool {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Issue 1041 (R4g, E3): the build seat's modules and the delivery parts are core, and build-agent is no owner any more.
|
||||
func TestIsCoreNamesTheBuildModulesAndTheDeliveryParts(t *testing.T) {
|
||||
for _, e := range []string{
|
||||
"mesh-catalog modules/mesh-build-go", "mesh-catalog modules/mesh-build-node", "mesh-catalog modules/mesh-build-image",
|
||||
"mesh-catalog modules/mesh-build-go/cmd/mesh-build-go",
|
||||
"mesh-delivery", "mesh-delivery modules/mesh-delivery-checker", "mesh-catalog modules/mesh-delivery-gate",
|
||||
"mesh-catalog modules/nats", "mesh-catalog modules/gitea", "mesh-controller",
|
||||
} {
|
||||
if !IsCore([]string{e}) {
|
||||
t.Errorf("an issue located in %q is not core", e)
|
||||
}
|
||||
}
|
||||
for _, e := range []string{"mesh-catalog modules/records", "hq", "mesh-catalog modules/mesh-issues", "mesh-catalog modules/mesh-build-extra"} {
|
||||
if IsCore([]string{e}) {
|
||||
t.Errorf("an issue located in %q is core", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoOwnerNamesBuildAgent(t *testing.T) {
|
||||
if IsCore([]string{"mesh-catalog modules/build-agent"}) {
|
||||
t.Error("mesh-catalog modules/build-agent is core: build-agent is gone")
|
||||
}
|
||||
tmpl, err := os.ReadFile(filepath.Join("..", "..", "files", "settings.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var s Settings
|
||||
if err := json.Unmarshal(tmpl, &s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, o := range s.Owners {
|
||||
if strings.Contains(o, "build-agent") {
|
||||
t.Errorf("the owner %q is registered: build-agent is gone (ADR 0524 §5)", o)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -547,7 +547,7 @@ func cycleRules(is *Issue, from, to string) error {
|
||||
}
|
||||
if IsCore(is.LocatedIn) && is.Replay == "" && is.ReplayNone == "" {
|
||||
return fmt.Errorf("issue %d is a core issue (it is located in the controller, the node-engine, the tool "+
|
||||
"runner, the SDK, or the catalogue's bus, forge or build agent): it resolves with replay, the replay's id "+
|
||||
"runner, the SDK, the delivery parts, or the catalogue's bus, forge or build modules): it resolves with replay, the replay's id "+
|
||||
"in mesh-lab's replays register, or replay-none, why no replay is possible (ADR 0237)", is.Number)
|
||||
}
|
||||
case StatusWontfix:
|
||||
|
||||
@@ -142,7 +142,6 @@
|
||||
"mesh-catalog modules/zsh-autosuggestions",
|
||||
"mesh-catalog modules/zsh-syntax-highlighting",
|
||||
"mesh-controller",
|
||||
"mesh-controller modules/build-agent",
|
||||
"mesh-controller modules/route-proxy",
|
||||
"mesh-delivery",
|
||||
"mesh-delivery modules/mesh-delivery-builder",
|
||||
|
||||
@@ -235,7 +235,6 @@
|
||||
"mesh-catalog modules/zsh-autosuggestions",
|
||||
"mesh-catalog modules/zsh-syntax-highlighting",
|
||||
"mesh-controller",
|
||||
"mesh-controller modules/build-agent",
|
||||
"mesh-controller modules/route-proxy",
|
||||
"mesh-delivery",
|
||||
"mesh-delivery modules/mesh-delivery-builder",
|
||||
|
||||
Reference in New Issue
Block a user