Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
831f34e59a | ||
|
|
86be014907 | ||
|
|
06358f94bd |
@@ -10,9 +10,6 @@
|
|||||||
// (`mesh-controller.<name>`, `seat:mesh-controller.<verb>`, `mesh-controller_<bucket>`), anywhere in the manifest;
|
// (`mesh-controller.<name>`, `seat:mesh-controller.<verb>`, `mesh-controller_<bucket>`), anywhere in the manifest;
|
||||||
// - a manifest string that is the bare module name `mesh-controller` outside the module's own `config`, where a value
|
// - a manifest string that is the bare module name `mesh-controller` outside the module's own `config`, where a value
|
||||||
// is the module's data (an issue owner naming the controller's repository is no dependency), not an address;
|
// is the module's data (an issue owner naming the controller's repository is no dependency), not an address;
|
||||||
// - a go.mod of a module or of a check requiring the controller's repository (its module path, github.com/novox/mesh-controller,
|
|
||||||
// or the forge's git.novox.be/novox/mesh-controller): the repository is archived once its last module is delivered
|
|
||||||
// elsewhere (novox/hq ADR 0593 §11), so nothing here may be built from it;
|
|
||||||
// - a line of any module's file naming one of the controller's buckets (`mesh-controller_`): its buckets are removed
|
// - a line of any module's file naming one of the controller's buckets (`mesh-controller_`): its buckets are removed
|
||||||
// after the controller's end (ADR 0590 §6), so a reader of one reads nothing. A test (`_test.go`, `.test.ts`) reads
|
// after the controller's end (ADR 0590 §6), so a reader of one reads nothing. A test (`_test.go`, `.test.ts`) reads
|
||||||
// no live bucket, its names are its own data, and a `testdata` directory is a copy of text from elsewhere (a design,
|
// no live bucket, its names are its own data, and a `testdata` directory is a copy of text from elsewhere (a design,
|
||||||
@@ -43,9 +40,6 @@ const (
|
|||||||
// address is a string naming the controller's seat, one of its states, verbs or events, or one of its buckets.
|
// address is a string naming the controller's seat, one of its states, verbs or events, or one of its buckets.
|
||||||
var address = regexp.MustCompile(`(^|[^A-Za-z0-9_-])(seat:)?mesh-controller([._][A-Za-z0-9])`)
|
var address = regexp.MustCompile(`(^|[^A-Za-z0-9_-])(seat:)?mesh-controller([._][A-Za-z0-9])`)
|
||||||
|
|
||||||
// repository is a go.mod line naming the controller's repository by either of its module paths.
|
|
||||||
var repository = regexp.MustCompile(`(github\.com|git\.novox\.be)/novox/mesh-controller(/|\s|$)`)
|
|
||||||
|
|
||||||
// Refusals is each reason the check gives over the tree at root, sorted; none when it passes.
|
// Refusals is each reason the check gives over the tree at root, sorted; none when it passes.
|
||||||
func Refusals(root string) ([]string, error) {
|
func Refusals(root string) ([]string, error) {
|
||||||
var out []string
|
var out []string
|
||||||
@@ -71,26 +65,6 @@ func Refusals(root string) ([]string, error) {
|
|||||||
"consumes, waits on or uses it; name the seat that holds the job now (novox/hq ADR 0590 §5)", module, s))
|
"consumes, waits on or uses it; name the seat that holds the job now (novox/hq ADR 0590 §5)", module, s))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mods, err := filepath.Glob(filepath.Join(root, "*", "*", "go.mod"))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
sort.Strings(mods)
|
|
||||||
for _, f := range mods {
|
|
||||||
if rel, _ := filepath.Rel(root, f); !strings.HasPrefix(rel, "modules"+string(filepath.Separator)) && !strings.HasPrefix(rel, "checks"+string(filepath.Separator)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
raw, err := os.ReadFile(f)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for n, l := range strings.Split(string(raw), "\n") {
|
|
||||||
if repository.MatchString(l) {
|
|
||||||
rel, _ := filepath.Rel(root, f)
|
|
||||||
out = append(out, fmt.Sprintf("%s:%d: requires the controller's repository: it is archived once its last module is built elsewhere, so nothing here is built from it (novox/hq ADR 0593 §11)", filepath.ToSlash(rel), n+1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
buckets, err := bucketLines(root)
|
buckets, err := bucketLines(root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -33,9 +33,6 @@ func TestRefusalsJudgeATree(t *testing.T) {
|
|||||||
{"a requirement", map[string]string{"modules/d/module.json": `{"module":"d","requires":[{"name":"x","from":"mesh-controller"}]}`}, `d: names "mesh-controller"`},
|
{"a requirement", map[string]string{"modules/d/module.json": `{"module":"d","requires":[{"name":"x","from":"mesh-controller"}]}`}, `d: names "mesh-controller"`},
|
||||||
{"the controller's own module", map[string]string{"modules/mesh-controller/module.json": `{"module":"mesh-controller"}`}, "mesh-controller: the controller is no module of the catalogue"},
|
{"the controller's own module", map[string]string{"modules/mesh-controller/module.json": `{"module":"mesh-controller"}`}, "mesh-controller: the controller is no module of the catalogue"},
|
||||||
{"a bucket in code", map[string]string{"modules/e/cmd/e/main.go": "package main\nconst b = \"mesh-controller_assignments\"\n"}, "modules/e/cmd/e/main.go:2: names the controller's bucket"},
|
{"a bucket in code", map[string]string{"modules/e/cmd/e/main.go": "package main\nconst b = \"mesh-controller_assignments\"\n"}, "modules/e/cmd/e/main.go:2: names the controller's bucket"},
|
||||||
{"a requirement of the controller's repository", map[string]string{"modules/h/go.mod": "module h\n\nrequire github.com/novox/mesh-controller v0.0.0\n"}, "modules/h/go.mod:3: requires the controller's repository"},
|
|
||||||
{"its forge path in a check", map[string]string{"checks/i/go.mod": "module i\n\nrequire (\n\tgit.novox.be/novox/mesh-controller/modules/route-proxy v0.1.0\n)\n"}, "checks/i/go.mod:4"},
|
|
||||||
{"another repository of the forge passes", map[string]string{"modules/j/go.mod": "module j\n\nrequire git.novox.be/novox/mesh-sdk/go v0.1.0\n"}, ""},
|
|
||||||
{"a bucket in a test is its data", map[string]string{"modules/e/cmd/e/main_test.go": "package main\n// mesh-controller_lease\n"}, ""},
|
{"a bucket in a test is its data", map[string]string{"modules/e/cmd/e/main_test.go": "package main\n// mesh-controller_lease\n"}, ""},
|
||||||
{"a fixture copied from elsewhere", map[string]string{"modules/e/cmd/e/testdata/x.md": "mesh-controller_rollouts\n"}, ""},
|
{"a fixture copied from elsewhere", map[string]string{"modules/e/cmd/e/testdata/x.md": "mesh-controller_rollouts\n"}, ""},
|
||||||
{"an owner and prose pass", map[string]string{"modules/f/module.json": `{"module":"f","config":{"owners":{"default":["mesh-controller","mesh-controller modules/route-proxy"]}},"why":"the mesh-controller module is gone"}`}, ""},
|
{"an owner and prose pass", map[string]string{"modules/f/module.json": `{"module":"f","config":{"owners":{"default":["mesh-controller","mesh-controller modules/route-proxy"]}},"why":"the mesh-controller module is gone"}`}, ""},
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ module checks/credentialform
|
|||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
|
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||||
|
|||||||
+2
-13
@@ -111,24 +111,13 @@ func forgeRequirements(dir string) ([]requirement, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("its go.mod cannot be read: %s", lastLine(out))
|
return nil, fmt.Errorf("its go.mod cannot be read: %s", lastLine(out))
|
||||||
}
|
}
|
||||||
var mod struct {
|
var mod struct{ Require []requirement }
|
||||||
Require []requirement
|
|
||||||
Replace []struct{ Old, New requirement }
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(out, &mod); err != nil {
|
if err := json.Unmarshal(out, &mod); err != nil {
|
||||||
return nil, fmt.Errorf("its go.mod cannot be read: %w", err)
|
return nil, fmt.Errorf("its go.mod cannot be read: %w", err)
|
||||||
}
|
}
|
||||||
// A requirement the go.mod replaces to a directory is a package of the module's own repository (novox/hq ADR 0593 §1): it
|
|
||||||
// is in the tree under check, so it is fetched from nowhere.
|
|
||||||
local := map[string]bool{}
|
|
||||||
for _, r := range mod.Replace {
|
|
||||||
if r.New.Version == "" {
|
|
||||||
local[r.Old.Path] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var on []requirement
|
var on []requirement
|
||||||
for _, r := range mod.Require {
|
for _, r := range mod.Require {
|
||||||
if strings.HasPrefix(r.Path, forge) && !local[r.Path] {
|
if strings.HasPrefix(r.Path, forge) {
|
||||||
on = append(on, r)
|
on = append(on, r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,28 +175,3 @@ func TestTheRefOfAVersion(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ADR 0593 §1: a module requires a sibling package of its own repository and replaces it to the directory beside it; that
|
|
||||||
// package is no fetch from the forge (it is in the tree under check), so it is never looked for in a clone beside the check,
|
|
||||||
// and the module builds with the SDK taken from there.
|
|
||||||
func TestASiblingPackageReplacedToItsDirectoryIsNotFetched(t *testing.T) {
|
|
||||||
offline(t)
|
|
||||||
beside, commit := sdkBeside(t)
|
|
||||||
version := "v0.1.14-0.20261012082113-" + commit[:12]
|
|
||||||
root := t.TempDir()
|
|
||||||
sibling := "git.novox.be/novox/mesh-catalog/modules/builder/runner"
|
|
||||||
write(t, filepath.Join(root, "builder", "runner", "go.mod"), "module "+sibling+"\n\ngo 1.22\n\nrequire git.novox.be/novox/mesh-sdk/go "+version+"\n")
|
|
||||||
write(t, filepath.Join(root, "builder", "runner", "run.go"), "package runner\n\nimport \"git.novox.be/novox/mesh-sdk/go/sdk\"\n\nfunc Run() string { return sdk.Hello() }\n")
|
|
||||||
dir := filepath.Join(root, "user")
|
|
||||||
write(t, filepath.Join(dir, "go.mod"), "module git.novox.be/novox/mesh-catalog/modules/user\n\ngo 1.22\n\nrequire (\n\t"+sibling+" v0.0.0\n"+
|
|
||||||
"\tgit.novox.be/novox/mesh-sdk/go "+version+"\n)\n\nreplace "+sibling+" => ../builder/runner\n")
|
|
||||||
write(t, filepath.Join(dir, "main.go"), "package main\n\nimport \""+sibling+"\"\n\nfunc main() { println(runner.Run()) }\n")
|
|
||||||
modfile, err := resolve(dir, beside, t.TempDir())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("resolve: %v", err)
|
|
||||||
}
|
|
||||||
if modfile == "" {
|
|
||||||
t.Fatal("resolve said the module fetches its dependencies, with nothing to fetch the SDK from")
|
|
||||||
}
|
|
||||||
builds(t, dir, modfile)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Package failurekit refuses a build module whose tests do not run the build runner's conformance kit (issue 1901, its thorough
|
// Package failurekit refuses a build module whose tests do not run mesh-sdk's conformance kit (issue 1901, its thorough
|
||||||
// fix): a module that claims a node-build-* seat and serves a test step must hold a test that calls conformance.Run, so
|
// fix): a module that claims a node-build-* seat and serves a test step must hold a test that calls conformance.Run, so
|
||||||
// its parse of the test runner's output is held to the failure contract (a failing test named with its cause, never a
|
// its parse of the test runner's output is held to the failure contract (a failing test named with its cause, never a
|
||||||
// passing line in its place). A build module with no test step (mesh-build-image builds images and runs no tests) is
|
// passing line in its place). A build module with no test step (mesh-build-image builds images and runs no tests) is
|
||||||
@@ -71,7 +71,7 @@ func Judge(root string) ([]Judged, error) {
|
|||||||
}
|
}
|
||||||
if !runs {
|
if !runs {
|
||||||
refused = append(refused, fmt.Sprintf("it claims %s and serves the test step %s, and no test of it calls conformance.Run "+
|
refused = append(refused, fmt.Sprintf("it claims %s and serves the test step %s, and no test of it calls conformance.Run "+
|
||||||
"(mesh-build-go buildstep/conformance): run the kit with the module's own parse path (issue 1901)", seat, test))
|
"(mesh-sdk go/buildstep/conformance): run the kit with the module's own parse path (issue 1901)", seat, test))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Every build module keeps steps, so every one runs the left-running case, test step or not (issue 1890).
|
// Every build module keeps steps, so every one runs the left-running case, test step or not (issue 1890).
|
||||||
@@ -81,7 +81,7 @@ func Judge(root string) ([]Judged, error) {
|
|||||||
}
|
}
|
||||||
if !sweeps {
|
if !sweeps {
|
||||||
refused = append(refused, fmt.Sprintf("it claims %s, and no test of it calls conformance.LeftRunning "+
|
refused = append(refused, fmt.Sprintf("it claims %s, and no test of it calls conformance.LeftRunning "+
|
||||||
"(mesh-build-go buildstep/conformance): run the kit's left-running case with the module's own start sweep (issue 1890)", seat))
|
"(mesh-sdk go/buildstep/conformance): run the kit's left-running case with the module's own start sweep (issue 1890)", seat))
|
||||||
}
|
}
|
||||||
j.Refused = strings.Join(refused, "; ")
|
j.Refused = strings.Join(refused, "; ")
|
||||||
out = append(out, j)
|
out = append(out, j)
|
||||||
@@ -121,7 +121,7 @@ func runsTheKit(dir, call string) (bool, error) {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
found = strings.Contains(string(raw), call) &&
|
found = strings.Contains(string(raw), call) &&
|
||||||
strings.Contains(string(raw), `"git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/conformance"`)
|
strings.Contains(string(raw), `"git.novox.be/novox/mesh-sdk/go/buildstep/conformance"`)
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
return found, err
|
return found, err
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
// cases is refused, and a module that claims no node-build-* seat is not judged.
|
// cases is refused, and a module that claims no node-build-* seat is not judged.
|
||||||
func TestABuildModuleWithATestStepMustRunTheKit(t *testing.T) {
|
func TestABuildModuleWithATestStepMustRunTheKit(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
const imp = "package main\n\nimport \"git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/conformance\"\n\n"
|
const imp = "package main\n\nimport \"git.novox.be/novox/mesh-sdk/go/buildstep/conformance\"\n\n"
|
||||||
const sweep = "func TestSweep(t *testing.T) { conformance.LeftRunning(t, nil) }\n"
|
const sweep = "func TestSweep(t *testing.T) { conformance.LeftRunning(t, nil) }\n"
|
||||||
kit := imp + "func TestKit(t *testing.T) { conformance.Run(t, conformance.GoTest, nil) }\n" + sweep
|
kit := imp + "func TestKit(t *testing.T) { conformance.Run(t, conformance.GoTest, nil) }\n" + sweep
|
||||||
for path, body := range map[string]string{
|
for path, body := range map[string]string{
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ module checks/health
|
|||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
|
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ module checks/manifests
|
|||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
|
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// The tree's manifests are ones a judge can read, and no module is named twice. A row is a catalogue of its own: a
|
// The tree's manifests are ones a judge can read, and no module is named twice. A row is a catalogue of its own: a
|
||||||
@@ -50,45 +48,3 @@ func TestEveryManifestDecodesAndNamesOneModule(t *testing.T) {
|
|||||||
t.Fatalf("the catalogue holds manifests no judge can read:\n%s", strings.Join(real, "\n"))
|
t.Fatalf("the catalogue holds manifests no judge can read:\n%s", strings.Join(real, "\n"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No manifest of this catalogue claims the seat mesh-catalog: the indexer of the delivery holds it, and the module that
|
|
||||||
// held it before has dropped its claim (ADR 0604 §6). A row is a tree of its own: a manifest claiming it is named.
|
|
||||||
func TestNoManifestClaimsTheSeatMeshCatalog(t *testing.T) {
|
|
||||||
claimants := func(root string) []string {
|
|
||||||
files, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var out []string
|
|
||||||
for _, f := range files {
|
|
||||||
raw, err := os.ReadFile(f)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
m, err := catalogue.Decode(raw)
|
|
||||||
if err != nil {
|
|
||||||
continue // Problems names it
|
|
||||||
}
|
|
||||||
for _, c := range m.Claims {
|
|
||||||
if c.Name == "mesh-catalog" {
|
|
||||||
out = append(out, m.Module)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
row := t.TempDir()
|
|
||||||
if err := os.MkdirAll(filepath.Join(row, "modules", "a"), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(filepath.Join(row, "modules", "a", "module.json"),
|
|
||||||
[]byte(`{"module":"a","version":"1","claims":[{"name":"mesh-catalog","scope":"mesh"}]}`), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got := claimants(row); len(got) != 1 || got[0] != "a" {
|
|
||||||
t.Fatalf("a manifest claiming the seat is named, got %v", got)
|
|
||||||
}
|
|
||||||
if got := claimants(filepath.Join("..", "..")); len(got) != 0 {
|
|
||||||
t.Errorf("the catalogue's manifests %v claim the seat mesh-catalog: the indexer of the delivery holds it", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ module checks/readable
|
|||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
|
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
module git.novox.be/novox/mesh-catalog/checks/removedpackages
|
|
||||||
|
|
||||||
go 1.22
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
// Command removedpackages refuses a Go source of the catalogue that imports what mesh-sdk gave up in the release that
|
|
||||||
// narrowed it (novox/hq ADR 0593 §1): a package that left for its owner, and the build runner's names that left
|
|
||||||
// mesh-sdk's buildstep for mesh-build-go's package.
|
|
||||||
//
|
|
||||||
// A module compiles against the mesh-sdk version it pins, so a source that still names a removed package builds until the
|
|
||||||
// pin moves, and then breaks in a delivery of its own; and a later change could bring the import back with the old pin.
|
|
||||||
// This check refuses it at once, from the source alone:
|
|
||||||
//
|
|
||||||
// - an import of a package in Removed (or one under it);
|
|
||||||
// - in a file importing mesh-sdk's buildstep (under any name), a selector naming anything that is not in
|
|
||||||
// BuildstepContract: what is left of that package is what the delivery parts read, and the runner is
|
|
||||||
// mesh-build-go's package (git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep).
|
|
||||||
//
|
|
||||||
// It reads the non-test and test Go files under modules/ and checks/ (testdata, vendor and node_modules left out).
|
|
||||||
//
|
|
||||||
// go run . <catalogue root>
|
|
||||||
//
|
|
||||||
// Exits 1 on a finding. The lists are the release's: a package that goes later is added here with the change.
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"go/ast"
|
|
||||||
"go/parser"
|
|
||||||
"go/token"
|
|
||||||
"io/fs"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
const sdk = "git.novox.be/novox/mesh-sdk/go"
|
|
||||||
|
|
||||||
// Removed are the packages of mesh-sdk that left it, each with where its logic is now.
|
|
||||||
var Removed = map[string]string{
|
|
||||||
"buildcpu": "the build runner's package, git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/buildcpu",
|
|
||||||
"buildstep/conformance": "the build runner's package, git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/conformance",
|
|
||||||
"delivery/buildrun": "the builder's package (mesh-delivery-builder), imported by its repository's modules",
|
|
||||||
"compose": "the node-engine, which alone composes (ADR 0593 §6)",
|
|
||||||
"compose/frombus": "the node-engine, which alone composes (ADR 0593 §6)",
|
|
||||||
"compose/fromruntime": "the node-engine, which alone composes (ADR 0593 §6)",
|
|
||||||
"compose/judge": "the assigner's own package",
|
|
||||||
"takein": "the node-engine, which alone composes (ADR 0593 §6)",
|
|
||||||
"validate": "the node-engine, which alone composes (ADR 0593 §6)",
|
|
||||||
"facts": "nothing: the controller they served is ended (ADR 0590)",
|
|
||||||
"example": "nothing: no module imports it (ADR 0593 §12)",
|
|
||||||
"packageguard": "nothing: a test helper of mesh-sdk's own",
|
|
||||||
}
|
|
||||||
|
|
||||||
// BuildstepContract are the names mesh-sdk's buildstep keeps: the failure contract, the fetch request, the stale-step sweep,
|
|
||||||
// the step id and the module tree shape the delivery parts read.
|
|
||||||
var BuildstepContract = map[string]bool{
|
|
||||||
"AskWhat": true, "Boot": true, "By": true, "CapacitySetting": true, "Clip": true, "ConsoleFailure": true, "CutLine": true,
|
|
||||||
"EndOne": true, "EndStale": true, "EndStaleAtStart": true, "EndStaleRound": true, "Failure": true, "FetchRequest": true,
|
|
||||||
"FetchRequestProperties": true, "Hidden": true, "Launchers": true, "LauncherSuffix": true, "LineBytes": true,
|
|
||||||
"ModuleTree": true, "NewID": true, "OutputBytes": true, "Owner": true, "PackagesArg": true, "ParseFetchRequest": true,
|
|
||||||
"PassingLine": true, "RestartReason": true, "Scan": true, "ScanOf": true, "StaleAttempts": true, "StaleRetry": true,
|
|
||||||
"StaleServed": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Findings is every refusal in the Go sources under root, sorted.
|
|
||||||
func Findings(root string) ([]string, error) {
|
|
||||||
var out []string
|
|
||||||
for _, top := range []string{"modules", "checks"} {
|
|
||||||
err := filepath.WalkDir(filepath.Join(root, top), func(path string, d fs.DirEntry, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if d.IsDir() {
|
|
||||||
switch d.Name() {
|
|
||||||
case "testdata", "vendor", "node_modules", ".mesh-build":
|
|
||||||
return filepath.SkipDir
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if !strings.HasSuffix(path, ".go") {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
rel, _ := filepath.Rel(root, path)
|
|
||||||
found, err := fileFindings(path, filepath.ToSlash(rel))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
out = append(out, found...)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func fileFindings(path, rel string) ([]string, error) {
|
|
||||||
file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.SkipObjectResolution)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%s: %w", rel, err)
|
|
||||||
}
|
|
||||||
var out []string
|
|
||||||
buildstep := ""
|
|
||||||
for _, imp := range file.Imports {
|
|
||||||
p, err := strconv.Unquote(imp.Path.Value)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
sub, ok := strings.CutPrefix(p, sdk+"/")
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for pkg, to := range Removed {
|
|
||||||
if sub == pkg || strings.HasPrefix(sub, pkg+"/") {
|
|
||||||
out = append(out, fmt.Sprintf("%s: imports %s, which left mesh-sdk; it is %s (novox/hq ADR 0593 §1)", rel, p, to))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if sub == "buildstep" {
|
|
||||||
buildstep = "buildstep"
|
|
||||||
if imp.Name != nil {
|
|
||||||
buildstep = imp.Name.Name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if buildstep == "" || buildstep == "_" || buildstep == "." {
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
seen := map[string]bool{}
|
|
||||||
ast.Inspect(file, func(n ast.Node) bool {
|
|
||||||
sel, ok := n.(*ast.SelectorExpr)
|
|
||||||
if !ok {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
id, ok := sel.X.(*ast.Ident)
|
|
||||||
if !ok || id.Name != buildstep || !sel.Sel.IsExported() || BuildstepContract[sel.Sel.Name] || seen[sel.Sel.Name] {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
seen[sel.Sel.Name] = true
|
|
||||||
out = append(out, fmt.Sprintf("%s: uses %s.%s, which is the build runner's, no longer mesh-sdk's buildstep; import git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep (novox/hq ADR 0593 §1)", rel, buildstep, sel.Sel.Name))
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
if len(os.Args) != 2 {
|
|
||||||
fmt.Fprintln(os.Stderr, "usage: removedpackages <catalogue root>")
|
|
||||||
os.Exit(2)
|
|
||||||
}
|
|
||||||
found, err := Findings(os.Args[1])
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
os.Exit(2)
|
|
||||||
}
|
|
||||||
for _, f := range found {
|
|
||||||
fmt.Println(f)
|
|
||||||
}
|
|
||||||
if len(found) > 0 {
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
fmt.Println("no source imports a package mesh-sdk gave up")
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ADR 0593 §1: a source that imports a package mesh-sdk gave up, or names the build runner through mesh-sdk's buildstep, is
|
|
||||||
// refused naming where it went; the contract that stays, the runner's own package and a testdata copy are not.
|
|
||||||
func TestFindingsRefuseWhatMeshSDKGaveUp(t *testing.T) {
|
|
||||||
const head = "package x\n\nimport (\n"
|
|
||||||
for _, row := range []struct {
|
|
||||||
name, file, src string
|
|
||||||
want string // empty: clean
|
|
||||||
}{
|
|
||||||
{"clean", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/delivery\"\n)\n\nvar _ = delivery.StepsState\n", ""},
|
|
||||||
{"a removed package", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/compose\"\n)\n\nvar _ = compose.Resolve\n", "imports git.novox.be/novox/mesh-sdk/go/compose, which left mesh-sdk; it is the node-engine"},
|
|
||||||
{"under a removed package", "modules/a/x_test.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/compose/frombus\"\n)\n", "the node-engine"},
|
|
||||||
{"buildcpu", "checks/c/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/buildcpu\"\n)\n", "buildstep/buildcpu"},
|
|
||||||
{"the runner through mesh-sdk's buildstep", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/buildstep\"\n)\n\nvar _ = buildstep.RunAsk\n", "uses buildstep.RunAsk, which is the build runner's"},
|
|
||||||
{"the runner through an alias", "modules/a/x.go", head + "\tsdkstep \"git.novox.be/novox/mesh-sdk/go/buildstep\"\n)\n\nvar _ = sdkstep.Puller{}\n", "uses sdkstep.Puller"},
|
|
||||||
{"the contract of buildstep", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/buildstep\"\n)\n\nvar _ = buildstep.NewID\nvar _ buildstep.Failure\n", ""},
|
|
||||||
{"the runner's own package", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep\"\n)\n\nvar _ = buildstep.RunAsk\n", ""},
|
|
||||||
{"a copy in testdata", "modules/a/testdata/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/compose\"\n)\n", ""},
|
|
||||||
} {
|
|
||||||
t.Run(row.name, func(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
p := filepath.Join(root, row.file)
|
|
||||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(p, []byte(row.src), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
got, err := Findings(root)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if all := strings.Join(got, "\n"); (row.want == "") != (all == "") || !strings.Contains(all, row.want) {
|
|
||||||
t.Fatalf("findings = %q, want one containing %q", all, row.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The script's run of this check over the real tree (novox/hq ADR 0529 clause 2).
|
|
||||||
func TestTheCatalogueImportsNothingMeshSDKGaveUp(t *testing.T) {
|
|
||||||
got, err := Findings(filepath.Join("..", ".."))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, f := range got {
|
|
||||||
t.Error(f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
# What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
|
|
||||||
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
|
|
||||||
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
|
|
||||||
# every Go source of the modules and of the checks, whose imports are read.
|
|
||||||
modules
|
|
||||||
checks
|
|
||||||
@@ -36,7 +36,13 @@ import (
|
|||||||
// composition is every function or table the check lets stay in mesh-sdk, as "path-from-go/:name", and why. It names no
|
// composition is every function or table the check lets stay in mesh-sdk, as "path-from-go/:name", and why. It names no
|
||||||
// manifest judgement: each is the composer's refusal of composition itself (ADR 0578 clause 5).
|
// manifest judgement: each is the composer's refusal of composition itself (ADR 0578 clause 5).
|
||||||
var composition = map[string]string{
|
var composition = map[string]string{
|
||||||
|
"catalogue/consumers.go:consumersPlaceholderProblems": "composition: rendering a consumer's template",
|
||||||
|
"catalogue/collections.go:collectedPlaceholderProblems": "composition: placeholders a contribution renders",
|
||||||
|
"catalogue/environment_into.go:placeholderProblems": "composition: placeholders a contribution renders",
|
||||||
|
"catalogue/seat_contributions.go:seatPlaceholderProblems": "composition: placeholders a contribution renders",
|
||||||
"catalogue/collections.go:itemTemplateProblem": "composition: filling an item's template",
|
"catalogue/collections.go:itemTemplateProblem": "composition: filling an item's template",
|
||||||
|
"catalogue/setting_takes.go:takenSettingsProblems": "composition: whether stored settings still compose",
|
||||||
|
"catalogue/template.go:HeldTemplateProblem": "composition: the take-in refusal of a resolved build",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Problems is what mesh-sdk's Go tree under root (its go/ directory) holds of a manifest judge, one line each, but for
|
// Problems is what mesh-sdk's Go tree under root (its go/ directory) holds of a manifest judge, one line each, but for
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ module adwaita
|
|||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
|
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/klauspost/compress v1.20.0 // indirect
|
github.com/klauspost/compress v1.20.0 // indirect
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ record names leaves its platform manifests unrecorded with it.
|
|||||||
**What the definitions pin** (hq issue 323). A definition may pin an image of the store the mesh did not
|
**What the definitions pin** (hq issue 323). A definition may pin an image of the store the mesh did not
|
||||||
make: pushed before the mesh built it, and pinned under the store's current name or a name it had before.
|
make: pushed before the mesh built it, and pinned under the store's current name or a name it had before.
|
||||||
No record of the controller names it, yet a machine runs it, so `artifact_store_references` reads every
|
No record of the controller names it, yet a machine runs it, so `artifact_store_references` reads every
|
||||||
definition the catalogue holds (the seat mesh-catalog's verbs `modules`, then `module` for each, eight at
|
definition the catalogue holds (`mesh-catalog.catalog_modules`, then `catalog_module` for each, eight at
|
||||||
a time and within twenty seconds) and finds every `<name>/<repository>@sha256:…` in any of its strings,
|
a time and within twenty seconds) and finds every `<name>/<repository>@sha256:…` in any of its strings,
|
||||||
under each of the store's names: `artifact-store://`, the store's address as the controller answers it,
|
under each of the store's names: `artifact-store://`, the store's address as the controller answers it,
|
||||||
and the names it had before. Those are the mesh's setting `older-store-names` (space-separated, `none` by
|
and the names it had before. Those are the mesh's setting `older-store-names` (space-separated, `none` by
|
||||||
|
|||||||
@@ -23,11 +23,10 @@ import (
|
|||||||
// manifest's.
|
// manifest's.
|
||||||
const NamesFileEnv = "MESH_ARTIFACT_STORE_NAMES_FILE"
|
const NamesFileEnv = "MESH_ARTIFACT_STORE_NAMES_FILE"
|
||||||
|
|
||||||
// CatalogueModules and CatalogueModule are the verbs of the seat mesh-catalog these definitions are read through
|
// CatalogueModules and CatalogueModule are the catalog's tools these definitions are read through.
|
||||||
// (ADR 0604 §3); the answers are catalogue.CatalogModules and catalogue.CatalogOne.
|
|
||||||
const (
|
const (
|
||||||
CatalogueModules = "seat:mesh-catalog.modules"
|
CatalogueModules = "mesh-catalog.catalog_modules"
|
||||||
CatalogueModule = "seat:mesh-catalog.module"
|
CatalogueModule = "mesh-catalog.catalog_module"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefinitionsTimeout bounds reading every definition, below the runtime's thirty-second call limit; what
|
// DefinitionsTimeout bounds reading every definition, below the runtime's thirty-second call limit; what
|
||||||
@@ -280,8 +279,11 @@ func (c *Catalogue) definition(m catalogueEntry, names StoreNames, nameKey strin
|
|||||||
return pins, ""
|
return pins, ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The verb takes no commit: it answers the trunk's module, and the answer names the commit it is at.
|
args := map[string]any{"module": m.Module}
|
||||||
raw, err := c.Ask(CatalogueModule, map[string]any{"module": m.Module})
|
if m.Commit != "" {
|
||||||
|
args["commit"] = m.Commit
|
||||||
|
}
|
||||||
|
raw, err := c.Ask(CatalogueModule, args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "could not be asked: " + err.Error()
|
return nil, "could not be asked: " + err.Error()
|
||||||
}
|
}
|
||||||
@@ -291,7 +293,6 @@ func (c *Catalogue) definition(m catalogueEntry, names StoreNames, nameKey strin
|
|||||||
}
|
}
|
||||||
var one struct {
|
var one struct {
|
||||||
Module *struct {
|
Module *struct {
|
||||||
Commit string `json:"commit"`
|
|
||||||
Manifest json.RawMessage `json:"manifest"`
|
Manifest json.RawMessage `json:"manifest"`
|
||||||
} `json:"module"`
|
} `json:"module"`
|
||||||
Error string `json:"error"`
|
Error string `json:"error"`
|
||||||
@@ -300,11 +301,9 @@ func (c *Catalogue) definition(m catalogueEntry, names StoreNames, nameKey strin
|
|||||||
return nil, "not readable: " + firstOf(one.Error, errText(err), "no manifest in the answer")
|
return nil, "not readable: " + firstOf(one.Error, errText(err), "no manifest in the answer")
|
||||||
}
|
}
|
||||||
pins := PinsIn(one.Module.Manifest, names)
|
pins := PinsIn(one.Module.Manifest, names)
|
||||||
// Kept by the commit the answer names, which may be newer than the list's: a definition and its commit never
|
if m.Commit != "" {
|
||||||
// change together.
|
|
||||||
if at := firstOf(one.Module.Commit, m.Commit); at != "" {
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
c.kept[m.Module+"@"+at+"|"+nameKey] = pins
|
c.kept[key] = pins
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
}
|
}
|
||||||
return pins, ""
|
return pins, ""
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ func TestReferencesReadTheDefinitionsUnderTheStoresOlderNames(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if before != 2 || after != 2 {
|
if before != 2 || after != 2 {
|
||||||
t.Errorf("asked the seat's module %d times, then %d: each definition at its commit is read once", before, after)
|
t.Errorf("asked catalog_module %d times, then %d: each definition at its commit is read once", before, after)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -366,8 +366,8 @@ func (a *asked) ask(key string, body any) (json.RawMessage, error) {
|
|||||||
if a.failing[name] {
|
if a.failing[name] {
|
||||||
return nil, fmt.Errorf("no answer")
|
return nil, fmt.Errorf("no answer")
|
||||||
}
|
}
|
||||||
if _, has := args["commit"]; has {
|
if args["commit"] != "c-"+name {
|
||||||
return nil, fmt.Errorf("asked %s at %v: the seat's verb takes no commit", name, args["commit"])
|
return nil, fmt.Errorf("asked %s at %v", name, args["commit"])
|
||||||
}
|
}
|
||||||
manifest, has := a.definitions[name]
|
manifest, has := a.definitions[name]
|
||||||
if !has {
|
if !has {
|
||||||
@@ -375,8 +375,7 @@ func (a *asked) ask(key string, body any) (json.RawMessage, error) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
// The catalogue's tool answers through the runtime as the protocol's content list.
|
// The catalogue's tool answers through the runtime as the protocol's content list.
|
||||||
raw, _ := json.Marshal(catalogue.CatalogOne{Module: catalogue.CatalogModule{
|
raw, _ := json.Marshal(map[string]any{"module": map[string]any{"module": name, "manifest": json.RawMessage(manifest)}})
|
||||||
CatalogEntry: catalogue.CatalogEntry{Module: name, Commit: "c-" + name}, Manifest: json.RawMessage(manifest)}})
|
|
||||||
return json.Marshal(map[string]any{"content": []map[string]any{{"type": "text", "text": string(raw)}}})
|
return json.Marshal(map[string]any{"content": []map[string]any{{"type": "text", "text": string(raw)}}})
|
||||||
case "collect":
|
case "collect":
|
||||||
if args["confirm"] == "true" {
|
if args["confirm"] == "true" {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ module artifactstoretools
|
|||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
|
||||||
github.com/nats-io/nats.go v1.54.0
|
github.com/nats-io/nats.go v1.54.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||||
|
|||||||
@@ -19,8 +19,8 @@
|
|||||||
],
|
],
|
||||||
"invokes": [
|
"invokes": [
|
||||||
"seat:mesh-hand-acts.hand-act",
|
"seat:mesh-hand-acts.hand-act",
|
||||||
"seat:mesh-catalog.modules",
|
"mesh-catalog.catalog_modules",
|
||||||
"seat:mesh-catalog.module"
|
"mesh-catalog.catalog_module"
|
||||||
],
|
],
|
||||||
"reads": [
|
"reads": [
|
||||||
"mesh-delivery-assigner.wants",
|
"mesh-delivery-assigner.wants",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ module asuszephyrusg14
|
|||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
|
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/klauspost/compress v1.20.0 // indirect
|
github.com/klauspost/compress v1.20.0 // indirect
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ module avahi
|
|||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
|
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/klauspost/compress v1.20.0 // indirect
|
github.com/klauspost/compress v1.20.0 // indirect
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ module blueman
|
|||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
|
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/klauspost/compress v1.20.0 // indirect
|
github.com/klauspost/compress v1.20.0 // indirect
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ module bluetooth
|
|||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
|
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/klauspost/compress v1.20.0 // indirect
|
github.com/klauspost/compress v1.20.0 // indirect
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ module brother
|
|||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
|
||||||
github.com/nats-io/nats-server/v2 v2.11.17
|
github.com/nats-io/nats-server/v2 v2.11.17
|
||||||
github.com/nats-io/nats.go v1.54.0
|
github.com/nats-io/nats.go v1.54.0
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ module claude-code
|
|||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
|
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
|
||||||
|
|
||||||
require gopkg.in/yaml.v3 v3.0.1
|
require gopkg.in/yaml.v3 v3.0.1
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ module claude-licence-manager
|
|||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
|
||||||
github.com/jackc/pgx/v5 v5.11.0
|
github.com/jackc/pgx/v5 v5.11.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ module clipmenu
|
|||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
|
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/klauspost/compress v1.20.0 // indirect
|
github.com/klauspost/compress v1.20.0 // indirect
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ module cups
|
|||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
|
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/klauspost/compress v1.20.0 // indirect
|
github.com/klauspost/compress v1.20.0 // indirect
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ module dbus
|
|||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
|
||||||
github.com/godbus/dbus/v5 v5.1.0
|
github.com/godbus/dbus/v5 v5.1.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ module deskchannel
|
|||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
|
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/klauspost/compress v1.20.0 // indirect
|
github.com/klauspost/compress v1.20.0 // indirect
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ All six only read.
|
|||||||
| `disk_filesystems` | Each filesystem that stores data, said once however many places it is mounted: size, used and free GiB, used % as `df` counts it, inodes used % | `df -h`, `df -i` |
|
| `disk_filesystems` | Each filesystem that stores data, said once however many places it is mounted: size, used and free GiB, used % as `df` counts it, inodes used % | `df -h`, `df -i` |
|
||||||
| `disk_devices` | Each disk without sampling: model, size, whether it spins, scheduler, queue limit, what it is built on, and since boot the GiB read and written, the busy share and the wait per read and per write | `lsblk`, `cat /proc/diskstats` |
|
| `disk_devices` | Each disk without sampling: model, size, whether it spins, scheduler, queue limit, what it is built on, and since boot the GiB read and written, the busy share and the wait per read and per write | `lsblk`, `cat /proc/diskstats` |
|
||||||
| `disk_folders` | Which folders fill a disk, like `du -x`: the largest folders under `path` (default `/`) to `depth` levels (default 2, at most 3), `limit` of them (default 20, at most 100), with bytes and GiB. Sizes are allocated bytes, a hard link once. It stays on the path's one filesystem: a mount below it is named, never entered. It reads every folder the tool runner can read (docker's included) and no content; folders it cannot read are counted and named. After 25 s (the tool runner gives a call 30 s) it answers what it has measured, marked `partial`, with `stopped_at` (novox/hq issue 1804) | `du -x`, `ncdu` |
|
| `disk_folders` | Which folders fill a disk, like `du -x`: the largest folders under `path` (default `/`) to `depth` levels (default 2, at most 3), `limit` of them (default 20, at most 100), with bytes and GiB. Sizes are allocated bytes, a hard link once. It stays on the path's one filesystem: a mount below it is named, never entered. It reads every folder the tool runner can read (docker's included) and no content; folders it cannot read are counted and named. After 25 s (the tool runner gives a call 30 s) it answers what it has measured, marked `partial`, with `stopped_at` (novox/hq issue 1804) | `du -x`, `ncdu` |
|
||||||
| `cpu_top` | Samples a window (`seconds`, default 3, at most 10). It answers the machine's CPU in use (% of all CPUs and as CPUs busy, from `/proc/stat`), then `by=unit` (the default) ranks systemd units and containers by CPU, from their cgroups' cpu.stat, busiest first (`limit`, default 15), with the busy time no unit accounts for; `by=container` ranks containers by name With `unit` (a unit name such as `session-c1.scope`, or a cgroup path) it ranks that unit's processes instead (pid, command, cpu_pct, share_pct of the unit's busy time, threads), from `/proc/<pid>/stat` | `top`, `systemd-cgtop` |
|
| `cpu_top` | Samples a window (`seconds`, default 3, at most 10). It answers the machine's CPU in use (% of all CPUs and as CPUs busy, from `/proc/stat`), then `by=unit` (the default) ranks systemd units and containers by CPU, from their cgroups' cpu.stat, busiest first (`limit`, default 15), with the busy time no unit accounts for; `by=container` ranks containers by name | `top`, `systemd-cgtop` |
|
||||||
|
|
||||||
### How each number is read
|
### How each number is read
|
||||||
|
|
||||||
|
|||||||
@@ -1,151 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io/fs"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// clockTicks is USER_HZ, the unit of utime and stime in /proc/<pid>/stat: 100 on every Linux the mesh runs.
|
|
||||||
const clockTicks = 100.0
|
|
||||||
|
|
||||||
// ProcessCPU is one process of a unit and its share of CPU over the window (cpu_top with unit set).
|
|
||||||
type ProcessCPU struct {
|
|
||||||
PID int `json:"pid"`
|
|
||||||
Command string `json:"command"` // comm, then the first 120 characters of the command line
|
|
||||||
CPUPct float64 `json:"cpu_pct"` // % of one CPU: 100 = one CPU busy the whole window
|
|
||||||
SharePct float64 `json:"share_pct"` // this process's share of the unit's busy time
|
|
||||||
Threads int `json:"threads"`
|
|
||||||
raw float64
|
|
||||||
}
|
|
||||||
|
|
||||||
// procTicks is a process's utime+stime ticks and thread count from its /proc/<pid>/stat, whose comm
|
|
||||||
// field may hold spaces and parentheses, so the fields are counted from the last ")".
|
|
||||||
func parseProcStat(s string) (comm string, ticks uint64, threads int, start string, ok bool) {
|
|
||||||
l, r := strings.Index(s, "("), strings.LastIndex(s, ")")
|
|
||||||
if l < 0 || r < l {
|
|
||||||
return "", 0, 0, "", false
|
|
||||||
}
|
|
||||||
f := strings.Fields(s[r+1:])
|
|
||||||
if len(f) < 20 {
|
|
||||||
return "", 0, 0, "", false
|
|
||||||
}
|
|
||||||
ut, e1 := strconv.ParseUint(f[11], 10, 64)
|
|
||||||
st, e2 := strconv.ParseUint(f[12], 10, 64)
|
|
||||||
th, e3 := strconv.Atoi(f[17])
|
|
||||||
if e1 != nil || e2 != nil || e3 != nil {
|
|
||||||
return "", 0, 0, "", false
|
|
||||||
}
|
|
||||||
return s[l+1 : r], ut + st, th, f[19], true
|
|
||||||
}
|
|
||||||
|
|
||||||
// cgroupOfUnit is the cgroup path (relative to /sys/fs/cgroup) a unit name or a path names.
|
|
||||||
func (m *Machine) cgroupOfUnit(unit string) (string, error) {
|
|
||||||
base := m.path("/sys/fs/cgroup")
|
|
||||||
if strings.Contains(unit, "/") {
|
|
||||||
rel := strings.Trim(strings.TrimPrefix(unit, "/sys/fs/cgroup"), "/")
|
|
||||||
if strings.Contains(rel, "..") || filepath.Clean("/"+rel) != "/"+rel {
|
|
||||||
return "", fmt.Errorf("cgroup %q is not a path under /sys/fs/cgroup", unit)
|
|
||||||
}
|
|
||||||
if rel == "" || !m.exists("/sys/fs/cgroup/"+rel) {
|
|
||||||
return "", fmt.Errorf("no cgroup %q", unit)
|
|
||||||
}
|
|
||||||
return rel, nil
|
|
||||||
}
|
|
||||||
var found []string
|
|
||||||
filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error {
|
|
||||||
if err == nil && d.IsDir() && d.Name() == unit {
|
|
||||||
rel, _ := filepath.Rel(base, p)
|
|
||||||
found = append(found, filepath.ToSlash(rel))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
switch len(found) {
|
|
||||||
case 0:
|
|
||||||
return "", fmt.Errorf("no unit %q in the cgroup tree", unit)
|
|
||||||
case 1:
|
|
||||||
return found[0], nil
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("unit %q is %d cgroups (%s): give the cgroup path", unit, len(found), strings.Join(found, ", "))
|
|
||||||
}
|
|
||||||
|
|
||||||
type procReading struct {
|
|
||||||
ticks uint64
|
|
||||||
comm string
|
|
||||||
threads int
|
|
||||||
pid int
|
|
||||||
start string // starttime, to tell a reused pid
|
|
||||||
}
|
|
||||||
|
|
||||||
// unitProcs reads every process in a cgroup and its child cgroups: its ticks, name and threads.
|
|
||||||
func (m *Machine) unitProcs(cgroup string) map[int]procReading {
|
|
||||||
out := map[int]procReading{}
|
|
||||||
base := m.path("/sys/fs/cgroup/" + cgroup)
|
|
||||||
filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error {
|
|
||||||
if err != nil || d.IsDir() || d.Name() != "cgroup.procs" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for _, f := range strings.Fields(m.read(strings.TrimPrefix(p, filepath.Clean(m.Root)))) {
|
|
||||||
pid, err := strconv.Atoi(f)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
comm, ticks, th, start, ok := parseProcStat(m.read("/proc/" + f + "/stat"))
|
|
||||||
if ok {
|
|
||||||
out[pid] = procReading{ticks: ticks, comm: comm, threads: th, pid: pid, start: start}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// commandLine is a process's program path (argv[0], cut to 120 characters), never its arguments:
|
|
||||||
// they can carry a password.
|
|
||||||
func (m *Machine) commandLine(pid int) string {
|
|
||||||
s, _, _ := strings.Cut(m.read("/proc/"+strconv.Itoa(pid)+"/cmdline"), "\x00")
|
|
||||||
s, _, _ = strings.Cut(s, " ") // a process that rewrites its title puts its whole command line here
|
|
||||||
s, _, _ = strings.Cut(s, "\t")
|
|
||||||
s = strings.TrimSpace(s)
|
|
||||||
if r := []rune(s); len(r) > 120 {
|
|
||||||
s = string(r[:120])
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// topProcessCPU ranks a unit's processes by CPU between two readings; a process missing from either
|
|
||||||
// reading (started or ended in the window) is skipped.
|
|
||||||
func (m *Machine) topProcessCPU(a, b map[int]procReading, window time.Duration, limit int) []ProcessCPU {
|
|
||||||
var out []ProcessCPU
|
|
||||||
var sum float64
|
|
||||||
for pid, end := range b {
|
|
||||||
start, ok := a[pid]
|
|
||||||
if !ok || end.start != start.start || end.ticks <= start.ticks {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
pct := float64(end.ticks-start.ticks) / clockTicks / window.Seconds() * 100
|
|
||||||
sum += pct
|
|
||||||
out = append(out, ProcessCPU{PID: pid, Command: end.comm, Threads: end.threads, raw: pct})
|
|
||||||
}
|
|
||||||
sort.Slice(out, func(i, j int) bool {
|
|
||||||
if out[i].raw != out[j].raw {
|
|
||||||
return out[i].raw > out[j].raw
|
|
||||||
}
|
|
||||||
return out[i].PID < out[j].PID
|
|
||||||
})
|
|
||||||
if len(out) > limit {
|
|
||||||
out = out[:limit]
|
|
||||||
}
|
|
||||||
for i := range out {
|
|
||||||
out[i].CPUPct = round1(out[i].raw)
|
|
||||||
out[i].SharePct = round1(out[i].raw / sum * 100)
|
|
||||||
if cl := m.commandLine(out[i].PID); cl != "" {
|
|
||||||
out[i].Command += ": " + cl
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -180,52 +180,3 @@ func TestCPUTopKeepsCgroupsWithTheSameUnitNameAsSeparateRows(t *testing.T) {
|
|||||||
t.Errorf("the user session's init.scope: %+v", byCgroup)
|
t.Errorf("the user session's init.scope: %+v", byCgroup)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCPUTopRanksAUnitsProcesses is the unit argument: a session scope with a child cgroup, ranked by
|
|
||||||
// the utime+stime each process used over a 2 s window; one process ends, one starts, one comm holds ")".
|
|
||||||
func TestCPUTopRanksAUnitsProcesses(t *testing.T) {
|
|
||||||
f := aCPUHog(t)
|
|
||||||
f.file("/sys/fs/cgroup/user.slice/session-c1.scope/cgroup.procs", "10\n11\n12\n")
|
|
||||||
f.file("/sys/fs/cgroup/user.slice/session-c1.scope/app/cgroup.procs", "13\n")
|
|
||||||
st := func(pid int, comm string, ticks, threads int) {
|
|
||||||
f.then("/proc/"+itoa(pid)+"/stat", itoa(pid)+" ("+comm+") S 1 1 1 0 -1 0 0 0 0 0 "+itoa(ticks)+" 0 0 0 20 0 "+itoa(threads)+" 0 1 1 1\n")
|
|
||||||
}
|
|
||||||
for pid, c := range map[int][2]string{10: {"firefox", "1"}, 11: {"sh (x)", "1"}, 12: {"gone", "1"}} {
|
|
||||||
f.file("/proc/"+itoa(pid)+"/stat", itoa(pid)+" ("+c[0]+") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 "+c[1]+" 0 1 1 1\n")
|
|
||||||
}
|
|
||||||
f.file("/proc/13/stat", "13 (deep) S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 4 0 1 1 1\n")
|
|
||||||
f.file("/proc/10/cmdline", "/usr/bin/firefox\x00--new-window\x00")
|
|
||||||
f.file("/proc/11/cmdline", "mysql\x00--password\x00hunter2\x00")
|
|
||||||
f.file("/proc/13/cmdline", "/usr/bin/tool --password hunter2 -x") // a rewritten title: one string, spaces, no NUL
|
|
||||||
st(10, "firefox", 150, 40) // 75 % of one CPU over 2 s
|
|
||||||
st(11, "sh (x)", 40, 1) // 20 %
|
|
||||||
st(13, "deep", 10, 4) // 5 %, in the child cgroup
|
|
||||||
f.then("/sys/fs/cgroup/user.slice/session-c1.scope/cgroup.procs", "10\n11\n14\n")
|
|
||||||
f.file("/sys/fs/cgroup/user.slice/session-c1.scope/app/cgroup.procs", "13\n15\n")
|
|
||||||
f.file("/proc/15/stat", "15 (old) S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1 1 1\n")
|
|
||||||
f.then("/proc/15/stat", "15 (reused) S 1 1 1 0 -1 0 0 0 0 0 900 0 0 0 20 0 1 0 77 1 1\n") // new starttime: another process
|
|
||||||
st(14, "newborn", 500, 1) // not in the first reading: skipped
|
|
||||||
|
|
||||||
out, err := tool(t, f.machine(), "cpu_top").Run(map[string]any{"unit": "session-c1.scope", "seconds": 2.0, "limit": 3.0})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
top := out.(map[string]any)["top"].([]ProcessCPU)
|
|
||||||
if len(top) != 3 || top[2].PID != 13 || top[2].CPUPct != 5 || top[0].PID != 10 || top[0].CPUPct != 75 || top[0].SharePct != 75 || top[0].Threads != 40 ||
|
|
||||||
top[0].Command != "firefox: /usr/bin/firefox" || top[1].PID != 11 || top[1].CPUPct != 20 || top[1].Command != "sh (x): mysql" || top[2].Command != "deep: /usr/bin/tool" {
|
|
||||||
t.Errorf("processes of the unit: %+v", top)
|
|
||||||
}
|
|
||||||
for _, p := range top {
|
|
||||||
if p.PID == 15 || strings.Contains(p.Command, "hunter2") {
|
|
||||||
t.Errorf("a reused pid was counted, or an argument leaked: %+v", p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, u := range []string{"../../etc", "/sys/fs/cgroup/../../proc", "user.slice/../.."} {
|
|
||||||
if _, err := tool(t, f.machine(), "cpu_top").Run(map[string]any{"unit": u}); err == nil {
|
|
||||||
t.Errorf("cgroup path %q escaping the root was accepted", u)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, err := tool(t, f.machine(), "cpu_top").Run(map[string]any{"unit": "nothing.scope"}); err == nil {
|
|
||||||
t.Error("an unknown unit was accepted")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -90,14 +90,11 @@ func Tools(m *Machine) []stdio.Tool {
|
|||||||
Description: "What used the most CPU over a few seconds, from each unit's cgroup cpu.stat, which needs no root: the machine's " +
|
Description: "What used the most CPU over a few seconds, from each unit's cgroup cpu.stat, which needs no root: the machine's " +
|
||||||
"CPU in use (as % of all CPUs and as CPUs busy, from /proc/stat), then by=unit (the default) each systemd unit and " +
|
"CPU in use (as % of all CPUs and as CPUs busy, from /proc/stat), then by=unit (the default) each systemd unit and " +
|
||||||
"container ranked by CPU, busiest first, with the busy time no unit accounts for; by=container only the containers, " +
|
"container ranked by CPU, busiest first, with the busy time no unit accounts for; by=container only the containers, " +
|
||||||
"by name. With unit (a systemd unit such as session-c1.scope, or its cgroup path) it ranks that unit's processes instead, CPU by process, the top process: " +
|
"by name. Replaces top, systemd-cgtop. (r)",
|
||||||
"pid, command, cpu_pct, share_pct of the unit's busy time and threads, from /proc/<pid>/stat, needing no root; a desktop session is one unit, " +
|
|
||||||
"and this names the program in it. Replaces top, systemd-cgtop. (r)",
|
|
||||||
Input: map[string]any{
|
Input: map[string]any{
|
||||||
"seconds": map[string]any{"type": "integer", "description": "how long to sample, in seconds (default 3, at most 10)"},
|
"seconds": map[string]any{"type": "integer", "description": "how long to sample, in seconds (default 3, at most 10)"},
|
||||||
"limit": map[string]any{"type": "integer", "description": "how many units or containers to name (default 15, at most 50)"},
|
"limit": map[string]any{"type": "integer", "description": "how many units or containers to name (default 15, at most 50)"},
|
||||||
"by": map[string]any{"type": "string", "enum": []string{"unit", "container"}, "description": "what to rank (default unit)"},
|
"by": map[string]any{"type": "string", "enum": []string{"unit", "container"}, "description": "what to rank (default unit)"},
|
||||||
"unit": map[string]any{"type": "string", "description": "a systemd unit name (session-c1.scope) or cgroup path: rank that unit's processes by CPU instead"},
|
|
||||||
},
|
},
|
||||||
Run: func(args map[string]any) (any, error) {
|
Run: func(args map[string]any) (any, error) {
|
||||||
n, err := count(args, "seconds", 3, 1, 10)
|
n, err := count(args, "seconds", 3, 1, 10)
|
||||||
@@ -121,25 +118,6 @@ func Tools(m *Machine) []stdio.Tool {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if unit := text(args, "unit"); unit != "" {
|
|
||||||
cg, err := m.cgroupOfUnit(unit)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
pa := m.unitProcs(cg)
|
|
||||||
m.Wait(w)
|
|
||||||
bc, err := parseCPU(m.read("/proc/stat"))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return map[string]any{
|
|
||||||
"window_s": w.Seconds(),
|
|
||||||
"by": "process",
|
|
||||||
"cgroup": cg,
|
|
||||||
"cpu": machineCPUOver(ac, bc),
|
|
||||||
"top": m.topProcessCPU(pa, m.unitProcs(cg), w, limit),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
a := m.unitCPU()
|
a := m.unitCPU()
|
||||||
m.Wait(w)
|
m.Wait(w)
|
||||||
bc, err := parseCPU(m.read("/proc/stat"))
|
bc, err := parseCPU(m.read("/proc/stat"))
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ module diskload
|
|||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
|
||||||
github.com/nats-io/nats.go v1.54.0
|
github.com/nats-io/nats.go v1.54.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ module dmenu
|
|||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
|
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/klauspost/compress v1.20.0 // indirect
|
github.com/klauspost/compress v1.20.0 // indirect
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ module dnsmasq
|
|||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
|
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/klauspost/compress v1.20.0 // indirect
|
github.com/klauspost/compress v1.20.0 // indirect
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ module docker-compose
|
|||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
|
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/klauspost/compress v1.20.0 // indirect
|
github.com/klauspost/compress v1.20.0 // indirect
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ module docker
|
|||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
|
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/klauspost/compress v1.20.0 // indirect
|
github.com/klauspost/compress v1.20.0 // indirect
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ module dunst
|
|||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
|
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/klauspost/compress v1.20.0 // indirect
|
github.com/klauspost/compress v1.20.0 // indirect
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ module fail2ban
|
|||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
|
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/klauspost/compress v1.20.0 // indirect
|
github.com/klauspost/compress v1.20.0 // indirect
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ module feh
|
|||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
|
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/klauspost/compress v1.20.0 // indirect
|
github.com/klauspost/compress v1.20.0 // indirect
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ module flatpak
|
|||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
|
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/klauspost/compress v1.20.0 // indirect
|
github.com/klauspost/compress v1.20.0 // indirect
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ module fonts
|
|||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
|
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/klauspost/compress v1.20.0 // indirect
|
github.com/klauspost/compress v1.20.0 // indirect
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ module forticlient
|
|||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
|
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/klauspost/compress v1.20.0 // indirect
|
github.com/klauspost/compress v1.20.0 // indirect
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||||
|
|||||||
+17
-25
@@ -559,14 +559,8 @@ export class GiteaClient {
|
|||||||
*
|
*
|
||||||
* **A listing the forge cut is no listing** (it says `truncated` on a page with nothing more to read, or the pages
|
* **A listing the forge cut is no listing** (it says `truncated` on a page with nothing more to read, or the pages
|
||||||
* run past a bound): the call fails naming the repository and the commit, never answering the directories it did
|
* run past a bound): the call fails naming the repository and the commit, never answering the directories it did
|
||||||
* see, since a directory left out would be taken for one without a module. With `files`, a list of directories (the
|
* see, since a directory left out would be taken for one without a module. Reads only. */
|
||||||
* root as `""`), the answer also carries, of each, every blob under it, recursively, as its path relative to the
|
async commitTrees(owner: string, repo: string, sha: string, paths?: Record<string, string[]>): Promise<{ root: string; trees: Record<string, string>; paths?: Record<string, Record<string, string>> }> {
|
||||||
* directory with its git object id (the indexer digests the files of a mesh package from it, novox/hq ADR 0593 §2); a
|
|
||||||
* directory the commit lacks fails naming it. Reads only. */
|
|
||||||
async commitTrees(owner: string, repo: string, sha: string, paths?: Record<string, string[]>, files?: string[]): Promise<{ root: string; trees: Record<string, string>; paths?: Record<string, Record<string, string>>; files?: Record<string, Record<string, string>> }> {
|
|
||||||
for (const dir of files ?? []) {
|
|
||||||
if (dir !== "" && dir.split("/").some((seg) => seg === "" || seg === "." || seg === "..")) throw new Error(`files: ${JSON.stringify(dir)} is not a plain relative directory`);
|
|
||||||
}
|
|
||||||
const base = `${repoPath(owner, repo)}/git/trees/${commitSegment(sha)}?recursive=true`;
|
const base = `${repoPath(owner, repo)}/git/trees/${commitSegment(sha)}?recursive=true`;
|
||||||
const cut = (why: string) => new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} was cut (${why}); no trees are answered from a part`);
|
const cut = (why: string) => new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} was cut (${why}); no trees are answered from a part`);
|
||||||
const entries: any[] = [];
|
const entries: any[] = [];
|
||||||
@@ -593,22 +587,7 @@ export class GiteaClient {
|
|||||||
if (!tree) throw new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} holds ${e.path} but not the tree of ${dir}`);
|
if (!tree) throw new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} holds ${e.path} but not the tree of ${dir}`);
|
||||||
trees[dir] = tree;
|
trees[dir] = tree;
|
||||||
}
|
}
|
||||||
let filesOf: Record<string, Record<string, string>> | undefined;
|
if (paths === undefined) return { root, trees };
|
||||||
if (files !== undefined) {
|
|
||||||
// **The files of whole directories** (novox/hq ADR 0593 §2): every blob under each, from the same listing. The listing
|
|
||||||
// is whole (a cut one failed above), so a directory absent from it is absent at the commit.
|
|
||||||
filesOf = {};
|
|
||||||
for (const dir of files) {
|
|
||||||
if (dir !== "" && dirOf.get(dir) === undefined) throw new Error(`the directory ${dir} is not in ${owner}/${repo} at ${sha}`);
|
|
||||||
const prefix = dir === "" ? "" : `${dir}/`;
|
|
||||||
const blobs: Record<string, string> = {};
|
|
||||||
for (const e of entries) {
|
|
||||||
if (e?.type === "blob" && typeof e.path === "string" && e.path.startsWith(prefix)) blobs[e.path.slice(prefix.length)] = String(e.sha);
|
|
||||||
}
|
|
||||||
filesOf[dir] = blobs;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (paths === undefined) return filesOf === undefined ? { root, trees } : { root, trees, files: filesOf };
|
|
||||||
// **Named paths** (novox/hq ADR 0499 §3, issue 1758): each asked path's git object id at the commit, a file's or a
|
// **Named paths** (novox/hq ADR 0499 §3, issue 1758): each asked path's git object id at the commit, a file's or a
|
||||||
// directory's, from the same listing, by directory and by path relative to it. A path the listing lacks fails
|
// directory's, from the same listing, by directory and by path relative to it. A path the listing lacks fails
|
||||||
// naming both, and nothing is answered in part.
|
// naming both, and nothing is answered in part.
|
||||||
@@ -623,7 +602,7 @@ export class GiteaClient {
|
|||||||
named[dir][p] = id;
|
named[dir][p] = id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return filesOf === undefined ? { root, trees, paths: named } : { root, trees, paths: named, files: filesOf };
|
return { root, trees, paths: named };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether a repository holds a file at a commit: true, false for a 404, thrown when the forge cannot say. */
|
/** Whether a repository holds a file at a commit: true, false for a 404, thrown when the forge cannot say. */
|
||||||
@@ -672,6 +651,19 @@ export class GiteaClient {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Every status of one context on a commit, newest first, as the forge keeps each one it was given: what tells
|
||||||
|
* when the check now pending on a head was opened, after a restart (novox/hq issue 456). */
|
||||||
|
async contextHistory(owner: string, repo: string, sha: string, context: string): Promise<HeadCheck[]> {
|
||||||
|
const raw = await this.request<any>(`${repoPath(owner, repo)}/statuses/${commitSegment(sha)}?sort=highestindex&limit=50`);
|
||||||
|
const out: HeadCheck[] = [];
|
||||||
|
for (const s of Array.isArray(raw) ? raw : []) {
|
||||||
|
if (s?.context !== context) continue;
|
||||||
|
out.push({ state: String(s.status ?? s.state ?? ""), description: String(s.description ?? ""),
|
||||||
|
updated_at: String(s.created_at ?? s.updated_at ?? "") });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/** A commit's statuses whole, as the forge combines them for a ref (a sha, or the start of one): the commit
|
/** A commit's statuses whole, as the forge combines them for a ref (a sha, or the start of one): the commit
|
||||||
* it resolved to and every status with its description, link, times and setter (novox/hq ADR 0239). */
|
* it resolved to and every status with its description, link, times and setter (novox/hq ADR 0239). */
|
||||||
async combinedStatus(owner: string, repo: string, ref: string): Promise<{ sha: string; state: string; statuses: unknown[] }> {
|
async combinedStatus(owner: string, repo: string, ref: string): Promise<{ sha: string; state: string; statuses: unknown[] }> {
|
||||||
|
|||||||
+181
-12
@@ -1,21 +1,91 @@
|
|||||||
// **One writer of a head's mesh/merge-gate** (novox/hq issue 446).
|
// **One writer of a head's mesh/merge-gate, and the verdict lands last** (novox/hq issue 446).
|
||||||
//
|
//
|
||||||
// A pending check says where it is: "waiting in the build queue since …" when the head is announced. The forge keeps only
|
// A pending check says where it is: "waiting in the build queue since …" when the head is announced, "building on
|
||||||
// the newest status of a context, so two writers cannot order their writes; this module is the one writer of
|
// <machine> since …" when a build machine takes the check — the build seat's `started` (novox/hq ADR 0157) — and the
|
||||||
// mesh/merge-gate, and every write of one head's gate goes through one chain, in the order it was asked. A recheck's
|
// verdict when the controller says `checked`. The forge keeps only the newest status of a context, so a pending word
|
||||||
// pending (novox/hq issue 456) and a trunk's move (ADR 0323 §4) take their turns on the same chain. No verdict is written
|
// written after the verdict hides it: a pass reads pending and the merge waits for ever; a failure hides under
|
||||||
// here any more: the controller's `checked` is no event (ADR 0571 clause 13), and main requires the checker's mesh/check.
|
// "building". Two writers cannot order their writes, and a writer that reads the forge before writing races the other
|
||||||
|
// between its read and its write. So this module is the one writer of mesh/merge-gate, and every write of one head's
|
||||||
|
// gate goes through one chain: a verdict is marked the moment it is heard, before it waits its turn, and a "building"
|
||||||
|
// still waiting behind or taking its turn after that mark is dropped, never written.
|
||||||
|
//
|
||||||
|
// **A recheck's pending is written here too** (novox/hq issue 456). mesh-delivery's recheck puts the gate back to
|
||||||
|
// pending through the gitea_commit_status tool, which this module serves from the watcher's own bundle so that it
|
||||||
|
// reaches this writer: the pending clears the verdict mark, so the new run's "building" is said, and queues behind an
|
||||||
|
// old verdict in flight, so that verdict lands first. A verdict is weighed by the ask it answers — the controller's
|
||||||
|
// checked.id, `build-<unix nanoseconds>` of when the check was asked — against when the check now pending was opened:
|
||||||
|
// an older run's verdict, redelivered after its write failed, is dropped, never written over the pending. After a
|
||||||
|
// restart, when the check was opened is read from the forge's own history of the gate.
|
||||||
|
|
||||||
import { CHECK_CONTEXT, pendingWords } from "./pulls.js";
|
import { CHECK_CONTEXT, pendingWords } from "./pulls.js";
|
||||||
import type { CommitStatus, HeadCheck } from "./client.js";
|
import type { CommitStatus, HeadCheck } from "./client.js";
|
||||||
|
|
||||||
/** What the gate writer needs of the forge: a status set, and a head's statuses as the forge holds them. */
|
/** The build seat's `started`, as mesh-controller's link.BuildStart publishes it. */
|
||||||
|
export interface BuildStarted {
|
||||||
|
id?: string;
|
||||||
|
repository?: string;
|
||||||
|
path?: string;
|
||||||
|
ref?: string;
|
||||||
|
on?: string;
|
||||||
|
at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the gate writer needs of the forge: a status set, and a head's gate as the forge holds it. */
|
||||||
export interface GateForge {
|
export interface GateForge {
|
||||||
setCommitStatus(owner: string, repo: string, sha: string, status: CommitStatus): Promise<void>;
|
setCommitStatus(owner: string, repo: string, sha: string, status: CommitStatus): Promise<void>;
|
||||||
|
/** The head's mesh/merge-gate, newest: its state and when it was set; undefined when it has none. */
|
||||||
|
gateOf(owner: string, repo: string, sha: string): Promise<{ state: string; updated_at?: string } | undefined>;
|
||||||
|
/** The head's mesh/merge-gate statuses, every one the forge keeps, newest first (novox/hq issue 456). */
|
||||||
|
gateHistory(owner: string, repo: string, sha: string): Promise<GateStatus[]>;
|
||||||
/** The head's statuses by context, the newest of each, with their words (novox/hq ADR 0323). */
|
/** The head's statuses by context, the newest of each, with their words (novox/hq ADR 0323). */
|
||||||
headChecks(owner: string, repo: string, sha: string): Promise<Record<string, HeadCheck>>;
|
headChecks(owner: string, repo: string, sha: string): Promise<Record<string, HeadCheck>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One status of a head's gate as the forge keeps it: its state, its words, and when it was set. */
|
||||||
|
export interface GateStatus {
|
||||||
|
state: string;
|
||||||
|
description?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When a check was asked, from its id as mesh-controller's link.NewBuildID writes it: `build-<unix nanoseconds>`.
|
||||||
|
* Undefined for an id of any other shape, whose ask time is not known (novox/hq issue 456).
|
||||||
|
*/
|
||||||
|
export function askedAt(id: string | undefined): number | undefined {
|
||||||
|
const m = /^build-(\d{1,20})$/.exec(String(id ?? ""));
|
||||||
|
if (!m) return undefined;
|
||||||
|
const ms = Number(BigInt(m[1]) / 1_000_000n);
|
||||||
|
return ms > 0 ? ms : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the check now pending on a head was opened, from the gate's history, newest first: the newest status, past the
|
||||||
|
* "building" words this writer says only inside an open check, when it is pending; undefined when the gate holds a
|
||||||
|
* verdict, holds nothing, or its time cannot be read (novox/hq issue 456).
|
||||||
|
*/
|
||||||
|
export function openedAt(history: GateStatus[]): number | undefined {
|
||||||
|
for (const s of history) {
|
||||||
|
if (s.state !== "pending") return undefined;
|
||||||
|
if ((s.description ?? "").startsWith(BUILDING)) continue;
|
||||||
|
const t = Date.parse(s.updated_at ?? "");
|
||||||
|
return Number.isNaN(t) ? undefined : t;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How far a build machine's clock may stand behind this one's when a start is weighed against the pending words. */
|
||||||
|
const CLOCK_SLACK_MS = 60_000;
|
||||||
|
|
||||||
|
/** How the words of a check a machine is running begin. */
|
||||||
|
const BUILDING = "building on ";
|
||||||
|
|
||||||
|
/** The words of a check a machine is running, in the time the operator reads. */
|
||||||
|
export function buildingWords(on: string, at: Date): string {
|
||||||
|
const two = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return `${BUILDING}${on} since ${two(at.getHours())}:${two(at.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Whether a repository as the mesh clones it (`http://forge/novox/hq.git`) is owner/repo. */
|
/** Whether a repository as the mesh clones it (`http://forge/novox/hq.git`) is owner/repo. */
|
||||||
export function clonedAs(url: string | undefined, owner: string, repo: string): boolean {
|
export function clonedAs(url: string | undefined, owner: string, repo: string): boolean {
|
||||||
const u = (url ?? "").trim().toLowerCase().replace(/\.git$/, "");
|
const u = (url ?? "").trim().toLowerCase().replace(/\.git$/, "");
|
||||||
@@ -24,6 +94,12 @@ export function clonedAs(url: string | undefined, owner: string, repo: string):
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Head {
|
interface Head {
|
||||||
|
/** When the pending words were last set by this writer; undefined for a head this process did not announce. */
|
||||||
|
pendingAt?: number;
|
||||||
|
/** A verdict was heard for the head: nothing pending is written after it. */
|
||||||
|
verdict: boolean;
|
||||||
|
/** Which verdict set the mark: one dropped in its turn takes the mark back only while it is still its own. */
|
||||||
|
mark?: object;
|
||||||
/** The chain the head's writes take their turns on. */
|
/** The chain the head's writes take their turns on. */
|
||||||
chain: Promise<void>;
|
chain: Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -42,7 +118,7 @@ export class GateWriter {
|
|||||||
const key = `${owner}/${repo}@${sha}`.toLowerCase();
|
const key = `${owner}/${repo}@${sha}`.toLowerCase();
|
||||||
let h = this.heads.get(key);
|
let h = this.heads.get(key);
|
||||||
if (!h) {
|
if (!h) {
|
||||||
h = { chain: Promise.resolve() };
|
h = { verdict: false, chain: Promise.resolve() };
|
||||||
this.heads.set(key, h);
|
this.heads.set(key, h);
|
||||||
// Kept for the heads still in play; the oldest go first, past what any forge holds open at once.
|
// Kept for the heads still in play; the oldest go first, past what any forge holds open at once.
|
||||||
if (this.heads.size > 2000) this.heads.delete(this.heads.keys().next().value as string);
|
if (this.heads.size > 2000) this.heads.delete(this.heads.keys().next().value as string);
|
||||||
@@ -68,17 +144,36 @@ export class GateWriter {
|
|||||||
pending(owner: string, repo: string, sha: string): Promise<void> {
|
pending(owner: string, repo: string, sha: string): Promise<void> {
|
||||||
const h = this.head(owner, repo, sha);
|
const h = this.head(owner, repo, sha);
|
||||||
const at = this.now();
|
const at = this.now();
|
||||||
|
this.opened(h, at);
|
||||||
return this.said(h, `${owner}/${repo}@${sha.slice(0, 8)}: could not say a check is pending`, () =>
|
return this.said(h, `${owner}/${repo}@${sha.slice(0, 8)}: could not say a check is pending`, () =>
|
||||||
this.forge.setCommitStatus(owner, repo, sha, { state: "pending", context: CHECK_CONTEXT, description: pendingWords(at) }));
|
this.forge.setCommitStatus(owner, repo, sha, { state: "pending", context: CHECK_CONTEXT, description: pendingWords(at) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A new check of the head opened now: no verdict heard for it yet, and nothing older than it is its own. */
|
||||||
|
private opened(h: Head, at: Date): void {
|
||||||
|
h.pendingAt = at.getTime();
|
||||||
|
h.verdict = false;
|
||||||
|
h.mark = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A recheck's pending, asked through the gitea_commit_status tool (novox/hq issue 456): it takes its turn after every
|
* A recheck's pending, asked through the gitea_commit_status tool (novox/hq issue 456): on mesh/merge-gate it opens
|
||||||
* write already asked for, so it lands over them. Any other status of the merge check takes its turn on the same
|
* a new check of the head — the verdict mark cleared, so the new run's "building" is said — and takes its turn
|
||||||
* chain. A write that fails fails the caller.
|
* after every write already asked for, an old verdict in flight included, so it lands over that verdict. Any
|
||||||
|
* other status of the merge check (mesh/repo-check, which the verdict also writes) takes its turn on the same
|
||||||
|
* chain. A write that fails fails the caller, and the head is as it was before it.
|
||||||
*/
|
*/
|
||||||
rechecked(owner: string, repo: string, sha: string, status: CommitStatus): Promise<void> {
|
rechecked(owner: string, repo: string, sha: string, status: CommitStatus): Promise<void> {
|
||||||
return this.turn(this.head(owner, repo, sha), () => this.forge.setCommitStatus(owner, repo, sha, status));
|
const h = this.head(owner, repo, sha);
|
||||||
|
const write = () => this.forge.setCommitStatus(owner, repo, sha, status);
|
||||||
|
if (status.context !== CHECK_CONTEXT || status.state !== "pending") return this.turn(h, write);
|
||||||
|
const before = { pendingAt: h.pendingAt, verdict: h.verdict, mark: h.mark };
|
||||||
|
const at = this.now();
|
||||||
|
this.opened(h, at);
|
||||||
|
return this.turn(h, write).catch((err) => {
|
||||||
|
if (h.pendingAt === at.getTime() && !h.verdict) Object.assign(h, before);
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -100,4 +195,78 @@ export class GateWriter {
|
|||||||
}
|
}
|
||||||
}).then(() => written);
|
}).then(() => written);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A build machine took an ask of this head: said while no verdict was heard, and only for the check now pending —
|
||||||
|
* a start from before the pending words were set (a run before a recheck, the stream's history) is not this check's.
|
||||||
|
* A head this process did not announce (it restarted since) is weighed against the forge's own gate: said only while
|
||||||
|
* that is pending, and not for a start older than it.
|
||||||
|
*/
|
||||||
|
started(owner: string, repo: string, sha: string, b: BuildStarted): Promise<boolean> {
|
||||||
|
const at = b.at ? new Date(b.at) : new Date(NaN);
|
||||||
|
if (!b.on || Number.isNaN(at.getTime())) return Promise.resolve(false);
|
||||||
|
const h = this.head(owner, repo, sha);
|
||||||
|
let said = false;
|
||||||
|
return this.said(h, `${owner}/${repo}@${sha.slice(0, 8)}: could not say where its check is`, async () => {
|
||||||
|
if (h.verdict) return;
|
||||||
|
let since = h.pendingAt;
|
||||||
|
if (since === undefined) {
|
||||||
|
const gate = await this.forge.gateOf(owner, repo, sha);
|
||||||
|
// No gate on the forge: none was written (its base does not require it, issue 1301), and nothing is said.
|
||||||
|
if (!gate || gate.state !== "pending") return;
|
||||||
|
since = gate?.updated_at ? Date.parse(gate.updated_at) : undefined;
|
||||||
|
}
|
||||||
|
if (since !== undefined && at.getTime() < since - CLOCK_SLACK_MS) return;
|
||||||
|
if (h.verdict) return; // heard while the forge was read
|
||||||
|
await this.forge.setCommitStatus(owner, repo, sha, { state: "pending", context: CHECK_CONTEXT,
|
||||||
|
description: buildingWords(b.on as string, at) });
|
||||||
|
said = true;
|
||||||
|
}).then(() => said);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The verdict: marked at once, so no pending word is written after it, then written in its turn — last. A write
|
||||||
|
* that fails fails the caller, so the event is offered again.
|
||||||
|
*
|
||||||
|
* `id` is the ask it answers (checked.id): a verdict of a check asked before the check now pending was opened is
|
||||||
|
* an older run's — redelivered after its write failed, or heard late — and is dropped, never written over the
|
||||||
|
* pending (novox/hq issue 456). When this process opened no check of the head (it restarted since), the opening
|
||||||
|
* is read from the forge's history of the gate in the verdict's turn; a history that cannot be read writes the
|
||||||
|
* verdict, as one with no readable id is: a verdict is never lost to what cannot be weighed.
|
||||||
|
*/
|
||||||
|
verdict(owner: string, repo: string, sha: string, write: () => Promise<void>, id?: string): Promise<void> {
|
||||||
|
const h = this.head(owner, repo, sha);
|
||||||
|
const asked = askedAt(id);
|
||||||
|
const head = `${owner}/${repo}@${sha.slice(0, 8)}`;
|
||||||
|
if (asked !== undefined && h.pendingAt !== undefined && older(asked, h.pendingAt)) {
|
||||||
|
console.log(`[gitea] ${head}: the verdict of ${id} is an older run's than the check now pending; not written`);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
const before = h.verdict;
|
||||||
|
const mark = {};
|
||||||
|
h.verdict = true;
|
||||||
|
h.mark = mark;
|
||||||
|
return this.turn(h, async () => {
|
||||||
|
if (asked !== undefined && h.pendingAt === undefined) {
|
||||||
|
let opened: number | undefined;
|
||||||
|
try {
|
||||||
|
opened = openedAt(await this.forge.gateHistory(owner, repo, sha));
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[gitea] ${head}: the gate's history could not be read, so the verdict of ${id} is written ` +
|
||||||
|
`unweighed — ${err instanceof Error ? err.message : err}`);
|
||||||
|
}
|
||||||
|
if (opened !== undefined && older(asked, opened)) {
|
||||||
|
if (h.mark === mark) h.verdict = before;
|
||||||
|
console.log(`[gitea] ${head}: the verdict of ${id} is an older run's than the check the forge holds pending; not written`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await write();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a check asked at `asked` was asked before a check opened at `opened`, past what the clocks may differ by. */
|
||||||
|
function older(asked: number, opened: number): boolean {
|
||||||
|
return asked < opened - CLOCK_SLACK_MS;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ module gitea
|
|||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
|
||||||
github.com/nats-io/nats-server/v2 v2.11.17
|
github.com/nats-io/nats-server/v2 v2.11.17
|
||||||
github.com/nats-io/nats.go v1.54.0
|
github.com/nats-io/nats.go v1.54.0
|
||||||
golang.org/x/crypto v0.57.0
|
golang.org/x/crypto v0.57.0
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||||
|
|||||||
+121
-6
@@ -9,9 +9,13 @@
|
|||||||
//
|
//
|
||||||
// module.gitea.pull.closed — a pull request closed unmerged: the delivery it was is stopped (novox/hq ADR 0239)
|
// module.gitea.pull.closed — a pull request closed unmerged: the delivery it was is stopped (novox/hq ADR 0239)
|
||||||
//
|
//
|
||||||
// It consumes no event of the controller: no controller judges a merge (ADR 0524 §5, ADR 0571 clause 13), and the two older
|
// Once consumed mesh-controller.checked — a pull request's merge check, judged — and set it as the head
|
||||||
// statuses, `mesh/merge-gate` and `mesh/repo-check`, are written for no base that main requires (issue 1301); main
|
// commit's statuses (setVerdict, kept below for a revert, and bound to no event since ADR 0571 clause 13): `mesh/merge-gate`, the modules of the mesh's graph the change touches, and
|
||||||
// requires `mesh/check`, the checker's, everywhere, hq included since ADR 0527 §2.
|
// `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, hq included since
|
||||||
|
// ADR 0527 §2, and since R4g no controller judges a merge (its merge gate went with build-agent, ADR 0524 §5), so nothing
|
||||||
|
// publishes a merge check's verdict, this module consumes no event of the controller, and these two are written for no base.
|
||||||
//
|
//
|
||||||
// Reads mesh-delivery-checker.owed — what the checker owes the forge, mesh/check on each head it judged — by a watch, and
|
// 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
|
// writes it (check.ts; ADR 0521, R4e finding R2-1). No tool writes mesh/check (finding K1), and no event: the watch hands
|
||||||
@@ -49,13 +53,14 @@ import { announce, keepWatermark, readWatermark } from "./announce.js";
|
|||||||
import { mergeTool } from "./merge.js";
|
import { mergeTool } from "./merge.js";
|
||||||
import { GiteaAdmin, GiteaClient, movedSince } from "./client.js";
|
import { GiteaAdmin, GiteaClient, movedSince } from "./client.js";
|
||||||
import { keepMainRules, mainRulesStep } from "./mainrules.js";
|
import { keepMainRules, mainRulesStep } from "./mainrules.js";
|
||||||
import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, headKey, requiredOf, writesGate, type Announced } from "./pulls.js";
|
import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, commentFor, composedGate, groupGateStatus, headKey, maintained,
|
||||||
|
requiredOf, statusesFor, writesGate, type Announced, type Checked, repoCheckFacts } from "./pulls.js";
|
||||||
import { actOn, announceNewHeads, startPass } from "./watch.js";
|
import { actOn, announceNewHeads, startPass } from "./watch.js";
|
||||||
import { GateWriter } from "./gate.js";
|
import { GateWriter } from "./gate.js";
|
||||||
import { HOOK_PATH, HOOK_PORT, Pace, claimHead, ensureSystemHook, eventsTool, keptSecret, receiver, type ForgeEvent } from "./webhook.js";
|
import { HOOK_PATH, HOOK_PORT, Pace, claimHead, ensureSystemHook, eventsTool, keptSecret, receiver, type ForgeEvent } from "./webhook.js";
|
||||||
import { keepSiblings, readSiblings, siblingsFile, weighSiblings } from "./siblings.js";
|
import { keepSiblings, noteComposed, noteVerdict, readSiblings, siblingsFile, siblingsOf, weighSiblings } from "./siblings.js";
|
||||||
import { bindInBackground, bindUntilHeld } from "./bind.js";
|
import { bindInBackground, bindUntilHeld } from "./bind.js";
|
||||||
import { announcementOf, weighTrunks } from "./trunk.js";
|
import { announcementOf, keepTook, lateVerdictComment, outOfDateWords, sameCommit, tookFile, weighTrunks } from "./trunk.js";
|
||||||
import { ReadAll, readAllTools } from "./readall.js";
|
import { ReadAll, readAllTools } from "./readall.js";
|
||||||
import { DeliveredWrites, INDEXER_MODULES, writeTools, type ModulesChange } from "./writes.js";
|
import { DeliveredWrites, INDEXER_MODULES, writeTools, type ModulesChange } from "./writes.js";
|
||||||
import { TOOLS_ACCOUNT, TOOLS_ORG, meshAccounts } from "./tools-account.js";
|
import { TOOLS_ACCOUNT, TOOLS_ORG, meshAccounts } from "./tools-account.js";
|
||||||
@@ -351,11 +356,121 @@ async function siblingMoves(client: GiteaClient, repository: string, number?: nu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **The verdict, set where the pull request shows it.** Every verdict is the head commit's status; one
|
||||||
|
// that is not a pass also leaves the check's own account as a comment, so the reason is read where the
|
||||||
|
// change is reviewed. An error — the check could not run — is the forge's `error`, never a success.
|
||||||
|
async function setVerdict(client: GiteaClient, event: { body: unknown }): Promise<void> {
|
||||||
|
const c = (event.body ?? {}) as Checked;
|
||||||
|
if (!c.group && c.owner && c.repo && c.took) {
|
||||||
|
// How long it took, for the merge tool to say how long the next one may (novox/hq ADR 0323 §5).
|
||||||
|
keepTook(tookFile(process.env.MESH_GITEA_STATE_DIR), `${c.owner}/${c.repo}`,
|
||||||
|
{ took: c.took, at: new Date().toISOString(), head: c.commit });
|
||||||
|
}
|
||||||
|
if (c.group) {
|
||||||
|
// A delivery group's composed check (novox/hq ADR 0239, ADR 0403 §1): its verdict is each member's merge gate, at the
|
||||||
|
// head it read; the group's own status, mesh/delivery-group, is its owner's to say.
|
||||||
|
await setGroupVerdict(client, c);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!c.owner || !c.repo || !c.commit || !c.verdict) {
|
||||||
|
console.error("[gitea] a merge check's verdict named no repository, commit or verdict; ignored");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The siblings this verdict read are kept for the member (ADR 0382 §5); a verdict that read none removes the record.
|
||||||
|
const siblings = siblingsOf(c.siblings);
|
||||||
|
if (c.number && noteVerdict(siblingNamed, `${c.owner}/${c.repo}#${c.number}`.toLowerCase(), c.commit, siblings)) {
|
||||||
|
keepSiblings(siblingsRecord, siblingNamed);
|
||||||
|
}
|
||||||
|
// A sibling that moved while the check ran is weighed once this verdict has landed, in the head's own turn after it.
|
||||||
|
if (siblings.length) {
|
||||||
|
for (const repository of new Set(siblings.map((s) => s.repository))) {
|
||||||
|
void Promise.resolve().then(() => siblingMoves(client, repository));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Each status links to the pull request, where the delivery's view is kept (novox/hq ADR 0239).
|
||||||
|
let target: string | undefined;
|
||||||
|
let base: string | undefined;
|
||||||
|
if (c.number) {
|
||||||
|
const pull = await client.getPullRequest(c.owner, c.repo, c.number).catch(() => undefined);
|
||||||
|
target = pull?.html_url || undefined;
|
||||||
|
base = pull?.base || undefined;
|
||||||
|
}
|
||||||
|
// **A verdict judged against a trunk commit that is no longer the trunk's head is out of date when it lands**
|
||||||
|
// (novox/hq ADR 0323 §4): a check asked before the trunk moved. It is said so, pending with the reason, in place of
|
||||||
|
// its pass or failure; a trunk that cannot be read lets the verdict stand, and the merge tool compares the commits.
|
||||||
|
const trunk = base ?? c.plan?.base;
|
||||||
|
// Only the statuses the base still requires are written (issue 1301): on hq, mesh/repo-check alone.
|
||||||
|
const required = trunk ? requiredOf(await client.appliedProtection(c.owner, c.repo, trunk).catch(() => undefined)) : undefined;
|
||||||
|
const kept = maintained(required);
|
||||||
|
if (c.trunk && trunk) {
|
||||||
|
const head = await client.branchHead(c.owner, c.repo, trunk).catch(() => undefined);
|
||||||
|
if (head && !sameCommit(c.trunk, head)) {
|
||||||
|
const description = outOfDateWords(c.trunk, head);
|
||||||
|
for (const context of [...(composedGate(c) ? [] : [CHECK_CONTEXT]), ...(c["repo-check"] ? [REPO_CHECK_CONTEXT] : [])]
|
||||||
|
.filter((x) => kept.includes(x))) {
|
||||||
|
await client.setCommitStatus(c.owner, c.repo, c.commit, { state: "pending", context, description,
|
||||||
|
...(target ? { target_url: target } : {}) });
|
||||||
|
}
|
||||||
|
// Its account is kept and shown all the same (ADR 0323 §7): what it judged, and that it is out of date.
|
||||||
|
const said = commentFor(c, await repoCheckFacts(client, c, trunk), required);
|
||||||
|
if (c.number && !composedGate(c)) await client.addComment(c.owner, c.repo, c.number, lateVerdictComment(c, said, description));
|
||||||
|
console.log(`[gitea] ${c.owner}/${c.repo}#${c.number ?? "?"} at ${c.commit.slice(0, 8)}: merge check ${c.verdict}, ` +
|
||||||
|
`${description}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const facts = await repoCheckFacts(client, c, base ?? c.plan?.base);
|
||||||
|
const gateNow = composedGate(c) ? (await client.headChecks(c.owner, c.repo, c.commit).catch(() => ({} as Record<string, { description?: string }>)))[CHECK_CONTEXT]?.description : undefined;
|
||||||
|
for (const status of statusesFor(c, facts, gateNow, required)) {
|
||||||
|
await client.setCommitStatus(c.owner, c.repo, c.commit, target ? { ...status, target_url: target } : status);
|
||||||
|
}
|
||||||
|
const comment = composedGate(c) ? null : commentFor(c, facts, required);
|
||||||
|
if (comment && c.number) await client.addComment(c.owner, c.repo, c.number, comment);
|
||||||
|
console.log(`[gitea] ${c.owner}/${c.repo}#${c.number ?? "?"} at ${c.commit.slice(0, 8)}: merge check ${c.verdict}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** **The composed verdict sets each member's gate** (novox/hq ADR 0403 §1, issue 978), in each head's own turn of the gate
|
||||||
|
* writer (gate.ts), judged against the trunk as a member's own verdict is: one that names a trunk commit that is no longer
|
||||||
|
* the trunk's head is out of date when it lands, and said so, pending. */
|
||||||
|
async function setGroupVerdict(client: GiteaClient, c: Checked): Promise<void> {
|
||||||
|
// The base branch each member merges into, read with its pull request below: the composed verdict names the siblings of
|
||||||
|
// each member from it (issue 1049).
|
||||||
|
const bases = new Map<string, string>();
|
||||||
|
for (const m of c.members ?? []) {
|
||||||
|
if (!m.owner || !m.repo || !m.commit || !c.verdict) {
|
||||||
|
console.error(`[gitea] a composed verdict of ${c.group} names a member with no repository, commit or verdict; ignored`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await gates.verdict(m.owner, m.repo, m.commit, async () => {
|
||||||
|
const pull = m.number ? await client.getPullRequest(m.owner, m.repo, m.number).catch(() => undefined) : undefined;
|
||||||
|
const target = pull?.html_url || undefined;
|
||||||
|
if (m.number && pull?.base) bases.set(`${m.owner}/${m.repo}#${m.number}`, pull.base);
|
||||||
|
let status = groupGateStatus(c, m);
|
||||||
|
if (m.trunk && pull?.base) {
|
||||||
|
const head = await client.branchHead(m.owner, m.repo, pull.base).catch(() => undefined);
|
||||||
|
if (head && !sameCommit(m.trunk, head)) status = { state: "pending", context: CHECK_CONTEXT, description: outOfDateWords(m.trunk, head) };
|
||||||
|
}
|
||||||
|
await client.setCommitStatus(m.owner, m.repo, m.commit, target ? { ...status, target_url: target } : status);
|
||||||
|
console.log(`[gitea] ${m.owner}/${m.repo}#${m.number ?? "?"} at ${m.commit.slice(0, 8)}: the group ${c.group}'s composed check ${c.verdict}, ` +
|
||||||
|
`its merge gate ${status.state}`);
|
||||||
|
}, c.id);
|
||||||
|
}
|
||||||
|
// The siblings of each member are the other members at the heads and base commits this verdict composed (issue 1049): a
|
||||||
|
// sibling's own merge of such a head is no move of the member's base. Kept for what the merge tool and the siblings'
|
||||||
|
// weighing read; nothing is run.
|
||||||
|
if (c.verdict && c.members?.length) {
|
||||||
|
const members = c.members.map((m) => ({ ...m, base: m.number ? bases.get(`${m.owner}/${m.repo}#${m.number}`) : undefined }));
|
||||||
|
if (noteComposed(siblingNamed, members)) keepSiblings(siblingsRecord, siblingNamed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// **This module is the one writer of a head's mesh/merge-gate** (novox/hq issue 446): the announcement's pending words,
|
// **This module is the one writer of a head's mesh/merge-gate** (novox/hq issue 446): the announcement's pending words,
|
||||||
// where the check is when a build machine takes it, and the verdict — each head's writes in one chain, the verdict last
|
// where the check is when a build machine takes it, and the verdict — each head's writes in one chain, the verdict last
|
||||||
// (gate.ts). A second writer elsewhere raced the verdict and could leave a pass reading pending.
|
// (gate.ts). A second writer elsewhere raced the verdict and could leave a pass reading pending.
|
||||||
const gates = new GateWriter({
|
const gates = new GateWriter({
|
||||||
setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status),
|
setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status),
|
||||||
|
gateOf: async (owner, repo, sha) => (await (gitea as GiteaClient).headChecks(owner, repo, sha))[CHECK_CONTEXT],
|
||||||
|
gateHistory: (owner, repo, sha) => (gitea as GiteaClient).contextHistory(owner, repo, sha, CHECK_CONTEXT),
|
||||||
headChecks: (owner, repo, sha) => (gitea as GiteaClient).headChecks(owner, repo, sha),
|
headChecks: (owner, repo, sha) => (gitea as GiteaClient).headChecks(owner, repo, sha),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { MERGE_METHODS, Refused, checkName } from "./guard.js";
|
|||||||
import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, maintained, requiredOf } from "./pulls.js";
|
import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, maintained, requiredOf } from "./pulls.js";
|
||||||
import { approvedHeadsOf, groupRefusal, memberKey, namesSiblings, notApproved, readSiblings, siblingOutOfDate, siblingsFile } from "./siblings.js";
|
import { approvedHeadsOf, groupRefusal, memberKey, namesSiblings, notApproved, readSiblings, siblingOutOfDate, siblingsFile } from "./siblings.js";
|
||||||
import { checkGatesMerge } from "./mainrules.js";
|
import { checkGatesMerge } from "./mainrules.js";
|
||||||
import { announcementOf, askedWords, outOfDateWords, sameCommit, twelve, verdictAtTrunk, waitsForComposed } from "./trunk.js";
|
import { announcementOf, askedWords, outOfDateWords, sameCommit, tookFile, tookWords, twelve, verdictAtTrunk, waitsForComposed } from "./trunk.js";
|
||||||
|
|
||||||
/** A head named for a merge: at least 12 hex characters of its sha (novox/hq issue 381), up to a sha-256 commit's 64. */
|
/** A head named for a merge: at least 12 hex characters of its sha (novox/hq issue 381), up to a sha-256 commit's 64. */
|
||||||
const REVIEWED_HEAD = /^[0-9a-f]{12,64}$/;
|
const REVIEWED_HEAD = /^[0-9a-f]{12,64}$/;
|
||||||
@@ -47,7 +47,7 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
|
|||||||
"optionally deleting the source branch afterwards. Refused " +
|
"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 " +
|
"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 " +
|
"reviewed. A verdict judged against an earlier trunk commit is refused, naming both, and the head's check against " +
|
||||||
"the trunk's head is asked once: ask again at the same head " +
|
"the trunk's head is asked once, with how long the last check of the repository took: ask again at the same head " +
|
||||||
"once it passes — the tool never merges by itself.",
|
"once it passes — the tool never merges by itself.",
|
||||||
input: {
|
input: {
|
||||||
owner: { type: "string", description: "the repository owner" },
|
owner: { type: "string", description: "the repository owner" },
|
||||||
@@ -163,6 +163,7 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
|
|||||||
const siblingWords = siblingOutOfDate(checks[judge]?.description) ? checks[judge]?.description : undefined;
|
const siblingWords = siblingOutOfDate(checks[judge]?.description) ? checks[judge]?.description : undefined;
|
||||||
const judged = siblingWords ? `was put out of date by a sibling's move (${siblingWords})`
|
const judged = siblingWords ? `was put out of date by a sibling's move (${siblingWords})`
|
||||||
: at.judged ? `was judged against trunk ${twelve(at.judged)}` : "names no trunk commit it was judged against";
|
: at.judged ? `was judged against trunk ${twelve(at.judged)}` : "names no trunk commit it was judged against";
|
||||||
|
const took = tookWords(tookFile(deps.stateDir), `${owner}/${repo}`);
|
||||||
const head = twelve(pull.head_sha), now = twelve(trunkHead);
|
const head = twelve(pull.head_sha), now = twelve(trunkHead);
|
||||||
// **A member that waits for its group's composed check is not announced again** (novox/hq issue 1106): its own
|
// **A member that waits for its group's composed check is not announced again** (novox/hq issue 1106): its own
|
||||||
// check can only say "composed" once more, so an ask here brought a verdict naming no trunk and the next merge was
|
// check can only say "composed" once more, so an ask here brought a verdict naming no trunk and the next merge was
|
||||||
@@ -175,7 +176,7 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
|
|||||||
if (at.onItsWay) {
|
if (at.onItsWay) {
|
||||||
throw new Refused(`${owner}/${repo}#${number} is not merged: its verdict ${judged}, and ${pull.base} is now ` +
|
throw new Refused(`${owner}/${repo}#${number} is not merged: its verdict ${judged}, and ${pull.base} is now ` +
|
||||||
`${now}. Its check is already asked and not yet answered (${checks[judge]?.description}), so it is not ` +
|
`${now}. Its check is already asked and not yet answered (${checks[judge]?.description}), so it is not ` +
|
||||||
`asked again. Ask again at ${head} once both statuses pass: this tool never merges by itself ` +
|
`asked again; ${took}. Ask again at ${head} once both statuses pass: this tool never merges by itself ` +
|
||||||
`(novox/hq ADR 0323). A check that never answers is asked again with mesh-delivery.recheck. Nothing was merged`);
|
`(novox/hq ADR 0323). A check that never answers is asked again with mesh-delivery.recheck. Nothing was merged`);
|
||||||
}
|
}
|
||||||
// Pending first, so a second ask finds the check on its way; then the head is announced as a new head is. An
|
// Pending first, so a second ask finds the check on its way; then the head is announced as a new head is. An
|
||||||
@@ -202,7 +203,7 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
|
|||||||
`${now}, and its check could not be asked: ${err instanceof Error ? err.message : err}. Nothing was merged`);
|
`${now}, and its check could not be asked: ${err instanceof Error ? err.message : err}. Nothing was merged`);
|
||||||
}
|
}
|
||||||
throw new Refused(`${owner}/${repo}#${number} is not merged: its verdict ${judged}, and ${pull.base} is now ` +
|
throw new Refused(`${owner}/${repo}#${number} is not merged: its verdict ${judged}, and ${pull.base} is now ` +
|
||||||
`${now}. The check of head ${head} merged into trunk ${now} was asked. Ask again at ${head} once both ` +
|
`${now}. The check of head ${head} merged into trunk ${now} was asked; ${took}. Ask again at ${head} once both ` +
|
||||||
`statuses pass: this tool never merges by itself (novox/hq ADR 0323). In a busy repository another merge may ` +
|
`statuses pass: this tool never merges by itself (novox/hq ADR 0323). In a busy repository another merge may ` +
|
||||||
`move the trunk again first; this is then refused and asked once more. A check that never answers is asked ` +
|
`move the trunk again first; this is then refused and asked once more. A check that never answers is asked ` +
|
||||||
`again with mesh-delivery.recheck. Nothing was merged`);
|
`again with mesh-delivery.recheck. Nothing was merged`);
|
||||||
|
|||||||
+277
-7
@@ -1,15 +1,17 @@
|
|||||||
// A pull request's merge check (novox/hq to-be 45 §9): what the forge's announcer says when a pull
|
// A pull request's merge check (novox/hq to-be 45 §9): what the forge's announcer says when a pull
|
||||||
// request's head moves, and the status contexts the check is kept under.
|
// request's head moves, and what it sets as the pull request's status when the mesh says the verdict.
|
||||||
//
|
//
|
||||||
// **Before merge, never after.** Each new head of an open pull request is announced as `pull.updated`, and the mesh
|
// **Before merge, never after.** Every check the mesh had ran after a merge, on a machine: a manifest the
|
||||||
// checks it against every machine of its facts before it merges. The verdict is the checker's `mesh/check`, which this
|
// node-engine refuses (issue 236), an identity a real machine's name made too long (263). So each new head
|
||||||
// module writes from the checker's owed state (check.ts); the controller's `checked`, which once set the two older
|
// of an open pull request is announced as `pull.updated`; the controller asks the build seat to check it
|
||||||
// statuses, is no event any more (ADR 0571 clause 13).
|
// against every machine of the mesh's facts; and the verdict comes back as the controller's `checked`,
|
||||||
|
// which this sets as the head commit's status — and, when it is not a pass, as a comment saying why.
|
||||||
//
|
//
|
||||||
// Pure functions here, so they are tested without a forge.
|
// Pure functions here, so they are tested without a forge; index.ts does the asking and the setting.
|
||||||
|
|
||||||
import type { GiteaPull, GiteaRepo } from "./client.js";
|
import type { CommitStatus, GiteaClient, GiteaPull, GiteaRepo } from "./client.js";
|
||||||
import { movedSince } from "./client.js";
|
import { movedSince } from "./client.js";
|
||||||
|
import { UNKNOWN_MARK, besideWords, siblingsOf } from "./siblings.js";
|
||||||
|
|
||||||
/** The status context a merge check's gate is kept under: one per commit, the newest replacing the last. */
|
/** The status context a merge check's gate is kept under: one per commit, the newest replacing the last. */
|
||||||
export const CHECK_CONTEXT = "mesh/merge-gate";
|
export const CHECK_CONTEXT = "mesh/merge-gate";
|
||||||
@@ -57,6 +59,88 @@ export function pendingWords(announced: Date): string {
|
|||||||
return `waiting in the build queue since ${two(announced.getHours())}:${two(announced.getMinutes())}`;
|
return `waiting in the build queue since ${two(announced.getHours())}:${two(announced.getMinutes())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One layer of a check, judged. */
|
||||||
|
export interface Layer {
|
||||||
|
verdict: string;
|
||||||
|
summary: string;
|
||||||
|
/** The modules a merge of the change would move, as the controller's planner reckons it… */
|
||||||
|
modules?: string[];
|
||||||
|
/** …and those it would build after them because they stand on them. */
|
||||||
|
dependents?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the controller says as `checked` (mesh-controller internal/link, Checked). */
|
||||||
|
export interface Checked {
|
||||||
|
owner: string;
|
||||||
|
repo: string;
|
||||||
|
number?: number;
|
||||||
|
commit: string;
|
||||||
|
verdict: string;
|
||||||
|
summary: string;
|
||||||
|
report?: string;
|
||||||
|
id: string;
|
||||||
|
on?: string;
|
||||||
|
/** The gate — the modules of the mesh's graph the change touches — and the repository's own check. A
|
||||||
|
* controller from before the layers says neither, and its verdict is the gate's. */
|
||||||
|
gate?: Layer;
|
||||||
|
"repo-check"?: Layer;
|
||||||
|
/** The change plan of the commit checked (novox/hq ADR 0238): what a merge of it would build and send. */
|
||||||
|
plan?: ChangePlan;
|
||||||
|
/** Set on a delivery group's composed check (novox/hq ADR 0239): its verdict is each member's merge gate (ADR 0403 §1,
|
||||||
|
* issue 978), set in setGroupVerdict, never read as this head's own. */
|
||||||
|
group?: string;
|
||||||
|
/** The members a composed check read, each at its head and the trunk commit it was merged onto (ADR 0323 §6). */
|
||||||
|
members?: { owner: string; repo: string; number?: number; commit: string; trunk?: string }[];
|
||||||
|
/** The trunk commit the head was merged into and judged as, twelve characters (novox/hq ADR 0323 §3); absent from a
|
||||||
|
* controller whose build seat judged the head alone. Beside are the other repositories' commits the check read,
|
||||||
|
* by directory; took how long it ran. */
|
||||||
|
trunk?: string;
|
||||||
|
beside?: Record<string, string>;
|
||||||
|
took?: string;
|
||||||
|
/** The other members of its delivery group the check read beside the head, each at its head merged onto its base
|
||||||
|
* commit (novox/hq ADR 0382 §4); absent when it read none, and from a controller from before the rule. */
|
||||||
|
siblings?: { repository: string; number: number; head: string; base: string; base_commit: string }[];
|
||||||
|
/** Why the siblings could not be learned (mesh-delivery did not answer), when that is so. */
|
||||||
|
siblings_unknown?: string;
|
||||||
|
/** Where the judge came from, when it came from the group (ADR 0382 §3): said in the account. */
|
||||||
|
judge_from?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A change plan, as the controller says it (mesh-controller internal/link, ChangePlan). */
|
||||||
|
export interface ChangePlan {
|
||||||
|
repository: string;
|
||||||
|
base: string;
|
||||||
|
head: string;
|
||||||
|
moved?: string[];
|
||||||
|
dependents?: string[];
|
||||||
|
new?: string[];
|
||||||
|
unread?: string[];
|
||||||
|
tiers?: string[][];
|
||||||
|
machines?: { machine: string; receives?: string[]; waits?: string[] }[];
|
||||||
|
steps?: string[];
|
||||||
|
summary: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a plan builds anything: a change that touches the mesh's graph. */
|
||||||
|
function builds(plan?: ChangePlan): boolean {
|
||||||
|
return !!plan && ((plan.moved?.length ?? 0) > 0 || (plan.new?.length ?? 0) > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A delivery plan (the controller's ChangePlan) as a person reads it on the pull request. */
|
||||||
|
export function planText(plan: ChangePlan): string {
|
||||||
|
const lines = [`**Delivery plan** — ${plan.summary}`];
|
||||||
|
(plan.tiers ?? []).forEach((tier, i) => lines.push(`- tier ${i}: ${tier.join(", ")}`));
|
||||||
|
for (const m of plan.machines ?? []) {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (m.receives?.length) parts.push(`receives ${m.receives.join(", ")}`);
|
||||||
|
if (m.waits?.length) parts.push(`waits for a person: ${m.waits.join(", ")}`);
|
||||||
|
lines.push(`- ${m.machine}: ${parts.join("; ")}`);
|
||||||
|
}
|
||||||
|
for (const step of plan.steps ?? []) lines.push(`- ${step}`);
|
||||||
|
if (plan.unread?.length) lines.push(`- read by no module's build: ${plan.unread.join(", ")}`);
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
/** The heads already announced, keyed by `headKey`, so a restart announces nothing twice. */
|
/** The heads already announced, keyed by `headKey`, so a restart announces nothing twice. */
|
||||||
export type Announced = Record<string, string>;
|
export type Announced = Record<string, string>;
|
||||||
|
|
||||||
@@ -92,11 +176,197 @@ export function reposToLook(repos: GiteaRepo[], floor: string, pulls: PullSeen[]
|
|||||||
return repos.filter((r) => moved.has(r.full_name));
|
return repos.filter((r) => moved.has(r.full_name));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **A required status is success or it blocks** (novox/hq issue 293). The forge combines `warning` as a
|
||||||
|
// failure, and a branch whose protection requires mesh/merge-gate or mesh/repo-check — with no
|
||||||
|
// administrator override — will not merge past one. So the controller's `warning`, a note and never a
|
||||||
|
// question, is set as `success` with the note in its description; only what a person must decide is a
|
||||||
|
// `failure`, with why. The mapping, verdict by verdict:
|
||||||
|
//
|
||||||
|
// pass → success
|
||||||
|
// warning (a note: rebuild width, a problem
|
||||||
|
// already so on the base, a script's own) → success, "pass, with a note: …"
|
||||||
|
// repo-check, no merge-check.sh, not required → success, "no repository check defined"
|
||||||
|
// repo-check, no merge-check.sh, required (or
|
||||||
|
// the protection unreadable) → failure: a person adds one, or lifts the requirement
|
||||||
|
// fail → failure
|
||||||
|
// error, or no verdict → error — never a success
|
||||||
|
|
||||||
|
/** The forge's state for a verdict: an error is the forge's `error`, never a success; a warning is a note. */
|
||||||
|
function stateOf(verdict: string): CommitStatus["state"] {
|
||||||
|
return verdict === "pass" || verdict === "warning" ? "success" : verdict === "fail" ? "failure" : "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How a verdict is said in a status's description: a warning as the pass with a note it is. */
|
||||||
|
function saidAs(verdict: string): string {
|
||||||
|
return verdict === "warning" ? "pass, with a note" : verdict || "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
function described(verdict: string, summary: string, modules?: string[], dependents?: string[]): string {
|
||||||
|
// The forge keeps a short description; the rest is the comment's.
|
||||||
|
let d = `${saidAs(verdict)}: ${summary}`;
|
||||||
|
if (modules?.length) d += ` [${modules.join(", ")}${dependents?.length ? ` +${dependents.length} dependent(s)` : ""}]`;
|
||||||
|
return clipped(d);
|
||||||
|
}
|
||||||
|
|
||||||
export function clipped(d: string): string {
|
export function clipped(d: string): string {
|
||||||
d = d.replace(/\s+/g, " ").trim();
|
d = d.replace(/\s+/g, " ").trim();
|
||||||
return d.length > 140 ? d.slice(0, 139) + "…" : d;
|
return d.length > 140 ? d.slice(0, 139) + "…" : d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What the forge says of a repository's own check beyond the controller's verdict, asked by index.ts only
|
||||||
|
* when the verdict is a warning: whether the head holds a merge-check.sh, and whether the base branch's
|
||||||
|
* protection requires mesh/repo-check. Unknown is undefined. */
|
||||||
|
export interface RepoCheckFacts {
|
||||||
|
defined?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// **What only the forge knows of a repository check said as a warning** (novox/hq issue 293): whether the
|
||||||
|
// head holds a merge-check.sh at all, and — when it does not — whether the base branch's protection
|
||||||
|
// requires mesh/repo-check. Asked only for a warning; unknown is left undefined, which statusesFor reads
|
||||||
|
// as possibly required: a failure on a status nothing requires blocks nothing, a success on one that is
|
||||||
|
// required would let an untested repository merge. The base's protection is the one the forge applies, read
|
||||||
|
// from the branch: the rules' own list is a repository administrator's, which the tools' account is not
|
||||||
|
// (novox/hq ADR 0365).
|
||||||
|
export async function repoCheckFacts(
|
||||||
|
forge: Pick<GiteaClient, "holdsFile" | "appliedProtection">, c: Checked, base: string | undefined): Promise<RepoCheckFacts> {
|
||||||
|
if (c["repo-check"]?.verdict !== "warning") return {};
|
||||||
|
const defined = await forge.holdsFile(c.owner, c.repo, c.commit, "merge-check.sh").catch(() => undefined);
|
||||||
|
if (defined !== false) return { defined };
|
||||||
|
const rule = await forge.appliedProtection(c.owner, c.repo, base || "main").catch(() => undefined);
|
||||||
|
if (rule === undefined) return { defined };
|
||||||
|
const required = !!rule?.enable_status_check && (rule.status_check_contexts ?? []).includes(REPO_CHECK_CONTEXT);
|
||||||
|
return { defined, required };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The description of a required repository check that is not defined. */
|
||||||
|
export const UNDEFINED_REQUIRED = "fail: mesh/repo-check is required here and this head defines no merge-check.sh — " +
|
||||||
|
"a person adds one, or lifts the requirement from the branch's protection";
|
||||||
|
|
||||||
|
/** The repository check's status: its verdict, unless the repository defines no check at all — then
|
||||||
|
* success where the check is not required, and a failure for a person where it is (or may be). */
|
||||||
|
function repoStatus(repo: Layer, facts: RepoCheckFacts): CommitStatus {
|
||||||
|
if (repo.verdict === "warning" && facts.defined === false) {
|
||||||
|
return facts.required === false
|
||||||
|
? { state: "success", context: REPO_CHECK_CONTEXT, description: "no repository check defined" }
|
||||||
|
: { state: "failure", context: REPO_CHECK_CONTEXT, description: clipped(UNDEFINED_REQUIRED) };
|
||||||
|
}
|
||||||
|
return { state: stateOf(repo.verdict), context: REPO_CHECK_CONTEXT, description: described(repo.verdict, repo.summary) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the repository check is a failure a person must read: failed, could not run, or required and
|
||||||
|
* not defined. */
|
||||||
|
function repoWrong(repo: Layer | undefined, facts: RepoCheckFacts): boolean {
|
||||||
|
return !!repo && repoStatus(repo, facts).state !== "success";
|
||||||
|
}
|
||||||
|
|
||||||
/** What a group member's gate says while the group's composed check is the one that judges it: the member's own check can say
|
/** What a group member's gate says while the group's composed check is the one that judges it: the member's own check can say
|
||||||
* nothing more of it, so asking that check again brings no verdict that names a trunk (novox/hq issue 1106). */
|
* nothing more of it, so asking that check again brings no verdict that names a trunk (novox/hq issue 1106). */
|
||||||
export const WAITS_FOR_COMPOSED = "waits for its group's composed check";
|
export const WAITS_FOR_COMPOSED = "waits for its group's composed check";
|
||||||
|
|
||||||
|
/** The forge's status for the gate. */
|
||||||
|
export function statusFor(c: Checked): CommitStatus {
|
||||||
|
const gate = c.gate ?? { verdict: c.verdict, summary: c.summary };
|
||||||
|
// With a plan, the status says what the change does and how it was judged: "pass: builds gitea → anchor;
|
||||||
|
// no bus step; every machine composes…".
|
||||||
|
const description = builds(c.plan)
|
||||||
|
? described(gate.verdict, `${c.plan!.summary}; ${gate.summary}`)
|
||||||
|
: described(gate.verdict, gate.summary, gate.modules, gate.dependents);
|
||||||
|
return judgedFirst(c, { state: stateOf(gate.verdict), context: CHECK_CONTEXT, description });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **A member's gate from its group's composed verdict** (novox/hq ADR 0403 §1, issue 978): the composed check, which
|
||||||
|
* composed every machine with all the members' heads, sets each member's `mesh/merge-gate` at the head it read. The
|
||||||
|
* description names what it judged first, as a member's own gate does (ADR 0323 §3), and the group.
|
||||||
|
*/
|
||||||
|
export function groupGateStatus(c: Checked, m: NonNullable<Checked["members"]>[number]): CommitStatus {
|
||||||
|
const said = described(c.verdict, `group ${c.group} composed: ${c.summary}`);
|
||||||
|
const first = m.trunk ? `head ${m.commit.slice(0, 12)} merged into trunk ${m.trunk.slice(0, 12)} — ` : "";
|
||||||
|
return { state: stateOf(c.verdict), context: CHECK_CONTEXT, description: clipped(first + said) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a verdict's gate layer is the group's composed check's (ADR 0403 §1): no gate was run for the head alone. */
|
||||||
|
export function composedGate(c: Checked): boolean {
|
||||||
|
return c.gate?.verdict === "composed";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every status a verdict sets: the gate's, and the repository's own check's when it was said; of them, only those the
|
||||||
|
* base requires (`required`, the base's rule: maintained). */
|
||||||
|
export function statusesFor(c: Checked, facts: RepoCheckFacts = {}, gateNow?: string, required?: string[]): CommitStatus[] {
|
||||||
|
const kept = maintained(required);
|
||||||
|
return allStatusesFor(c, facts, gateNow).filter((s) => kept.includes(s.context));
|
||||||
|
}
|
||||||
|
|
||||||
|
function allStatusesFor(c: Checked, facts: RepoCheckFacts = {}, gateNow?: string): CommitStatus[] {
|
||||||
|
if (composedGate(c)) {
|
||||||
|
// A member whose gate the group's composed check judges (ADR 0403 §1): only its repository's check is its own to
|
||||||
|
// set. The gate stays pending, saying it waits for the group, unless a composed verdict already stands on it.
|
||||||
|
const out: CommitStatus[] = [];
|
||||||
|
if (!(gateNow ?? "").includes(" composed: ")) {
|
||||||
|
out.push({ state: "pending", context: CHECK_CONTEXT, description: WAITS_FOR_COMPOSED });
|
||||||
|
}
|
||||||
|
const repo = c["repo-check"];
|
||||||
|
if (repo) out.push(judgedFirst(c, repoStatus(repo, facts)));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const out = [statusFor(c)];
|
||||||
|
const repo = c["repo-check"];
|
||||||
|
if (repo) out.push(judgedFirst(c, repoStatus(repo, facts)));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** **A status names what its verdict judged, first** (novox/hq ADR 0323 §3): "head 1a2b3c4d5e6f merged into trunk
|
||||||
|
* 7a8b9c0d1e2f — pass: …", so the forge's 140 characters never clip it away; the trunk's move is weighed by it. A
|
||||||
|
* verdict that names no trunk commit is said as before. */
|
||||||
|
function judgedFirst(c: Checked, status: CommitStatus): CommitStatus {
|
||||||
|
// The siblings it read, named after the commits (ADR 0382 §4), so the clip never takes them either.
|
||||||
|
const siblings = siblingsOf(c.siblings);
|
||||||
|
// A verdict that could not learn them says so on the status too (clause 1: it is still checked, and merges).
|
||||||
|
const mark = siblings.length ? besideWords(siblings) : c.siblings_unknown ? UNKNOWN_MARK : "";
|
||||||
|
if (!c.trunk) return mark ? { ...status, description: clipped(`${mark} — ${status.description}`) } : status;
|
||||||
|
return { ...status, description: clipped(`head ${c.commit.slice(0, 12)} merged into trunk ${c.trunk.slice(0, 12)}${mark ? " " + mark : ""} — ${status.description}`) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The comment a verdict leaves on its pull request, with the check's own account: the change plan of a
|
||||||
|
* change that builds something (novox/hq ADR 0238), and why, when the gate is not a pass or the
|
||||||
|
* repository's own check failed or could not run. A repository with no merge-check.sh, touching nothing,
|
||||||
|
* is said by its statuses alone, not by a comment on every push. */
|
||||||
|
export function commentFor(c: Checked, facts: RepoCheckFacts = {}, required?: string[]): string | null {
|
||||||
|
const gate = c.gate ?? { verdict: c.verdict, summary: c.summary };
|
||||||
|
// A base that no longer requires the gate (issue 1301) is told nothing of it: its verdict is the repository's own check.
|
||||||
|
const withGate = writesGate(required);
|
||||||
|
const repo = c["repo-check"];
|
||||||
|
const wrong = repoWrong(repo, facts);
|
||||||
|
if ((!withGate || gate.verdict === "pass") && !wrong && !builds(c.plan)) return null;
|
||||||
|
const lines = [`**Merge check** at \`${c.commit.slice(0, 8)}\``, ""];
|
||||||
|
if (c.trunk) {
|
||||||
|
// What was judged (novox/hq ADR 0323 §3): the head merged into the trunk commit, and what was read beside it.
|
||||||
|
const beside = Object.entries(c.beside ?? {}).map(([d, sha]) => `${d} \`${sha}\``).join(", ");
|
||||||
|
lines.splice(1, 0, `Judged: head \`${c.commit.slice(0, 12)}\` merged into trunk \`${c.trunk.slice(0, 12)}\`` +
|
||||||
|
(beside ? `; read beside it: ${beside}` : ""));
|
||||||
|
}
|
||||||
|
const siblings = siblingsOf(c.siblings);
|
||||||
|
if (siblings.length) {
|
||||||
|
lines.splice(c.trunk ? 2 : 1, 0, `Siblings read beside it: ` + siblings.map((s) =>
|
||||||
|
`${s.repository}#${s.number} \`${s.head.slice(0, 12)}\` merged onto \`${s.base_commit.slice(0, 12)}\` (${s.base})`).join(", ") +
|
||||||
|
(c.judge_from ? `; judged by ${c.judge_from}` : ""));
|
||||||
|
} else if (c.siblings_unknown) {
|
||||||
|
lines.splice(c.trunk ? 2 : 1, 0, `Siblings could not be learned: ${c.siblings_unknown}`);
|
||||||
|
}
|
||||||
|
if (withGate) {
|
||||||
|
lines.push(`- \`${CHECK_CONTEXT}\`: **${saidAs(gate.verdict).toUpperCase()}** — ${gate.summary}` +
|
||||||
|
(gate.modules?.length ? ` (modules: ${gate.modules.join(", ")}` +
|
||||||
|
(gate.dependents?.length ? `; built after them: ${gate.dependents.join(", ")}` : "") + ")" : ""));
|
||||||
|
}
|
||||||
|
if (repo) {
|
||||||
|
const st = repoStatus(repo, facts);
|
||||||
|
lines.push(`- \`${REPO_CHECK_CONTEXT}\`: ` + (repo.verdict === "warning" && facts.defined === false
|
||||||
|
? `**${st.state === "success" ? "PASS" : "FAIL"}** — ${st.description.replace(/^fail: /, "")}`
|
||||||
|
: `**${saidAs(repo.verdict).toUpperCase()}** — ${repo.summary}`));
|
||||||
|
}
|
||||||
|
if (builds(c.plan)) lines.push("", planText(c.plan!));
|
||||||
|
const ran = c.on ? `\n\nRun by the build seat on ${c.on} as \`${c.id}\` (\`builds --log ${c.id}\`).` : "";
|
||||||
|
const report = c.report ? `\n\n\`\`\`\n${c.report.replace(/```/g, "'''")}\n\`\`\`` : "";
|
||||||
|
return lines.join("\n") + ran + report;
|
||||||
|
}
|
||||||
|
|||||||
@@ -119,6 +119,48 @@ export function keepSiblings(file: string | null, record: SiblingRecord): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A member's latest verdict, kept: its siblings when it read some, else the entry removed. Answers whether it changed. */
|
||||||
|
export function noteVerdict(record: SiblingRecord, key: string, head: string, siblings: SiblingNamed[]): boolean {
|
||||||
|
if (siblings.length === 0) {
|
||||||
|
if (!(key in record)) return false;
|
||||||
|
delete record[key];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
record[key] = { head, siblings };
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One member of a composed verdict, as the group's composed check read it: its pull request, the head it judged, the
|
||||||
|
* trunk commit that head was merged onto, and the branch the pull request merges into. */
|
||||||
|
export interface ComposedMember {
|
||||||
|
owner: string;
|
||||||
|
repo: string;
|
||||||
|
number?: number;
|
||||||
|
commit: string;
|
||||||
|
trunk?: string;
|
||||||
|
base?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **A group's composed verdict names, for each member, the other members at the heads and base commits it composed**
|
||||||
|
* (novox/hq ADR 0403 §5, issue 1049): the composed check read every member's head together, so it is the verdict to trust
|
||||||
|
* for the siblings. Without this, the record held what a member's OWN verdict named, which went stale when a sibling got
|
||||||
|
* a new head; the sibling's own merge of the head the composed check judged then read as another head's merge, put the
|
||||||
|
* member's gate back to pending, and the group merged in two passes. A member the verdict cannot name (no pull request
|
||||||
|
* number, trunk commit or base branch), or a verdict of fewer than two such members, changes nothing. Answers whether
|
||||||
|
* the record changed.
|
||||||
|
*/
|
||||||
|
export function noteComposed(record: SiblingRecord, members: ComposedMember[]): boolean {
|
||||||
|
const named = members.filter((m) => m.owner && m.repo && m.number && m.commit && m.trunk && m.base);
|
||||||
|
if (named.length < 2 || named.length !== members.length) return false;
|
||||||
|
for (const m of named) {
|
||||||
|
const siblings = named.filter((o) => o !== m).map((o): SiblingNamed => ({
|
||||||
|
repository: `${o.owner}/${o.repo}`, number: o.number!, head: o.commit, base: o.base!, base_commit: o.trunk! }));
|
||||||
|
record[memberKey(m.owner, m.repo, m.number!)] = { head: m.commit, siblings };
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// --- what a sibling's move is ---------------------------------------------------------------------------------
|
// --- what a sibling's move is ---------------------------------------------------------------------------------
|
||||||
|
|
||||||
/** What weighing a sibling reads of the forge. */
|
/** What weighing a sibling reads of the forge. */
|
||||||
|
|||||||
@@ -221,58 +221,3 @@ test("gitea_commit_trees takes paths as {directory: [paths]} and refuses another
|
|||||||
server.close();
|
server.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("files: every blob under each asked directory, recursively, as its path relative to it with the blob's object id (ADR 0593 §2)", async () => {
|
|
||||||
const { client, server } = await serve([{ sha: COMMIT, url: "u", tree: LISTING, truncated: false, page: 1, total_count: LISTING.length }]);
|
|
||||||
try {
|
|
||||||
const got = await client.commitTrees("novox", "hq", COMMIT, undefined, ["modules/a", "modules/b", ""]);
|
|
||||||
assert.deepEqual(got.files, {
|
|
||||||
"modules/a": { "main.go": h("6"), "module.json": h("5") },
|
|
||||||
"modules/b": { "c/module.json": h("9") },
|
|
||||||
"": { "README.md": h("1"), "module.json": h("2"), "modules/a/module.json": h("5"), "modules/a/main.go": h("6"), "modules/b/c/module.json": h("9"), "modules/d/notes.md": h("b") },
|
|
||||||
});
|
|
||||||
assert.equal((await client.commitTrees("novox", "hq", COMMIT)).files, undefined, "no files asked, none answered");
|
|
||||||
} finally {
|
|
||||||
server.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a directory of files the commit lacks, or spelled badly, or a listing the forge cut, fails and answers nothing", async () => {
|
|
||||||
const { client, server } = await serve([{ sha: COMMIT, url: "u", tree: LISTING, truncated: false, page: 1, total_count: LISTING.length }]);
|
|
||||||
try {
|
|
||||||
await assert.rejects(client.commitTrees("novox", "hq", COMMIT, undefined, ["modules/a", "modules/gone"]), (err: Error) => {
|
|
||||||
assert.match(err.message, /modules\/gone/);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
await assert.rejects(client.commitTrees("novox", "hq", COMMIT, undefined, ["README.md"]), /README\.md/, "a file is no directory");
|
|
||||||
for (const bad of ["../x", "/abs", "a//b", "modules/a/", "."]) {
|
|
||||||
await assert.rejects(client.commitTrees("novox", "hq", COMMIT, undefined, [bad]), /files/, bad);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
server.close();
|
|
||||||
}
|
|
||||||
const cut = await serve([{ sha: COMMIT, tree: LISTING, truncated: true, page: 1, total_count: LISTING.length }]);
|
|
||||||
try {
|
|
||||||
await assert.rejects(cut.client.commitTrees("novox", "hq", COMMIT, undefined, ["modules/a"]), /cut/);
|
|
||||||
} finally {
|
|
||||||
cut.server.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("gitea_commit_trees takes files as a list of directories and refuses another shape before the forge is asked", async () => {
|
|
||||||
const { getGiteaTools } = await import("../tools/index.ts");
|
|
||||||
const { client, server, asked } = await serve([{ sha: COMMIT, url: "u", tree: LISTING, truncated: false, page: 1, total_count: LISTING.length }]);
|
|
||||||
try {
|
|
||||||
const tool = getGiteaTools(client).find((t) => t.name === "gitea_commit_trees")!;
|
|
||||||
assert.ok("files" in tool.input, "the tool names the argument");
|
|
||||||
const got: any = await tool.run({ owner: "novox", repo: "hq", sha: COMMIT, files: ["modules/a"] } as any);
|
|
||||||
assert.deepEqual(got.files, { "modules/a": { "main.go": h("6"), "module.json": h("5") } });
|
|
||||||
const before = asked.length;
|
|
||||||
for (const files of ["modules/a", { "modules/a": [] }, [1], ["../x"]]) {
|
|
||||||
await assert.rejects(tool.run({ owner: "novox", repo: "hq", sha: COMMIT, files } as any), /files/, JSON.stringify(files));
|
|
||||||
}
|
|
||||||
assert.equal(asked.length, before, "the forge was asked for a refused call");
|
|
||||||
} finally {
|
|
||||||
server.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
+258
-16
@@ -1,20 +1,32 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
|
|
||||||
// **One writer of a head's mesh/merge-gate** (novox/hq issue 446): the announcement's pending words, a recheck's pending and
|
// **A pending check says where it is, and the verdict lands last** (novox/hq issue 446). This module is the one writer
|
||||||
// a trunk's move, each write of a head in the order it was asked.
|
// of a head's mesh/merge-gate: the announcement's pending words, "building on <machine> since …" when the build seat
|
||||||
|
// says a machine took the check, and the verdict. The forge keeps the newest status of a context, so a pending word
|
||||||
|
// written after the verdict hides it — a pass reads pending, a failure hides under "building".
|
||||||
|
|
||||||
const SHA = "a6b08de8507c1703528277e5cefc80548dbc9bb3";
|
const SHA = "a6b08de8507c1703528277e5cefc80548dbc9bb3";
|
||||||
|
|
||||||
|
// The build seat's `started` as a machine said it for novox/hq#309's check on 2026-10-11 (its log, read with the
|
||||||
|
// controller's `builds --log build-1791680941048622697`, opens at 03:09:01 on shanks); only the forge's address
|
||||||
|
// replaced. The shape is mesh-controller's link.BuildStart.
|
||||||
|
const startedOnShanks = { id: "build-1791680941048622697", repository: "http://127.0.0.1:20000/novox/hq.git",
|
||||||
|
ref: SHA, on: "shanks", at: "2026-10-11T01:09:01.204319Z" };
|
||||||
|
|
||||||
/** A forge that keeps the newest status per context, as gitea does, and whose writes take as long as a test says. */
|
/** A forge that keeps the newest status per context, as gitea does, and whose writes take as long as a test says. */
|
||||||
function aForge() {
|
function aForge() {
|
||||||
const kept: Record<string, { state: string; description: string; updated_at: string }> = {};
|
const kept: Record<string, { state: string; description: string; updated_at: string }> = {};
|
||||||
const written: string[] = [];
|
const written: string[] = [];
|
||||||
|
/** Every mesh/merge-gate status the forge was given, newest first, as gitea keeps each one. */
|
||||||
|
const history: { state: string; description: string; updated_at: string }[] = [];
|
||||||
let failNext = 0;
|
let failNext = 0;
|
||||||
let hold: Promise<void> | null = null;
|
let hold: Promise<void> | null = null;
|
||||||
let release = () => {};
|
let release = () => {};
|
||||||
|
let readHold: Promise<void> | null = null;
|
||||||
|
let readRelease = () => {};
|
||||||
return {
|
return {
|
||||||
kept, written,
|
kept, written, history,
|
||||||
/** The next writes fail: a request the forge did not take. */
|
/** The next writes fail: a request the forge did not take. */
|
||||||
failWrites(n = 1) {
|
failWrites(n = 1) {
|
||||||
failNext = n;
|
failNext = n;
|
||||||
@@ -27,9 +39,6 @@ function aForge() {
|
|||||||
hold = null;
|
hold = null;
|
||||||
release();
|
release();
|
||||||
},
|
},
|
||||||
async headChecks() {
|
|
||||||
return {};
|
|
||||||
},
|
|
||||||
async setCommitStatus(_o: string, _r: string, _sha: string, s: { state: string; context: string; description: string }) {
|
async setCommitStatus(_o: string, _r: string, _sha: string, s: { state: string; context: string; description: string }) {
|
||||||
if (hold) await hold;
|
if (hold) await hold;
|
||||||
if (failNext > 0) {
|
if (failNext > 0) {
|
||||||
@@ -37,35 +46,226 @@ function aForge() {
|
|||||||
throw new Error("the forge answered 502");
|
throw new Error("the forge answered 502");
|
||||||
}
|
}
|
||||||
kept[s.context] = { state: s.state, description: s.description, updated_at: new Date().toISOString() };
|
kept[s.context] = { state: s.state, description: s.description, updated_at: new Date().toISOString() };
|
||||||
|
if (s.context === "mesh/merge-gate") history.unshift(kept[s.context]);
|
||||||
written.push(`${s.state} ${s.description}`);
|
written.push(`${s.state} ${s.description}`);
|
||||||
},
|
},
|
||||||
|
/** Reads answer what the forge held when asked, as late as the reads are held: the answer of a request in flight. */
|
||||||
|
holdReads() {
|
||||||
|
readHold = new Promise<void>((r) => (readRelease = r));
|
||||||
|
},
|
||||||
|
releaseReads() {
|
||||||
|
readHold = null;
|
||||||
|
readRelease();
|
||||||
|
},
|
||||||
|
async gateOf(_o: string, _r: string, _sha: string) {
|
||||||
|
const held = kept["mesh/merge-gate"] ? { ...kept["mesh/merge-gate"] } : undefined;
|
||||||
|
if (readHold) await readHold;
|
||||||
|
return held;
|
||||||
|
},
|
||||||
|
async gateHistory(_o: string, _r: string, _sha: string) {
|
||||||
|
return history.map((h) => ({ ...h }));
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// **A recheck's pending goes through the one writer** (novox/hq issue 456). mesh-delivery's recheck puts the gate back
|
/** The clock of the test: the head announced at 01:08:50Z. */
|
||||||
// to pending through the gitea_commit_status tool.
|
const announcedAt = () => new Date("2026-10-11T01:08:50Z");
|
||||||
|
|
||||||
|
test("a build machine taking the check is said on the head: building on which machine, since the local time", async () => {
|
||||||
|
const { GateWriter, buildingWords } = await import("../gate.ts");
|
||||||
|
const forge = aForge();
|
||||||
|
const gates = new GateWriter(forge, announcedAt);
|
||||||
|
await gates.pending("novox", "hq", SHA);
|
||||||
|
assert.equal(await gates.started("novox", "hq", SHA, startedOnShanks), true);
|
||||||
|
assert.equal(forge.kept["mesh/merge-gate"].state, "pending");
|
||||||
|
assert.equal(forge.kept["mesh/merge-gate"].description, buildingWords("shanks", new Date(startedOnShanks.at)));
|
||||||
|
assert.equal(buildingWords("shanks", new Date(2026, 9, 11, 3, 9, 1)), "building on shanks since 03:09");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the verdict heard while a 'building' write is in flight lands last and stays", async () => {
|
||||||
|
const { GateWriter } = await import("../gate.ts");
|
||||||
|
const forge = aForge();
|
||||||
|
const gates = new GateWriter(forge, announcedAt);
|
||||||
|
await gates.pending("novox", "hq", SHA);
|
||||||
|
forge.holdWrites(); // the 'building' request is in flight when the verdict comes
|
||||||
|
const building = gates.started("novox", "hq", SHA, startedOnShanks);
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
const verdict = gates.verdict("novox", "hq", SHA, () =>
|
||||||
|
forge.setCommitStatus("novox", "hq", SHA, { state: "success", context: "mesh/merge-gate", description: "pass" }));
|
||||||
|
forge.release();
|
||||||
|
await Promise.all([building, verdict]);
|
||||||
|
assert.equal(`${forge.kept["mesh/merge-gate"].state} ${forge.kept["mesh/merge-gate"].description}`, "success pass",
|
||||||
|
`the verdict is not the newest status: ${forge.written.join(" | ")}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The race the review of mesh-catalog#202 reproduced: a writer reads the forge's gate (pending), the verdict is written,
|
||||||
|
// then the writer writes "building" — and the pass reads pending until the next push. A head this process did not
|
||||||
|
// announce is the one case where the forge is read before writing.
|
||||||
|
test("the verdict written while a start's read of the forge is in flight stays the newest status", async () => {
|
||||||
|
const { GateWriter } = await import("../gate.ts");
|
||||||
|
const forge = aForge();
|
||||||
|
forge.kept["mesh/merge-gate"] = { state: "pending", description: "waiting", updated_at: "2026-10-11T01:08:50Z" };
|
||||||
|
const gates = new GateWriter(forge, announcedAt);
|
||||||
|
forge.holdReads();
|
||||||
|
const building = gates.started("novox", "hq", SHA, startedOnShanks);
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
const verdict = gates.verdict("novox", "hq", SHA, () =>
|
||||||
|
forge.setCommitStatus("novox", "hq", SHA, { state: "success", context: "mesh/merge-gate", description: "pass" }));
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
forge.releaseReads();
|
||||||
|
await Promise.all([building, verdict]);
|
||||||
|
assert.equal(`${forge.kept["mesh/merge-gate"].state} ${forge.kept["mesh/merge-gate"].description}`, "success pass",
|
||||||
|
`the verdict is not the newest status: ${forge.written.join(" | ")}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the verdict heard while a 'building' write still waits its turn: the 'building' is never written", async () => {
|
||||||
|
const { GateWriter } = await import("../gate.ts");
|
||||||
|
const forge = aForge();
|
||||||
|
const gates = new GateWriter(forge, announcedAt);
|
||||||
|
forge.holdWrites(); // the pending words are still being written
|
||||||
|
const pending = gates.pending("novox", "hq", SHA);
|
||||||
|
const building = gates.started("novox", "hq", SHA, startedOnShanks);
|
||||||
|
const verdict = gates.verdict("novox", "hq", SHA, () =>
|
||||||
|
forge.setCommitStatus("novox", "hq", SHA, { state: "failure", context: "mesh/merge-gate", description: "fail: 0 of 4 compose" }));
|
||||||
|
forge.release();
|
||||||
|
await Promise.all([pending, verdict]);
|
||||||
|
assert.equal(await building, false);
|
||||||
|
assert.equal(forge.kept["mesh/merge-gate"].state, "failure");
|
||||||
|
assert.ok(!forge.written.some((w) => w.includes("building")), forge.written.join(" | "));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a start heard after the verdict writes nothing", async () => {
|
||||||
|
const { GateWriter } = await import("../gate.ts");
|
||||||
|
const forge = aForge();
|
||||||
|
const gates = new GateWriter(forge, announcedAt);
|
||||||
|
await gates.pending("novox", "hq", SHA);
|
||||||
|
await gates.verdict("novox", "hq", SHA, () =>
|
||||||
|
forge.setCommitStatus("novox", "hq", SHA, { state: "success", context: "mesh/merge-gate", description: "pass" }));
|
||||||
|
assert.equal(await gates.started("novox", "hq", SHA, startedOnShanks), false);
|
||||||
|
assert.equal(forge.kept["mesh/merge-gate"].state, "success");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a start from before the check now pending (a run before a recheck, the stream's history) writes nothing", async () => {
|
||||||
|
const { GateWriter } = await import("../gate.ts");
|
||||||
|
const forge = aForge();
|
||||||
|
let now = announcedAt();
|
||||||
|
const gates = new GateWriter(forge, () => now);
|
||||||
|
await gates.pending("novox", "hq", SHA);
|
||||||
|
await gates.verdict("novox", "hq", SHA, () =>
|
||||||
|
forge.setCommitStatus("novox", "hq", SHA, { state: "failure", context: "mesh/merge-gate", description: "error" }));
|
||||||
|
now = new Date("2026-10-11T01:30:00Z"); // checked again: the recheck's pending, as the tool writes it (issue 456)
|
||||||
|
await gates.rechecked("novox", "hq", SHA, { state: "pending", context: "mesh/merge-gate", description: "checking again: flaky" });
|
||||||
|
assert.equal(await gates.started("novox", "hq", SHA, startedOnShanks), false, "the first run's start");
|
||||||
|
assert.equal(forge.kept["mesh/merge-gate"].description, "checking again: flaky");
|
||||||
|
assert.equal(await gates.started("novox", "hq", SHA, { ...startedOnShanks, at: "2026-10-11T01:31:00Z" }), true,
|
||||||
|
"the second run's start");
|
||||||
|
});
|
||||||
|
|
||||||
|
// **A recheck's pending goes through the one writer** (novox/hq issue 456). mesh-delivery's recheck puts the gate back
|
||||||
|
// to pending through the gitea_commit_status tool; before, that write bypassed the head's chain: the first run's
|
||||||
|
// verdict mark stayed set, so the new run never said "building", and an old run's verdict could land over the pending.
|
||||||
|
|
||||||
|
/** The ask of a check, as the controller's link.NewBuildID names it: when it was asked, in unix nanoseconds. */
|
||||||
|
const askOf = (at: string) => `build-${BigInt(Date.parse(at)) * 1_000_000n}`;
|
||||||
|
const firstRun = askOf("2026-10-11T01:09:01Z");
|
||||||
const recheckAt = new Date("2026-10-11T01:30:00Z");
|
const recheckAt = new Date("2026-10-11T01:30:00Z");
|
||||||
|
const secondRun = askOf("2026-10-11T01:30:02Z");
|
||||||
const checkingAgain = { state: "pending" as const, context: "mesh/merge-gate", description: "checking again: main moved on" };
|
const checkingAgain = { state: "pending" as const, context: "mesh/merge-gate", description: "checking again: main moved on" };
|
||||||
|
const writeOf = (forge: ReturnType<typeof aForge>, state: string, description: string) => () =>
|
||||||
|
forge.setCommitStatus("novox", "hq", SHA, { state, context: "mesh/merge-gate", description });
|
||||||
const gateOf = (forge: ReturnType<typeof aForge>) =>
|
const gateOf = (forge: ReturnType<typeof aForge>) =>
|
||||||
`${forge.kept["mesh/merge-gate"].state} ${forge.kept["mesh/merge-gate"].description}`;
|
`${forge.kept["mesh/merge-gate"].state} ${forge.kept["mesh/merge-gate"].description}`;
|
||||||
|
|
||||||
test("a repository as the mesh clones it is told from another", async () => {
|
test("an old run's verdict redelivered after a recheck's pending is not written over it", async () => {
|
||||||
const { clonedAs } = await import("../gate.ts");
|
const { GateWriter } = await import("../gate.ts");
|
||||||
assert.equal(clonedAs("http://127.0.0.1:20000/novox/hq.git", "novox", "hq"), true);
|
const forge = aForge();
|
||||||
assert.equal(clonedAs("http://127.0.0.1:20000/novox/mesh-hq.git", "novox", "hq"), false);
|
let now = announcedAt();
|
||||||
assert.equal(clonedAs("http://127.0.0.1:20000/novox/mesh-controller.git", "novox", "hq"), false);
|
const gates = new GateWriter(forge, () => now);
|
||||||
|
await gates.pending("novox", "hq", SHA);
|
||||||
|
forge.failWrites(); // the first run's verdict is not taken: the event is offered again
|
||||||
|
await assert.rejects(gates.verdict("novox", "hq", SHA, writeOf(forge, "success", "pass"), firstRun));
|
||||||
|
now = recheckAt;
|
||||||
|
await gates.rechecked("novox", "hq", SHA, checkingAgain);
|
||||||
|
await gates.verdict("novox", "hq", SHA, writeOf(forge, "success", "pass"), firstRun); // redelivered
|
||||||
|
assert.equal(gateOf(forge), "pending checking again: main moved on", forge.written.join(" | "));
|
||||||
|
// The recheck's own verdict is written.
|
||||||
|
await gates.verdict("novox", "hq", SHA, writeOf(forge, "failure", "fail: 0 of 4 compose"), secondRun);
|
||||||
|
assert.equal(gateOf(forge), "failure fail: 0 of 4 compose");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a recheck the forge does not take fails its caller, and the next write still takes its turn", async () => {
|
test("an old run's verdict in flight when the recheck comes lands before the recheck's pending", async () => {
|
||||||
|
const { GateWriter } = await import("../gate.ts");
|
||||||
|
const forge = aForge();
|
||||||
|
let now = announcedAt();
|
||||||
|
const gates = new GateWriter(forge, () => now);
|
||||||
|
await gates.pending("novox", "hq", SHA);
|
||||||
|
forge.holdWrites(); // the first run's verdict is being written when the recheck comes
|
||||||
|
const verdict = gates.verdict("novox", "hq", SHA, writeOf(forge, "success", "pass"), firstRun);
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
now = recheckAt;
|
||||||
|
const pending = gates.rechecked("novox", "hq", SHA, checkingAgain);
|
||||||
|
forge.release();
|
||||||
|
await Promise.all([verdict, pending]);
|
||||||
|
assert.equal(gateOf(forge), "pending checking again: main moved on", forge.written.join(" | "));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a rechecked head says where its new run is: building on which machine", async () => {
|
||||||
|
const { GateWriter, buildingWords } = await import("../gate.ts");
|
||||||
|
const forge = aForge();
|
||||||
|
let now = announcedAt();
|
||||||
|
const gates = new GateWriter(forge, () => now);
|
||||||
|
await gates.pending("novox", "hq", SHA);
|
||||||
|
await gates.verdict("novox", "hq", SHA, writeOf(forge, "success", "pass"), firstRun);
|
||||||
|
now = recheckAt;
|
||||||
|
await gates.rechecked("novox", "hq", SHA, checkingAgain);
|
||||||
|
const at = "2026-10-11T01:30:03Z";
|
||||||
|
assert.equal(await gates.started("novox", "hq", SHA, { ...startedOnShanks, id: secondRun, at }), true);
|
||||||
|
assert.equal(forge.kept["mesh/merge-gate"].description, buildingWords("shanks", new Date(at)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the recheck's own verdict is written, even asked a little before the pending by another machine's clock", async () => {
|
||||||
const { GateWriter } = await import("../gate.ts");
|
const { GateWriter } = await import("../gate.ts");
|
||||||
const forge = aForge();
|
const forge = aForge();
|
||||||
const gates = new GateWriter(forge, () => recheckAt);
|
const gates = new GateWriter(forge, () => recheckAt);
|
||||||
|
await gates.rechecked("novox", "hq", SHA, checkingAgain);
|
||||||
|
await gates.verdict("novox", "hq", SHA, writeOf(forge, "success", "pass"), askOf("2026-10-11T01:29:30Z"));
|
||||||
|
assert.equal(gateOf(forge), "success pass");
|
||||||
|
// A verdict whose ask cannot be read is written: a verdict is never lost to what cannot be weighed.
|
||||||
|
await gates.rechecked("novox", "hq", SHA, checkingAgain);
|
||||||
|
await gates.verdict("novox", "hq", SHA, writeOf(forge, "failure", "fail"), "an id of another shape");
|
||||||
|
assert.equal(gateOf(forge), "failure fail");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("after a restart, an old run's verdict is weighed against the check the forge's history holds open", async () => {
|
||||||
|
const { GateWriter } = await import("../gate.ts");
|
||||||
|
const forge = aForge();
|
||||||
|
// What the forge kept before this process started: the announcement, the first run's verdict never written,
|
||||||
|
// the recheck's pending, and the second run's "building" over it.
|
||||||
|
forge.history.unshift({ state: "pending", description: "waiting in the build queue since 03:08", updated_at: "2026-10-11T01:08:50Z" });
|
||||||
|
forge.history.unshift({ state: "pending", description: "checking again: main moved on", updated_at: "2026-10-11T01:30:00Z" });
|
||||||
|
forge.history.unshift({ state: "pending", description: "building on shanks since 03:30", updated_at: "2026-10-11T01:30:05Z" });
|
||||||
|
forge.kept["mesh/merge-gate"] = forge.history[0];
|
||||||
|
const gates = new GateWriter(forge, announcedAt);
|
||||||
|
await gates.verdict("novox", "hq", SHA, writeOf(forge, "success", "pass"), firstRun);
|
||||||
|
assert.equal(forge.kept["mesh/merge-gate"].description, "building on shanks since 03:30", forge.written.join(" | "));
|
||||||
|
await gates.verdict("novox", "hq", SHA, writeOf(forge, "failure", "fail"), secondRun);
|
||||||
|
assert.equal(gateOf(forge), "failure fail");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a recheck the forge does not take fails its caller, and the head is as it was", async () => {
|
||||||
|
const { GateWriter } = await import("../gate.ts");
|
||||||
|
const forge = aForge();
|
||||||
|
let now = announcedAt();
|
||||||
|
const gates = new GateWriter(forge, () => now);
|
||||||
await gates.pending("novox", "hq", SHA);
|
await gates.pending("novox", "hq", SHA);
|
||||||
forge.failWrites();
|
forge.failWrites();
|
||||||
|
await assert.rejects(gates.verdict("novox", "hq", SHA, writeOf(forge, "success", "pass"), firstRun));
|
||||||
|
now = recheckAt;
|
||||||
|
forge.failWrites();
|
||||||
await assert.rejects(gates.rechecked("novox", "hq", SHA, checkingAgain));
|
await assert.rejects(gates.rechecked("novox", "hq", SHA, checkingAgain));
|
||||||
await gates.rechecked("novox", "hq", SHA, checkingAgain);
|
// The recheck was refused, so its check is not asked: the first run's verdict, offered again, is the head's.
|
||||||
assert.equal(gateOf(forge), "pending checking again: main moved on");
|
await gates.verdict("novox", "hq", SHA, writeOf(forge, "success", "pass"), firstRun);
|
||||||
|
assert.equal(gateOf(forge), "success pass");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the status tool writes the merge check's pending through the writer, and the mesh's other statuses as they were", async () => {
|
test("the status tool writes the merge check's pending through the writer, and the mesh's other statuses as they were", async () => {
|
||||||
@@ -78,15 +278,57 @@ test("the status tool writes the merge check's pending through the writer, and t
|
|||||||
setCommitStatus: async (_o, _r, _sha, s) => void direct.push(`${s.context} ${s.state}`),
|
setCommitStatus: async (_o, _r, _sha, s) => void direct.push(`${s.context} ${s.state}`),
|
||||||
gates,
|
gates,
|
||||||
});
|
});
|
||||||
|
await gates.verdict("novox", "hq", SHA, writeOf(forge, "success", "pass"), firstRun);
|
||||||
await tool.run({ owner: "novox", repo: "hq", sha: SHA, context: "mesh/merge-gate", state: "pending", description: "checking again: x" });
|
await tool.run({ owner: "novox", repo: "hq", sha: SHA, context: "mesh/merge-gate", state: "pending", description: "checking again: x" });
|
||||||
await tool.run({ owner: "novox", repo: "hq", sha: SHA, context: "mesh/repo-check", state: "pending", description: "checking again: x" });
|
await tool.run({ owner: "novox", repo: "hq", sha: SHA, context: "mesh/repo-check", state: "pending", description: "checking again: x" });
|
||||||
await tool.run({ owner: "novox", repo: "hq", sha: SHA, context: "mesh/delivery", state: "success", description: "delivered" });
|
await tool.run({ owner: "novox", repo: "hq", sha: SHA, context: "mesh/delivery", state: "success", description: "delivered" });
|
||||||
assert.equal(gateOf(forge), "pending checking again: x");
|
assert.equal(gateOf(forge), "pending checking again: x");
|
||||||
assert.equal(forge.kept["mesh/repo-check"].state, "pending");
|
assert.equal(forge.kept["mesh/repo-check"].state, "pending");
|
||||||
assert.deepEqual(direct, ["mesh/delivery success"]);
|
assert.deepEqual(direct, ["mesh/delivery success"]);
|
||||||
|
// The redelivered first run's verdict is the writer's to drop.
|
||||||
|
await gates.verdict("novox", "hq", SHA, writeOf(forge, "success", "pass"), firstRun);
|
||||||
|
assert.equal(gateOf(forge), "pending checking again: x");
|
||||||
// A head named short is another head to the writer: refused, nothing sent.
|
// A head named short is another head to the writer: refused, nothing sent.
|
||||||
await assert.rejects(tool.run({ owner: "novox", repo: "hq", sha: SHA.slice(0, 12), context: "mesh/merge-gate", state: "pending" }),
|
await assert.rejects(tool.run({ owner: "novox", repo: "hq", sha: SHA.slice(0, 12), context: "mesh/merge-gate", state: "pending" }),
|
||||||
/whole sha/);
|
/whole sha/);
|
||||||
await assert.rejects(tool.run({ owner: "novox", repo: "hq", sha: SHA, context: "mesh/merge-gate", state: "success" }),
|
await assert.rejects(tool.run({ owner: "novox", repo: "hq", sha: SHA, context: "mesh/merge-gate", state: "success" }),
|
||||||
/set by its verdict only/);
|
/set by its verdict only/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("when a check was asked is read from its id; the opening of the check now pending from the gate's history", async () => {
|
||||||
|
const { askedAt, openedAt } = await import("../gate.ts");
|
||||||
|
assert.equal(askedAt("build-1791680941048622697"), Date.parse("2026-10-11T01:09:01.048Z"));
|
||||||
|
assert.equal(askedAt("build-mesh-catalog"), undefined);
|
||||||
|
assert.equal(askedAt(undefined), undefined);
|
||||||
|
assert.equal(openedAt([{ state: "pending", description: "building on ace since 03:31", updated_at: "2026-10-11T01:31:00Z" },
|
||||||
|
{ state: "pending", description: "checking again: x", updated_at: "2026-10-11T01:30:00Z" }]), Date.parse("2026-10-11T01:30:00Z"));
|
||||||
|
assert.equal(openedAt([{ state: "success", description: "pass", updated_at: "2026-10-11T01:30:00Z" }]), undefined);
|
||||||
|
assert.equal(openedAt([]), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a head this process did not announce (it restarted) is said only while the forge holds its gate pending", async () => {
|
||||||
|
const { GateWriter } = await import("../gate.ts");
|
||||||
|
const forge = aForge();
|
||||||
|
forge.kept["mesh/merge-gate"] = { state: "success", description: "pass", updated_at: "2026-10-11T01:20:00Z" };
|
||||||
|
const gates = new GateWriter(forge, announcedAt);
|
||||||
|
assert.equal(await gates.started("novox", "hq", SHA, startedOnShanks), false);
|
||||||
|
forge.kept["mesh/merge-gate"] = { state: "pending", description: "waiting", updated_at: "2026-10-11T01:08:50Z" };
|
||||||
|
assert.equal(await gates.started("novox", "hq", SHA, startedOnShanks), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a repository as the mesh clones it is told from another", async () => {
|
||||||
|
const { clonedAs } = await import("../gate.ts");
|
||||||
|
assert.equal(clonedAs("http://127.0.0.1:20000/novox/hq.git", "novox", "hq"), true);
|
||||||
|
assert.equal(clonedAs("http://127.0.0.1:20000/novox/mesh-hq.git", "novox", "hq"), false);
|
||||||
|
assert.equal(clonedAs("http://127.0.0.1:20000/novox/mesh-controller.git", "novox", "hq"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Issue 1301: a build machine's start on a head that holds no mesh/merge-gate (its base requires none, so none was written)
|
||||||
|
// says nothing: no "building on" status appears on a main that does not require the gate.
|
||||||
|
test("a start on a head with no gate on the forge writes nothing", async () => {
|
||||||
|
const { GateWriter } = await import("../gate.ts");
|
||||||
|
const forge = aForge();
|
||||||
|
const gates = new GateWriter(forge, announcedAt);
|
||||||
|
assert.equal(await gates.started("novox", "hq", SHA, startedOnShanks), false);
|
||||||
|
assert.equal(forge.kept["mesh/merge-gate"], undefined);
|
||||||
|
});
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ const client = new GiteaClient(`http://127.0.0.1:${port}`, new ConfiguredToken("
|
|||||||
const stateDir = mkdtempSync(join(tmpdir(), "gitea-tools-"));
|
const stateDir = mkdtempSync(join(tmpdir(), "gitea-tools-"));
|
||||||
// The merge check's one writer, as the watcher's bundle holds it: what the merge tool's pending goes through.
|
// The merge check's one writer, as the watcher's bundle holds it: what the merge tool's pending goes through.
|
||||||
const writer = new GateWriter({ setCommitStatus: (o, r, sha, s) => client.setCommitStatus(o, r, sha, s),
|
const writer = new GateWriter({ setCommitStatus: (o, r, sha, s) => client.setCommitStatus(o, r, sha, s),
|
||||||
headChecks: async () => ({}) });
|
gateOf: async () => undefined, gateHistory: async () => [], headChecks: async () => ({}) });
|
||||||
const throughTheWriter: string[] = [];
|
const throughTheWriter: string[] = [];
|
||||||
const gates = { rechecked: (o: string, r: string, sha: string, s: any) => {
|
const gates = { rechecked: (o: string, r: string, sha: string, s: any) => {
|
||||||
throughTheWriter.push(`${sha.slice(0, 12)} ${s.context} ${s.state}`);
|
throughTheWriter.push(`${sha.slice(0, 12)} ${s.context} ${s.state}`);
|
||||||
@@ -298,15 +298,18 @@ test("gitea_merge_pull_request merges only a head whose required statuses all su
|
|||||||
// **The merge tool refuses a verdict whose trunk commit is not the trunk's head, and asks the check itself** (novox/hq
|
// **The merge tool refuses a verdict whose trunk commit is not the trunk's head, and asks the check itself** (novox/hq
|
||||||
// ADR 0323 §5, issue 341): node-engine #66 merged at 00:54 on a verdict of 00:49 that judged its head alone, after #65
|
// ADR 0323 §5, issue 341): node-engine #66 merged at 00:54 on a verdict of 00:49 that judged its head alone, after #65
|
||||||
// had moved the trunk. Both commits are named; one check is asked, the announcement a new head makes, and not a second
|
// had moved the trunk. Both commits are named; one check is asked, the announcement a new head makes, and not a second
|
||||||
// while it is on its way; and once the verdict judges
|
// while it is on its way; the refusal says how long the last check of the repository took; and once the verdict judges
|
||||||
// the trunk's head and passes, the merge goes through — asked again, never by itself.
|
// the trunk's head and passes, the merge goes through — asked again, never by itself.
|
||||||
test("gitea_merge_pull_request refuses a verdict judged against an earlier trunk, and asks the check once", async () => {
|
test("gitea_merge_pull_request refuses a verdict judged against an earlier trunk, and asks the check once", async () => {
|
||||||
const merge = () => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED });
|
const merge = () => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED });
|
||||||
|
const { keepTook, tookFile } = await import("../dist/trunk.js");
|
||||||
|
keepTook(tookFile(stateDir), "novox/mesh-catalog", { took: "4m31s", at: "2026-10-12T14:49:00Z" });
|
||||||
|
|
||||||
state.trunk = MOVED; // another merge moved the trunk after the verdict
|
state.trunk = MOVED; // another merge moved the trunk after the verdict
|
||||||
await assert.rejects(merge(), (err: Error) => {
|
await assert.rejects(merge(), (err: Error) => {
|
||||||
assert.match(err.message, /is not merged: its verdict was judged against trunk 7a8b9c0d1e2f, and main is now 3c4d5e6f7a8b/);
|
assert.match(err.message, /is not merged: its verdict was judged against trunk 7a8b9c0d1e2f, and main is now 3c4d5e6f7a8b/);
|
||||||
assert.match(err.message, /check of head abc1234def56 merged into trunk 3c4d5e6f7a8b was asked/);
|
assert.match(err.message, /check of head abc1234def56 merged into trunk 3c4d5e6f7a8b was asked/);
|
||||||
|
assert.match(err.message, /the last check of novox\/mesh-catalog took 4m31s/);
|
||||||
assert.match(err.message, /never merges by itself/);
|
assert.match(err.message, /never merges by itself/);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
@@ -326,7 +329,7 @@ test("gitea_merge_pull_request refuses a verdict judged against an earlier trunk
|
|||||||
assert.equal(state.statuses.find((s) => s.context === "mesh/repo-check")!.state, "pending");
|
assert.equal(state.statuses.find((s) => s.context === "mesh/repo-check")!.state, "pending");
|
||||||
|
|
||||||
// Asked again while that check is on its way: refused, and nothing is asked a second time.
|
// Asked again while that check is on its way: refused, and nothing is asked a second time.
|
||||||
await assert.rejects(merge(), /already asked and not yet answered .*not asked again.*mesh-delivery\.recheck/);
|
await assert.rejects(merge(), /already asked and not yet answered .*not asked again; the last check of novox\/mesh-catalog took 4m31s.*mesh-delivery\.recheck/);
|
||||||
assert.equal(state.emitted.length, 1, "no second check while one is on its way");
|
assert.equal(state.emitted.length, 1, "no second check while one is on its way");
|
||||||
state.statuses = [{ ...gate, description: "building on ace since 15:02" }, ...state.statuses.slice(1)];
|
state.statuses = [{ ...gate, description: "building on ace since 15:02" }, ...state.statuses.slice(1)];
|
||||||
await assert.rejects(merge(), /not asked again/);
|
await assert.rejects(merge(), /not asked again/);
|
||||||
@@ -731,7 +734,7 @@ test("gitea_merge_pull_request refuses a head the Approve did not name", async (
|
|||||||
// the sibling merged that head, and the member then merges on its first ask: the sibling's own merge is no move of its
|
// the sibling merged that head, and the member then merges on its first ask: the sibling's own merge is no move of its
|
||||||
// base. The records are the module's own (siblings.ts), the weighing is what the sibling's merge announcement does.
|
// base. The records are the module's own (siblings.ts), the weighing is what the sibling's merge announcement does.
|
||||||
test("a group of two merges in one pass: the sibling's merge of the composed head does not stop the member's merge (issue 1049)", async () => {
|
test("a group of two merges in one pass: the sibling's merge of the composed head does not stop the member's merge (issue 1049)", async () => {
|
||||||
const { keepSiblings, siblingsFile, weighSiblings } = await import("../dist/siblings.js");
|
const { keepSiblings, siblingsFile, noteComposed, weighSiblings } = await import("../dist/siblings.js");
|
||||||
const both = ["novox/mesh-catalog@" + REVIEWED, "novox/mesh-host@" + SIBLING_HEAD];
|
const both = ["novox/mesh-catalog@" + REVIEWED, "novox/mesh-host@" + SIBLING_HEAD];
|
||||||
const OLD_SIBLING = "9".repeat(40), HOST_BASE = "c".repeat(40), HOST_MERGE = "f".repeat(40);
|
const OLD_SIBLING = "9".repeat(40), HOST_BASE = "c".repeat(40), HOST_MERGE = "f".repeat(40);
|
||||||
const beside = `head ${REVIEWED.slice(0, 12)} merged into trunk ${TRUNK.slice(0, 12)} beside mesh-host#114@${OLD_SIBLING.slice(0, 8)}`;
|
const beside = `head ${REVIEWED.slice(0, 12)} merged into trunk ${TRUNK.slice(0, 12)} beside mesh-host#114@${OLD_SIBLING.slice(0, 8)}`;
|
||||||
@@ -744,9 +747,8 @@ test("a group of two merges in one pass: the sibling's merge of the composed hea
|
|||||||
const record: Record<string, any> = { "novox/mesh-catalog#7": { head: REVIEWED,
|
const record: Record<string, any> = { "novox/mesh-catalog#7": { head: REVIEWED,
|
||||||
siblings: [{ repository: "novox/mesh-host", number: 114, head: OLD_SIBLING, base: "main", base_commit: HOST_BASE }] } };
|
siblings: [{ repository: "novox/mesh-host", number: 114, head: OLD_SIBLING, base: "main", base_commit: HOST_BASE }] } };
|
||||||
// The composed verdict then judged both new heads.
|
// The composed verdict then judged both new heads.
|
||||||
Object.assign(record, {
|
noteComposed(record, [{ owner: "novox", repo: "mesh-catalog", number: 7, commit: REVIEWED, trunk: TRUNK.slice(0, 12), base: "main" },
|
||||||
"novox/mesh-catalog#7": { head: REVIEWED, siblings: [{ repository: "novox/mesh-host", number: 114, head: SIBLING_HEAD, base: "main", base_commit: HOST_BASE.slice(0, 12) }] },
|
{ owner: "novox", repo: "mesh-host", number: 114, commit: SIBLING_HEAD, trunk: HOST_BASE.slice(0, 12), base: "main" }]);
|
||||||
"novox/mesh-host#114": { head: SIBLING_HEAD, siblings: [{ repository: "novox/mesh-catalog", number: 7, head: REVIEWED, base: "main", base_commit: TRUNK.slice(0, 12) }] } });
|
|
||||||
// The sibling merges its head; its announcement weighs the member's record. The member's gate is written only by what
|
// The sibling merges its head; its announcement weighs the member's record. The member's gate is written only by what
|
||||||
// that weighing decides, through the writer.
|
// that weighing decides, through the writer.
|
||||||
const pulled = { number: 114, title: "", html_url: "", merged: true, state: "closed", head_sha: SIBLING_HEAD, merge_commit_sha: HOST_MERGE };
|
const pulled = { number: 114, title: "", html_url: "", merged: true, state: "closed", head_sha: SIBLING_HEAD, merge_commit_sha: HOST_MERGE };
|
||||||
|
|||||||
@@ -29,6 +29,92 @@ test("a pull request retargeted keeps its head and is announced again, into its
|
|||||||
assert.notEqual(headKey("novox/de-spiegel", 6, "main"), headKey("novox/de-spiegel", 6, "old-trunk"));
|
assert.notEqual(headKey("novox/de-spiegel", 6, "main"), headKey("novox/de-spiegel", 6, "old-trunk"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a verdict is the head commit's status; an error is the forge's error, never a success", async () => {
|
||||||
|
const { statusFor, commentFor, CHECK_CONTEXT } = await import("../pulls.ts");
|
||||||
|
const base = { owner: "novox", repo: "mesh-catalog", number: 7, commit: "0123456789abcdef", id: "build-1", on: "laptop" };
|
||||||
|
assert.equal(statusFor({ ...base, verdict: "pass", summary: "every machine composes" }).state, "success");
|
||||||
|
// A warning is a note, never a blocking state: the forge combines `warning` as a failure (issue 293).
|
||||||
|
const wide = statusFor({ ...base, verdict: "warning", summary: "a merge rebuilds 14 module(s)" });
|
||||||
|
assert.equal(wide.state, "success");
|
||||||
|
assert.match(wide.description, /^pass, with a note: a merge rebuilds 14 module/);
|
||||||
|
assert.equal(statusFor({ ...base, verdict: "fail", summary: "x" }).state, "failure");
|
||||||
|
assert.equal(statusFor({ ...base, verdict: "error", summary: "the check could not run" }).state, "error");
|
||||||
|
assert.equal(statusFor({ ...base, verdict: "", summary: "" }).state, "error", "no verdict is no pass");
|
||||||
|
const long = statusFor({ ...base, verdict: "fail", summary: "y".repeat(500) });
|
||||||
|
assert.ok(long.description.length <= 140 && long.context === CHECK_CONTEXT);
|
||||||
|
assert.equal(commentFor({ ...base, verdict: "pass", summary: "fine" }), null, "a pass leaves no comment");
|
||||||
|
const said = commentFor({ ...base, verdict: "fail", summary: "lemurs refused", report: "fails:\n - ```x```" }) ?? "";
|
||||||
|
assert.match(said, /mesh\/merge-gate`: \*\*FAIL\*\*/);
|
||||||
|
assert.match(said, /01234567/);
|
||||||
|
assert.match(said, /builds --log build-1/);
|
||||||
|
assert.ok(!said.slice(said.indexOf("```") + 3, said.lastIndexOf("```")).includes("```"), "the report cannot close its own block");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("each layer is its own status: the gate with the modules it judged, the repository's own check beside it", async () => {
|
||||||
|
const { statusesFor, commentFor, CHECK_CONTEXT, REPO_CHECK_CONTEXT } = await import("../pulls.ts");
|
||||||
|
const base = { owner: "novox", repo: "mesh-catalog", number: 7, commit: "0123456789abcdef", id: "build-1" };
|
||||||
|
const both = statusesFor({ ...base, verdict: "pass", summary: "every machine composes",
|
||||||
|
gate: { verdict: "pass", summary: "every machine composes", modules: ["gitea", "keycloak"], dependents: ["node-tools"] },
|
||||||
|
"repo-check": { verdict: "fail", summary: "its merge-check.sh failed: FAIL x" } });
|
||||||
|
assert.deepEqual(both.map((s) => [s.context, s.state]), [[CHECK_CONTEXT, "success"], [REPO_CHECK_CONTEXT, "failure"]]);
|
||||||
|
assert.match(both[0].description, /gitea, keycloak \+1 dependent/);
|
||||||
|
assert.match(commentFor({ ...base, verdict: "pass", summary: "", gate: { verdict: "pass", summary: "" },
|
||||||
|
"repo-check": { verdict: "fail", summary: "its merge-check.sh failed" } }) ?? "", /mesh\/repo-check`: \*\*FAIL/);
|
||||||
|
|
||||||
|
// Nothing of the graph touched, no script: a pass and a warning, and no comment on every push.
|
||||||
|
const quiet = { ...base, verdict: "pass", summary: "the change touches no module of the mesh's graph",
|
||||||
|
gate: { verdict: "pass", summary: "the change touches no module of the mesh's graph" },
|
||||||
|
"repo-check": { verdict: "warning", summary: "the repository declares no merge-check.sh" } };
|
||||||
|
assert.deepEqual(statusesFor(quiet, { defined: false, required: false }).map((s) => [s.state, s.description]),
|
||||||
|
[["success", "pass: the change touches no module of the mesh's graph"], ["success", "no repository check defined"]]);
|
||||||
|
assert.equal(commentFor(quiet, { defined: false, required: false }), null);
|
||||||
|
|
||||||
|
// A repository outside the mesh, touching nothing: the gate alone, a pass.
|
||||||
|
assert.equal(statusesFor({ ...base, verdict: "pass", summary: "x", gate: { verdict: "pass", summary: "x" } }).length, 1);
|
||||||
|
|
||||||
|
// A controller from before the layers: its verdict is the gate's.
|
||||||
|
assert.deepEqual(statusesFor({ ...base, verdict: "warning", summary: "wide" }).map((s) => [s.context, s.state]),
|
||||||
|
[[CHECK_CONTEXT, "success"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// **A required status is success or it blocks** (novox/hq issue 293): branch protection requires
|
||||||
|
// mesh/merge-gate (and mesh/repo-check on the core repositories) with no administrator override, and the
|
||||||
|
// forge combines `warning` as a failure. No verdict ever sets `warning` on either; a note is a success that
|
||||||
|
// says it; only what a person must decide is a failure, with why.
|
||||||
|
test("no verdict sets warning on a required check; only a person's decision fails", async () => {
|
||||||
|
const { statusesFor, commentFor, UNDEFINED_REQUIRED, REPO_CHECK_CONTEXT } = await import("../pulls.ts");
|
||||||
|
const base = { owner: "novox", repo: "photos", number: 3, commit: "0123456789abcdef", id: "build-2" };
|
||||||
|
const noScript = { verdict: "warning", summary: "the repository declares no merge-check.sh" };
|
||||||
|
const quiet = { ...base, verdict: "pass", summary: "x", gate: { verdict: "pass", summary: "x" }, "repo-check": noScript };
|
||||||
|
for (const verdict of ["pass", "warning", "fail", "error", ""]) {
|
||||||
|
for (const facts of [{}, { defined: true }, { defined: false }, { defined: false, required: true }, { defined: false, required: false }]) {
|
||||||
|
const c = { ...base, verdict, summary: "s", gate: { verdict, summary: "s" }, "repo-check": { verdict, summary: "s" } };
|
||||||
|
for (const s of statusesFor(c, facts)) assert.notEqual(s.state, "warning", `${verdict} ${JSON.stringify(facts)} → ${s.context}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A repository without a merge-check.sh where repo-check is not required: success, said.
|
||||||
|
assert.deepEqual(statusesFor(quiet, { defined: false, required: false })[1],
|
||||||
|
{ state: "success", context: REPO_CHECK_CONTEXT, description: "no repository check defined" });
|
||||||
|
// Where it is required — or the protection could not be read — a person decides: a failure, with why.
|
||||||
|
for (const facts of [{ defined: false, required: true }, { defined: false }]) {
|
||||||
|
const s = statusesFor(quiet, facts)[1];
|
||||||
|
assert.equal(s.state, "failure");
|
||||||
|
assert.ok(s.description.length <= 140 && UNDEFINED_REQUIRED.startsWith(s.description.replace(/…$/, "")));
|
||||||
|
assert.match(s.description, /^fail: mesh\/repo-check is required/);
|
||||||
|
assert.match(commentFor(quiet, facts) ?? "", /mesh\/repo-check`: \*\*FAIL\*\* — mesh\/repo-check is required/);
|
||||||
|
}
|
||||||
|
// A script that defines its check and said a warning itself: a note, a success.
|
||||||
|
const noted = statusesFor({ ...quiet, "repo-check": { verdict: "warning", summary: "2 tests skipped" } }, { defined: true })[1];
|
||||||
|
assert.deepEqual([noted.state, noted.description], ["success", "pass, with a note: 2 tests skipped"]);
|
||||||
|
// The gate's notes — a wide rebuild, a problem already so on the base — are successes that say so.
|
||||||
|
const already = statusesFor({ ...base, verdict: "warning", summary: "s",
|
||||||
|
gate: { verdict: "warning", summary: "the module check's problems were all so on main already" } })[0];
|
||||||
|
assert.deepEqual([already.state, already.description],
|
||||||
|
["success", "pass, with a note: the module check's problems were all so on main already"]);
|
||||||
|
assert.match(commentFor({ ...base, verdict: "warning", summary: "wide", gate: { verdict: "warning", summary: "wide" } }) ?? "",
|
||||||
|
/PASS, WITH A NOTE/);
|
||||||
|
});
|
||||||
|
|
||||||
test("a commit status is set on the commit, under the merge check's context", async () => {
|
test("a commit status is set on the commit, under the merge check's context", async () => {
|
||||||
const { GiteaClient } = await import("../client.ts");
|
const { GiteaClient } = await import("../client.ts");
|
||||||
let seen: { path: string; body: any } | null = null;
|
let seen: { path: string; body: any } | null = null;
|
||||||
@@ -54,6 +140,24 @@ test("a commit status is set on the commit, under the merge check's context", as
|
|||||||
assert.equal(seen!.body.context, "mesh/merge-gate");
|
assert.equal(seen!.body.context, "mesh/merge-gate");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a change plan is the gate's result: said on the status and, when it builds something, as a comment", async () => {
|
||||||
|
const { statusFor, commentFor } = await import("../pulls.ts");
|
||||||
|
const plan = {
|
||||||
|
repository: "novox/mesh-catalog", base: "main", head: "0123456789abcdef", moved: ["gitea"],
|
||||||
|
tiers: [["gitea"]], machines: [{ machine: "anchor", receives: ["gitea"] }], steps: [],
|
||||||
|
summary: "builds gitea → anchor; no bus step",
|
||||||
|
};
|
||||||
|
const c = { owner: "novox", repo: "mesh-catalog", number: 7, commit: "0123456789abcdef", id: "b", verdict: "pass",
|
||||||
|
summary: "every machine composes", gate: { verdict: "pass", summary: "every machine composes", modules: ["gitea"] }, plan };
|
||||||
|
assert.equal(statusFor(c).description, "pass: builds gitea → anchor; no bus step; every machine composes");
|
||||||
|
const said = commentFor(c) ?? "";
|
||||||
|
assert.match(said, /Delivery plan\*\* — builds gitea → anchor/);
|
||||||
|
assert.match(said, /- anchor: receives gitea/);
|
||||||
|
// A plan that builds nothing, passing: the statuses say it, no comment.
|
||||||
|
const nothing = { ...c, plan: { ...plan, moved: [], tiers: [], machines: [], summary: "builds nothing" } };
|
||||||
|
assert.equal(commentFor(nothing), null);
|
||||||
|
});
|
||||||
|
|
||||||
// **A pending check says where it is** (novox/hq issue 446): announced, it waits in the build queue, since the time the
|
// **A pending check says where it is** (novox/hq issue 446): announced, it waits in the build queue, since the time the
|
||||||
// operator reads — not "mapping this head onto its module graph" for the ten minutes of a controller check.
|
// operator reads — not "mapping this head onto its module graph" for the ten minutes of a controller check.
|
||||||
test("a head announced is pending in the build queue, since the local time it was announced", async () => {
|
test("a head announced is pending in the build queue, since the local time it was announced", async () => {
|
||||||
@@ -62,10 +166,48 @@ test("a head announced is pending in the build queue, since the local time it wa
|
|||||||
assert.equal(pendingWords(new Date(2026, 9, 11, 14, 0, 0)), "waiting in the build queue since 14:00");
|
assert.equal(pendingWords(new Date(2026, 9, 11, 14, 0, 0)), "waiting in the build queue since 14:00");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// **A group's composed verdict is each member's merge gate** (novox/hq ADR 0403 §1, issue 978): set at the head the
|
||||||
|
// composed check read, naming the trunk commit it was merged onto, the group, and what the check said.
|
||||||
|
test("a composed verdict sets every member's gate at its own head, naming its trunk and the group", async () => {
|
||||||
|
const { groupGateStatus, CHECK_CONTEXT } = await import("../pulls.ts");
|
||||||
|
const c = { owner: "", repo: "", commit: "", id: "build-1", group: "feat/x", verdict: "pass", summary: "both compose",
|
||||||
|
members: [{ owner: "novox", repo: "mesh-controller", number: 1, commit: "c".repeat(40), trunk: "a".repeat(40) },
|
||||||
|
{ owner: "novox", repo: "mesh-catalog", number: 2, commit: "d".repeat(40) }] };
|
||||||
|
const [first, second] = c.members.map((m) => groupGateStatus(c, m));
|
||||||
|
assert.equal(first.context, CHECK_CONTEXT);
|
||||||
|
assert.equal(first.state, "success");
|
||||||
|
assert.match(first.description, /^head cccccccccccc merged into trunk aaaaaaaaaaaa — pass: group feat\/x composed: both compose/);
|
||||||
|
assert.equal(second.state, "success");
|
||||||
|
assert.match(second.description, /^pass: group feat\/x composed: both compose/, "no trunk named, none said");
|
||||||
|
// A composed check that failed is every member's failure, never a success.
|
||||||
|
assert.equal(groupGateStatus({ ...c, verdict: "fail", summary: "a and b do not compose" }, c.members[0]).state, "failure");
|
||||||
|
assert.equal(groupGateStatus({ ...c, verdict: "error", summary: "could not run" }, c.members[0]).state, "error");
|
||||||
|
});
|
||||||
|
|
||||||
|
// A member's own verdict whose gate layer is the group's composed check's sets its repository check only, and never the
|
||||||
|
// gate as an error: the gate stays pending until the composed verdict lands, in either order (ADR 0403 §1, issue 978).
|
||||||
|
test("a member's own verdict with a composed gate layer leaves the gate to the composed verdict, in either order", async () => {
|
||||||
|
const { statusesFor, composedGate, groupGateStatus, CHECK_CONTEXT, REPO_CHECK_CONTEXT } = await import("../pulls.ts");
|
||||||
|
const c = { owner: "novox", repo: "mesh-catalog", number: 2, commit: "d".repeat(40), id: "build-1", verdict: "pass", summary: "x",
|
||||||
|
gate: { verdict: "composed", summary: "judged by its group" }, "repo-check": { verdict: "pass", summary: "suite passed" } };
|
||||||
|
assert.ok(composedGate(c));
|
||||||
|
// Before the composed verdict: the repository check, and the gate pending, saying it waits.
|
||||||
|
const before = statusesFor(c, {}, undefined);
|
||||||
|
assert.deepEqual(before.map((s) => [s.context, s.state]), [[CHECK_CONTEXT, "pending"], [REPO_CHECK_CONTEXT, "success"]]);
|
||||||
|
assert.match(before[0].description, /waits for its group's composed check/);
|
||||||
|
assert.ok(!before.some((s) => s.state === "error"));
|
||||||
|
// After it: the standing composed gate is left alone.
|
||||||
|
const standing = groupGateStatus({ ...c, group: "feat/x", members: [] }, { owner: "novox", repo: "mesh-catalog", commit: c.commit }).description;
|
||||||
|
const after = statusesFor(c, {}, standing);
|
||||||
|
assert.deepEqual(after.map((s) => [s.context, s.state]), [[REPO_CHECK_CONTEXT, "success"]]);
|
||||||
|
// A verdict with an ordinary gate is as before.
|
||||||
|
assert.equal(composedGate({ ...c, gate: { verdict: "pass", summary: "x" } }), false);
|
||||||
|
});
|
||||||
|
|
||||||
// Issue 1301 (ADR 0503 §3, §5): gitea writes the controller's statuses only where the base still requires them. hq's main
|
// Issue 1301 (ADR 0503 §3, §5): gitea writes the controller's statuses only where the base still requires them. hq's main
|
||||||
// requires mesh/repo-check alone, so no mesh/merge-gate is written on its heads.
|
// requires mesh/repo-check alone: its verdict sets mesh/repo-check and no mesh/merge-gate, and its comment names no gate.
|
||||||
test("a base's required statuses decide which of the controller's statuses are written", async () => {
|
test("a base's required statuses decide which of the controller's statuses are written", async () => {
|
||||||
const { maintained, writesGate, CHECK_STATUS } = await import("../pulls.ts");
|
const { maintained, writesGate, statusesFor, commentFor, CHECK_STATUS } = await import("../pulls.ts");
|
||||||
assert.equal(CHECK_STATUS, "mesh/check");
|
assert.equal(CHECK_STATUS, "mesh/check");
|
||||||
assert.deepEqual(maintained(undefined), ["mesh/merge-gate", "mesh/repo-check"], "a rule not read: as before");
|
assert.deepEqual(maintained(undefined), ["mesh/merge-gate", "mesh/repo-check"], "a rule not read: as before");
|
||||||
assert.deepEqual(maintained(["mesh/repo-check"]), ["mesh/repo-check"]);
|
assert.deepEqual(maintained(["mesh/repo-check"]), ["mesh/repo-check"]);
|
||||||
@@ -75,6 +217,15 @@ test("a base's required statuses decide which of the controller's statuses are w
|
|||||||
assert.equal(writesGate(["mesh/repo-check"]), false);
|
assert.equal(writesGate(["mesh/repo-check"]), false);
|
||||||
assert.equal(writesGate(["mesh/check"]), false);
|
assert.equal(writesGate(["mesh/check"]), false);
|
||||||
assert.equal(writesGate(["mesh/merge-gate", "mesh/repo-check"]), true);
|
assert.equal(writesGate(["mesh/merge-gate", "mesh/repo-check"]), true);
|
||||||
|
const c = { owner: "novox", repo: "hq", number: 5, commit: "a".repeat(40), id: "x", verdict: "warning", summary: "no script",
|
||||||
|
trunk: "b".repeat(12), "repo-check": { verdict: "pass", summary: "ok" } } as any;
|
||||||
|
const hq = statusesFor(c, {}, undefined, ["mesh/repo-check"]);
|
||||||
|
assert.deepEqual(hq.map((s: any) => s.context), ["mesh/repo-check"]);
|
||||||
|
assert.match(hq[0].description, /merged into trunk bbbbbbbbbbbb/);
|
||||||
|
assert.deepEqual(statusesFor(c).map((s: any) => s.context), ["mesh/merge-gate", "mesh/repo-check"], "unread: as before");
|
||||||
|
assert.deepEqual(statusesFor(c, {}, undefined, ["mesh/check"]), []);
|
||||||
|
const comment = commentFor(c, {}, ["mesh/repo-check"]);
|
||||||
|
assert.ok(comment === null || !comment.includes("mesh/merge-gate"), comment ?? "");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a rule the forge gives with no contexts (null) requires nothing, and an unread rule is unknown", async () => {
|
test("a rule the forge gives with no contexts (null) requires nothing, and an unread rule is unknown", async () => {
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ import { mkdtempSync } from "node:fs";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
import { approvedHeadsOf, besideWords, groupRefusal, keepSiblings, namesSiblings, readSiblings, siblingOutOfDate, siblingsFile,
|
import { approvedHeadsOf, besideWords, groupRefusal, keepSiblings, namesSiblings, noteVerdict, readSiblings, siblingOutOfDate, siblingsFile,
|
||||||
siblingsOf, siblingStatuses, weighSiblings, type SiblingRecord } from "../dist/siblings.js";
|
siblingsOf, siblingStatuses, weighSiblings, type SiblingRecord } from "../dist/siblings.js";
|
||||||
import { weighTrunks } from "../dist/trunk.js";
|
import { weighTrunks } from "../dist/trunk.js";
|
||||||
|
import { commentFor, statusesFor } from "../dist/pulls.js";
|
||||||
|
|
||||||
const h = (c: string) => c.repeat(40);
|
const h = (c: string) => c.repeat(40);
|
||||||
const MEMBER_HEAD = h("a"), HOST_HEAD = h("b"), HOST_BASE = h("c"), HOST_NEW = h("d"), HOST_MOVED = h("e"), MERGE = h("f");
|
const MEMBER_HEAD = h("a"), HOST_HEAD = h("b"), HOST_BASE = h("c"), HOST_NEW = h("d"), HOST_MOVED = h("e"), MERGE = h("f");
|
||||||
@@ -21,11 +22,52 @@ const verdict = { owner: "novox", repo: "mesh-controller", number: 346, commit:
|
|||||||
trunk: h("1").slice(0, 12), siblings: [sibling], judge_from: "sibling novox/mesh-controller#346 merge 123456789abc",
|
trunk: h("1").slice(0, 12), siblings: [sibling], judge_from: "sibling novox/mesh-controller#346 merge 123456789abc",
|
||||||
gate: { verdict: "pass", summary: "every machine composes ".repeat(10) }, "repo-check": { verdict: "pass", summary: "ok" } };
|
gate: { verdict: "pass", summary: "every machine composes ".repeat(10) }, "repo-check": { verdict: "pass", summary: "ok" } };
|
||||||
|
|
||||||
test("the siblings a verdict names are kept per member, and read back from where the holder keeps its state", () => {
|
test("both statuses name the siblings the verdict read, keeping the verdict word and the start of its reason within 140 characters", () => {
|
||||||
|
// A realistic controller summary for a failure, long enough that the forge clips it.
|
||||||
|
const reason = "2 of 4 machines do not compose: ace: mesh-host.service needs seat node-reads, which no module on ace claims after this change";
|
||||||
|
const c = { ...verdict, gate: { verdict: "fail", summary: reason }, "repo-check": { verdict: "fail", summary: "its merge-check.sh failed: internal/link/check.go:145:2: undefined" } };
|
||||||
|
const [gate, repo] = statusesFor(c);
|
||||||
|
for (const s of [gate, repo]) {
|
||||||
|
assert.ok(s.description.startsWith(`head ${MEMBER_HEAD.slice(0, 12)} merged into trunk 111111111111 beside mesh-host#114@bbbbbbbb — fail: `), s.description);
|
||||||
|
assert.ok(s.description.length <= 140, `${s.description.length} characters`);
|
||||||
|
assert.ok(namesSiblings(s.description));
|
||||||
|
}
|
||||||
|
assert.match(gate.description, /fail: 2 of 4 machines do not compose: ace: mesh-host/, gate.description);
|
||||||
|
assert.match(repo.description, /fail: its merge-check\.sh failed: internal/, repo.description);
|
||||||
|
assert.equal(besideWords([...siblingsOf([sibling]), ...siblingsOf([{ ...sibling, number: 115 }])]), "beside mesh-host#114@bbbbbbbb +1 more");
|
||||||
|
const comment = commentFor({ ...verdict, gate: { verdict: "fail", summary: "no" } })!;
|
||||||
|
assert.match(comment, /Siblings read beside it: novox\/mesh-host#114 `bbbbbbbbbbbb` merged onto `cccccccccccc` \(main\); judged by sibling novox\/mesh-controller#346 merge/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a verdict that could not learn its siblings says so on the status and in the comment, and names none", () => {
|
||||||
|
const { siblings, ...unknown } = { ...verdict, siblings_unknown: "mesh-delivery did not answer" };
|
||||||
|
const reason = "2 of 4 machines do not compose: ace: mesh-host.service needs seat node-reads, which no module claims";
|
||||||
|
for (const s of statusesFor({ ...unknown, gate: { verdict: "fail", summary: reason } })) {
|
||||||
|
assert.match(s.description, /^head aaaaaaaaaaaa merged into trunk 111111111111 siblings unknown — (fail|pass): /, s.description);
|
||||||
|
assert.ok(s.description.length <= 140);
|
||||||
|
assert.equal(namesSiblings(s.description), false, "it read no siblings, so the merge tool asks for none");
|
||||||
|
}
|
||||||
|
assert.match(commentFor({ ...unknown, gate: { verdict: "fail", summary: "no" } })!, /Siblings could not be learned: mesh-delivery did not answer/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a verdict without siblings is said exactly as before (an older controller)", () => {
|
||||||
|
const { siblings, judge_from, ...old } = verdict;
|
||||||
|
const [gate] = statusesFor(old);
|
||||||
|
assert.equal(namesSiblings(gate.description), false);
|
||||||
|
assert.match(gate.description, /^head aaaaaaaaaaaa merged into trunk 111111111111 — pass: /);
|
||||||
|
assert.deepEqual(siblingsOf(undefined), []);
|
||||||
|
assert.deepEqual(siblingsOf([{ repository: "novox/x" }]), [], "a sibling named without its head is none");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the siblings a verdict names are kept per member, replaced by its next verdict, and removed by one that read none", () => {
|
||||||
const file = siblingsFile(mkdtempSync(join(tmpdir(), "gitea-siblings-")));
|
const file = siblingsFile(mkdtempSync(join(tmpdir(), "gitea-siblings-")));
|
||||||
const record: SiblingRecord = { "novox/mesh-controller#346": { head: MEMBER_HEAD, siblings: siblingsOf([sibling]) } };
|
const record: SiblingRecord = {};
|
||||||
|
assert.equal(noteVerdict(record, "novox/mesh-controller#346", MEMBER_HEAD, []), false);
|
||||||
|
assert.equal(noteVerdict(record, "novox/mesh-controller#346", MEMBER_HEAD, siblingsOf([sibling])), true);
|
||||||
keepSiblings(file, record);
|
keepSiblings(file, record);
|
||||||
assert.deepEqual(readSiblings(file), record, "kept where the holder keeps its state");
|
assert.deepEqual(readSiblings(file), record, "kept where the holder keeps its state");
|
||||||
|
assert.equal(noteVerdict(record, "novox/mesh-controller#346", MEMBER_HEAD, []), true);
|
||||||
|
assert.deepEqual(record, {});
|
||||||
assert.deepEqual(readSiblings(null), {});
|
assert.deepEqual(readSiblings(null), {});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -237,10 +279,6 @@ const composedMembers = () => [
|
|||||||
{ owner: "novox", repo: "mesh-controller", number: 346, commit: MEMBER_HEAD, trunk: "111111111111", base: "main" },
|
{ owner: "novox", repo: "mesh-controller", number: 346, commit: MEMBER_HEAD, trunk: "111111111111", base: "main" },
|
||||||
{ owner: "novox", repo: "mesh-host", number: 114, commit: HOST_NEW, trunk: HOST_BASE.slice(0, 12), base: "main" },
|
{ owner: "novox", repo: "mesh-host", number: 114, commit: HOST_NEW, trunk: HOST_BASE.slice(0, 12), base: "main" },
|
||||||
];
|
];
|
||||||
/** The record a group's composed verdict once left: for each member, the other members at the heads and base commits it composed. */
|
|
||||||
const composedRecord = (members: ReturnType<typeof composedMembers>): SiblingRecord => Object.fromEntries(members.map((m) => [
|
|
||||||
`${m.owner}/${m.repo}#${m.number}`.toLowerCase(), { head: m.commit, siblings: members.filter((o) => o !== m).map((o) => ({
|
|
||||||
repository: `${o.owner}/${o.repo}`, number: o.number, head: o.commit, base: o.base, base_commit: o.trunk })) }]));
|
|
||||||
const staleRecord = (): SiblingRecord => ({ "novox/mesh-controller#346": { head: MEMBER_HEAD, siblings: siblingsOf([sibling]) } });
|
const staleRecord = (): SiblingRecord => ({ "novox/mesh-controller#346": { head: MEMBER_HEAD, siblings: siblingsOf([sibling]) } });
|
||||||
|
|
||||||
test("without the composed verdict's siblings, a sibling's own merge of its new head is a move (the pin of issue 1049)", async () => {
|
test("without the composed verdict's siblings, a sibling's own merge of its new head is a move (the pin of issue 1049)", async () => {
|
||||||
@@ -252,7 +290,9 @@ test("without the composed verdict's siblings, a sibling's own merge of its new
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("a composed verdict names the siblings of each member at the heads and base commits it composed, so a sibling's own merge of its new head is no move (issue 1049)", async () => {
|
test("a composed verdict names the siblings of each member at the heads and base commits it composed, so a sibling's own merge of its new head is no move (issue 1049)", async () => {
|
||||||
const record = composedRecord(composedMembers());
|
const { noteComposed } = await import("../dist/siblings.js");
|
||||||
|
const record = staleRecord();
|
||||||
|
assert.equal(noteComposed(record, composedMembers()), true);
|
||||||
assert.deepEqual(record["novox/mesh-controller#346"], { head: MEMBER_HEAD,
|
assert.deepEqual(record["novox/mesh-controller#346"], { head: MEMBER_HEAD,
|
||||||
siblings: [{ repository: "novox/mesh-host", number: 114, head: HOST_NEW, base: "main", base_commit: HOST_BASE.slice(0, 12) }] });
|
siblings: [{ repository: "novox/mesh-host", number: 114, head: HOST_NEW, base: "main", base_commit: HOST_BASE.slice(0, 12) }] });
|
||||||
assert.deepEqual(record["novox/mesh-host#114"], { head: HOST_NEW,
|
assert.deepEqual(record["novox/mesh-host#114"], { head: HOST_NEW,
|
||||||
@@ -265,9 +305,21 @@ test("a composed verdict names the siblings of each member at the heads and base
|
|||||||
assert.equal(record["novox/mesh-controller#346"].siblings[0].merged, true);
|
assert.equal(record["novox/mesh-controller#346"].siblings[0].merged, true);
|
||||||
// A merge of any other head, or onto any other base, is still a move.
|
// A merge of any other head, or onto any other base, is still a move.
|
||||||
const other = world();
|
const other = world();
|
||||||
const again = composedRecord(composedMembers());
|
const again: SiblingRecord = {};
|
||||||
|
noteComposed(again, composedMembers());
|
||||||
Object.assign(other.host, { merged: true, state: "closed", merge_commit_sha: MERGE, head_sha: HOST_NEW });
|
Object.assign(other.host, { merged: true, state: "closed", merge_commit_sha: MERGE, head_sha: HOST_NEW });
|
||||||
other.parents = [HOST_MOVED, HOST_NEW];
|
other.parents = [HOST_MOVED, HOST_NEW];
|
||||||
await weigh(other, again);
|
await weigh(other, again);
|
||||||
assert.equal(other.written.length, 2);
|
assert.equal(other.written.length, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a composed verdict keeps nothing for a member it cannot name, and a group of one is none (issue 1049)", async () => {
|
||||||
|
const { noteComposed } = await import("../dist/siblings.js");
|
||||||
|
const record: SiblingRecord = {};
|
||||||
|
const [a, b] = composedMembers();
|
||||||
|
assert.equal(noteComposed(record, [a]), false);
|
||||||
|
assert.equal(noteComposed(record, [a, { ...b, number: undefined }]), false, "a member with no pull request number");
|
||||||
|
assert.equal(noteComposed(record, [a, { ...b, trunk: undefined }]), false, "a member with no trunk commit");
|
||||||
|
assert.equal(noteComposed(record, [a, { ...b, base: undefined }]), false, "a member with no base branch");
|
||||||
|
assert.deepEqual(record, {});
|
||||||
|
});
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { join } from "node:path";
|
|||||||
import { MintedToken } from "../dist/token.js";
|
import { MintedToken } from "../dist/token.js";
|
||||||
import { GiteaClient } from "../dist/client.js";
|
import { GiteaClient } from "../dist/client.js";
|
||||||
import { TOOLS_ACCOUNT, TOOLS_TEAM_UNITS, BUILDER_ACCOUNT, AGENT_ACCOUNT, meshAccounts } from "../dist/tools-account.js";
|
import { TOOLS_ACCOUNT, TOOLS_TEAM_UNITS, BUILDER_ACCOUNT, AGENT_ACCOUNT, meshAccounts } from "../dist/tools-account.js";
|
||||||
|
import { repoCheckFacts } from "../dist/pulls.js";
|
||||||
|
|
||||||
const ADMIN = "mesh-admin";
|
const ADMIN = "mesh-admin";
|
||||||
const PASSWORD = "the-vault-minted-this";
|
const PASSWORD = "the-vault-minted-this";
|
||||||
@@ -363,6 +364,16 @@ test("the manifest hands the tools their account, and the Go forge bundle holds
|
|||||||
|
|
||||||
// Review of issue 469: what reads the rules' list is moved to the branch, and nothing leaves the tools without a token.
|
// Review of issue 469: what reads the rules' list is moved to the branch, and nothing leaves the tools without a token.
|
||||||
|
|
||||||
|
test("a repository check said as a warning on a head without merge-check.sh reads the base's protection from the branch", async () => {
|
||||||
|
const client = fresh(await environment());
|
||||||
|
const forgeOf = { holdsFile: async () => false, appliedProtection: client.appliedProtection.bind(client) };
|
||||||
|
const c = { owner: "novox", repo: "hq", commit: "abc123def4567890abc123def4567890abc12345", "repo-check": { verdict: "warning" } } as any;
|
||||||
|
// main requires mesh/repo-check: required, so a head that defines no check fails it for a person to see.
|
||||||
|
assert.deepEqual(await repoCheckFacts(forgeOf, c, "main"), { defined: false, required: true });
|
||||||
|
// A base nothing guards: not required, so the head's repository check passes rather than fails.
|
||||||
|
assert.deepEqual(await repoCheckFacts(forgeOf, c, "feat/unguarded"), { defined: false, required: false });
|
||||||
|
});
|
||||||
|
|
||||||
test("a mint that fails leaves the administrator's old token in place: the tools are never left without one", async () => {
|
test("a mint that fails leaves the administrator's old token in place: the tools are never left without one", async () => {
|
||||||
const env = await environment();
|
const env = await environment();
|
||||||
const old = { id: 901, name: "mesh-tools", sha1: "sha-admin-kept", user: ADMIN, scopes: ["read:user"] };
|
const old = { id: 901, name: "mesh-tools", sha1: "sha-admin-kept", user: ADMIN, scopes: ["read:user"] };
|
||||||
|
|||||||
@@ -6,13 +6,39 @@
|
|||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
import { checkOnItsWay, outOfDateStatuses, outOfDateWords, trunkNamed, verdictAtTrunk, weighTrunks } from "../dist/trunk.js";
|
import { checkOnItsWay, lateVerdictComment, outOfDateStatuses, outOfDateWords, trunkNamed, verdictAtTrunk, weighTrunks } from "../dist/trunk.js";
|
||||||
import { GateWriter } from "../dist/gate.js";
|
import { GateWriter } from "../dist/gate.js";
|
||||||
|
import { statusesFor, commentFor } from "../dist/pulls.js";
|
||||||
|
|
||||||
const HEAD = "dfb1415aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
const HEAD = "dfb1415aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||||
const TRUNK = "a2c2c35bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
const TRUNK = "a2c2c35bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||||
const MOVED = "d28ca95ccccccccccccccccccccccccccccccccc";
|
const MOVED = "d28ca95ccccccccccccccccccccccccccccccccc";
|
||||||
|
|
||||||
|
test("both statuses say the head and the trunk commit the verdict judged, first, whatever the summary's length", () => {
|
||||||
|
const c = { owner: "novox", repo: "mesh-host", number: 66, commit: HEAD, verdict: "pass", summary: "x", id: "build-1",
|
||||||
|
trunk: TRUNK.slice(0, 12), beside: { "mesh-catalog": "0123456789ab", "mesh-lab": "ba9876543210" },
|
||||||
|
gate: { verdict: "pass", summary: "4 of 4 compose ".repeat(20) },
|
||||||
|
"repo-check": { verdict: "fail", summary: "its merge-check.sh failed: internal/store/store.go:145:2: Declared redeclared" } };
|
||||||
|
const [gate, repo] = statusesFor(c);
|
||||||
|
for (const s of [gate, repo]) {
|
||||||
|
assert.ok(s.description.startsWith("head dfb1415aaaaa merged into trunk a2c2c35bbbbb — "), s.description);
|
||||||
|
assert.ok(s.description.length <= 140, `${s.description.length} characters`);
|
||||||
|
assert.equal(trunkNamed(s.description), "a2c2c35bbbbb");
|
||||||
|
}
|
||||||
|
assert.equal(gate.state, "success");
|
||||||
|
assert.equal(repo.state, "failure");
|
||||||
|
const comment = commentFor(c)!;
|
||||||
|
assert.match(comment, /Judged: head `dfb1415aaaaa` merged into trunk `a2c2c35bbbbb`; read beside it: mesh-catalog `0123456789ab`, mesh-lab `ba9876543210`/);
|
||||||
|
|
||||||
|
// A verdict of a build seat from before the rule names no trunk, and is said as before.
|
||||||
|
const [old] = statusesFor({ ...c, trunk: undefined, gate: { verdict: "pass", summary: "every machine composes" } });
|
||||||
|
assert.equal(old.description, "pass: every machine composes");
|
||||||
|
assert.equal(trunkNamed(old.description), undefined);
|
||||||
|
// ADR 0548 §8: a carried verdict's line names the head on the new trunk first, so the merge tool merges it on that trunk.
|
||||||
|
assert.equal(trunkNamed(`pass ${HEAD.slice(0, 12)} on ${MOVED.slice(0, 12)} | carried from ${TRUNK.slice(0, 12)}, mesh-catalog 0123456789ab | unit 0 run, 4 kept`),
|
||||||
|
MOVED.slice(0, 12));
|
||||||
|
});
|
||||||
|
|
||||||
test("a trunk's move puts an open head's merge check out of date, naming both commits, unless a check is on its way", () => {
|
test("a trunk's move puts an open head's merge check out of date, naming both commits, unless a check is on its way", () => {
|
||||||
const verdict = (trunk: string, state = "success") => ({
|
const verdict = (trunk: string, state = "success") => ({
|
||||||
"mesh/merge-gate": { state, description: `head ${HEAD.slice(0, 12)} merged into trunk ${trunk.slice(0, 12)} — pass: x` },
|
"mesh/merge-gate": { state, description: `head ${HEAD.slice(0, 12)} merged into trunk ${trunk.slice(0, 12)} — pass: x` },
|
||||||
@@ -60,6 +86,8 @@ function aForge(pulls: { number: number; base: string; head: string }[], heads:
|
|||||||
writes.push(`${sha} ${s.context} ${s.state}`);
|
writes.push(`${sha} ${s.context} ${s.state}`);
|
||||||
statuses.set(sha, { ...statuses.get(sha), [s.context]: { state: s.state, description: s.description } });
|
statuses.set(sha, { ...statuses.get(sha), [s.context]: { state: s.state, description: s.description } });
|
||||||
},
|
},
|
||||||
|
gateOf: async () => undefined,
|
||||||
|
gateHistory: async () => [],
|
||||||
headChecks: async (_o: string, _r: string, sha: string) => statuses.get(sha) ?? {},
|
headChecks: async (_o: string, _r: string, sha: string) => statuses.get(sha) ?? {},
|
||||||
};
|
};
|
||||||
return { forge, writes, statuses };
|
return { forge, writes, statuses };
|
||||||
@@ -109,6 +137,24 @@ test("a trunk that cannot be read is weighed again on the next pass, never recor
|
|||||||
assert.equal(trunks["novox/mesh-host:main"], TRUNK, "not recorded: the next pass weighs it again");
|
assert.equal(trunks["novox/mesh-host:main"], TRUNK, "not recorded: the next pass weighs it again");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the trunk's move takes its turn after a verdict in flight, and weighs what the forge holds then", async () => {
|
||||||
|
const heads: Record<string, string> = { main: TRUNK };
|
||||||
|
const { forge, statuses } = aForge([{ number: 1, base: "main", head: HEAD }], heads);
|
||||||
|
const gates = new GateWriter(forge);
|
||||||
|
// A verdict judged against the trunk's new head, in flight when the move is weighed: it lands first, and is left.
|
||||||
|
let release!: () => void;
|
||||||
|
const held = new Promise<void>((r) => (release = r));
|
||||||
|
const verdictLanded = gates.verdict("novox", "mesh-host", HEAD, async () => {
|
||||||
|
await held;
|
||||||
|
statuses.set(HEAD, { "mesh/merge-gate": { state: "success", description: `head x merged into trunk ${MOVED.slice(0, 12)} — pass` } });
|
||||||
|
});
|
||||||
|
const moved = gates.outOfDate("novox", "mesh-host", HEAD, (checks) => outOfDateStatuses(checks, MOVED));
|
||||||
|
release();
|
||||||
|
await verdictLanded;
|
||||||
|
assert.deepEqual(await moved, [], "a verdict judged against the trunk's head is not put out of date");
|
||||||
|
assert.equal(statuses.get(HEAD)!["mesh/merge-gate"].state, "success");
|
||||||
|
});
|
||||||
|
|
||||||
test("a verdict judged against a trunk newer than the pass's first read is not put out of date", async () => {
|
test("a verdict judged against a trunk newer than the pass's first read is not put out of date", async () => {
|
||||||
const NEWER = "ffffffffffffffffffffffffffffffffffffffff";
|
const NEWER = "ffffffffffffffffffffffffffffffffffffffff";
|
||||||
const heads: Record<string, string> = { main: TRUNK };
|
const heads: Record<string, string> = { main: TRUNK };
|
||||||
@@ -122,6 +168,15 @@ test("a verdict judged against a trunk newer than the pass's first read is not p
|
|||||||
assert.equal(statuses.get(HEAD)!["mesh/merge-gate"].state, "success", "a fresh verdict was put out of date");
|
assert.equal(statuses.get(HEAD)!["mesh/merge-gate"].state, "success", "a fresh verdict was put out of date");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a verdict that landed out of date keeps its account on the pull request", () => {
|
||||||
|
const words = outOfDateWords(TRUNK, MOVED);
|
||||||
|
const own = lateVerdictComment({ commit: HEAD, trunk: TRUNK.slice(0, 12), verdict: "fail" }, "**Merge check** at `dfb1415a`\n\nthe report", words);
|
||||||
|
assert.match(own, /the report/);
|
||||||
|
assert.match(own, /\*\*Out of date when it landed\*\*: out of date: judged against trunk a2c2c35bbbbb/);
|
||||||
|
const bare = lateVerdictComment({ commit: HEAD, trunk: TRUNK.slice(0, 12), verdict: "pass" }, null, words);
|
||||||
|
assert.match(bare, /head dfb1415aaaaa merged into trunk a2c2c35bbbbb, pass/);
|
||||||
|
});
|
||||||
|
|
||||||
test("a release branch's move puts every open pull request into it out of date, as a trunk's does (ADR 0358 §5)", async () => {
|
test("a release branch's move puts every open pull request into it out of date, as a trunk's does (ADR 0358 §5)", async () => {
|
||||||
// novox/hq ADR 0358 §5: for a pull request into a release branch, the release branch takes the trunk's place in ADR
|
// novox/hq ADR 0358 §5: for a pull request into a release branch, the release branch takes the trunk's place in ADR
|
||||||
// 0323 — the check merges the head onto the release branch's head and names it as the trunk commit, so the release
|
// 0323 — the check merges the head onto the release branch's head and names it as the trunk commit, so the release
|
||||||
|
|||||||
@@ -79,17 +79,6 @@ function namedPaths(raw: unknown): Record<string, string[]> | undefined {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The `files` argument of gitea_commit_trees: a list of directories, each plain ("" is the root; no empty, `.` or `..`
|
|
||||||
* segment, not absolute, no trailing slash); anything else is refused before the forge is asked. Absent is absent. */
|
|
||||||
function filesDirs(raw: unknown): string[] | undefined {
|
|
||||||
if (raw === undefined || raw === null) return undefined;
|
|
||||||
if (!Array.isArray(raw) || raw.some((d) => typeof d !== "string")) throw new Error("files is a list of directories");
|
|
||||||
for (const d of raw as string[]) {
|
|
||||||
if (d !== "" && d.split("/").some((seg) => seg === "" || seg === "." || seg === "..")) throw new Error(`files: ${JSON.stringify(d)} is not a plain relative directory`);
|
|
||||||
}
|
|
||||||
return raw as string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin, source: SourceRead = sourceFromBus(state)): ToolDefinition[] {
|
export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin, source: SourceRead = sourceFromBus(state)): ToolDefinition[] {
|
||||||
return namesChecked(allGiteaTools(gitea, admin, source));
|
return namesChecked(allGiteaTools(gitea, admin, source));
|
||||||
}
|
}
|
||||||
@@ -733,20 +722,19 @@ function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "gitea_commit_trees",
|
name: "gitea_commit_trees",
|
||||||
description: "A commit's trees from the forge's one recursive listing: the root tree, and the tree of every directory that holds a module.json (the root as an empty name), each by its git hash. What the forge bridge puts in a merge's message (novox/hq ADR 0437 §5). With files, a list of directories, the answer also carries files, {directory: {path relative to it: git blob id}}, every blob under each directory, recursively, from the same listing (novox/hq ADR 0593 §2); a directory the commit lacks fails naming it. With paths, {directory: [path relative to it]}, the answer also carries paths, {directory: {path: git object id}} of each named file or directory at the commit (novox/hq ADR 0499 §3); a path the commit lacks fails naming the directory and the path. The commit is a full 40-hex sha. A listing the forge cut is refused, never answered in part. Reads only.",
|
description: "A commit's trees from the forge's one recursive listing: the root tree, and the tree of every directory that holds a module.json (the root as an empty name), each by its git hash. What the forge bridge puts in a merge's message (novox/hq ADR 0437 §5). With paths, {directory: [path relative to it]}, the answer also carries paths, {directory: {path: git object id}} of each named file or directory at the commit (novox/hq ADR 0499 §3); a path the commit lacks fails naming the directory and the path. The commit is a full 40-hex sha. A listing the forge cut is refused, never answered in part. Reads only.",
|
||||||
input: {
|
input: {
|
||||||
owner: { type: "string", description: "the repository owner" },
|
owner: { type: "string", description: "the repository owner" },
|
||||||
repo: { type: "string", description: "the repository name" },
|
repo: { type: "string", description: "the repository name" },
|
||||||
sha: { type: "string", description: "the commit, its full 40-hex sha" },
|
sha: { type: "string", description: "the commit, its full 40-hex sha" },
|
||||||
paths: { type: "object", description: "optional, {directory: [path relative to it]}: the git object id of each named file or directory is answered under paths; the root directory is the empty name" },
|
paths: { type: "object", description: "optional, {directory: [path relative to it]}: the git object id of each named file or directory is answered under paths; the root directory is the empty name" },
|
||||||
files: { type: "array", description: "optional, a list of directories (the root is the empty name): every blob under each, recursively, is answered under files as {directory: {path relative to it: git blob id}}" },
|
|
||||||
},
|
},
|
||||||
run: async (args) => {
|
run: async (args) => {
|
||||||
const owner = String(args.owner ?? "").trim(), repo = String(args.repo ?? "").trim();
|
const owner = String(args.owner ?? "").trim(), repo = String(args.repo ?? "").trim();
|
||||||
const sha = String(args.sha ?? "").trim();
|
const sha = String(args.sha ?? "").trim();
|
||||||
if (!owner || !repo) throw new Error("name the repository: owner and repo");
|
if (!owner || !repo) throw new Error("name the repository: owner and repo");
|
||||||
if (!/^[0-9a-f]{40}$/i.test(sha)) throw new Error("sha is a commit's full 40-hex sha");
|
if (!/^[0-9a-f]{40}$/i.test(sha)) throw new Error("sha is a commit's full 40-hex sha");
|
||||||
return gitea.commitTrees(owner, repo, sha, namedPaths(args.paths), filesDirs(args.files));
|
return gitea.commitTrees(owner, repo, sha, namedPaths(args.paths));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+55
-1
@@ -10,7 +10,7 @@
|
|||||||
// Nothing is run for it: a recheck, a new head, or the merge tool asks the check again;
|
// Nothing is run for it: a recheck, a new head, or the merge tool asks the check again;
|
||||||
// - **the merge tool** (tools/index.ts) compares the trunk commit the head's verdict names with the trunk's head at
|
// - **the merge tool** (tools/index.ts) compares the trunk commit the head's verdict names with the trunk's head at
|
||||||
// the moment it is asked, merges nothing when they differ, asks the head's check once — the same announcement a
|
// the moment it is asked, merges nothing when they differ, asks the head's check once — the same announcement a
|
||||||
// new head makes — and never merges by itself after.
|
// new head makes — and says how long the last check of that repository took. It never merges by itself after.
|
||||||
//
|
//
|
||||||
// Since issue 1301 (ADR 0447 §4) a main that requires mesh/check is the checker's: its line names the trunk commit it judged
|
// 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
|
// ("pass <head12> on <trunk12>"), the checker sets it out of date when the trunk moves, and the merge tool refuses a verdict
|
||||||
@@ -19,6 +19,8 @@
|
|||||||
//
|
//
|
||||||
// Pure functions and two small files of the module's own state, so this is tested without a forge.
|
// Pure functions and two small files of the module's own state, so this is tested without a forge.
|
||||||
|
|
||||||
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||||
|
import { dirname } from "node:path";
|
||||||
import type { CommitStatus, GiteaClient, GiteaPull, HeadCheck } from "./client.js";
|
import type { CommitStatus, GiteaClient, GiteaPull, HeadCheck } from "./client.js";
|
||||||
import { CHECK_CONTEXT, WAITS_FOR_COMPOSED, maintained, requiredOf } from "./pulls.js";
|
import { CHECK_CONTEXT, WAITS_FOR_COMPOSED, maintained, requiredOf } from "./pulls.js";
|
||||||
|
|
||||||
@@ -110,6 +112,14 @@ export function askedWords(at: Date, trunkHead: string): string {
|
|||||||
`against trunk ${twelve(trunkHead)}`;
|
`against trunk ${twelve(trunkHead)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The comment a verdict that landed out of date leaves (ADR 0323 §7: kept and shown): its own account when it has
|
||||||
|
* one, else what it judged and said, and that it is out of date. */
|
||||||
|
export function lateVerdictComment(c: { commit: string; trunk?: string; verdict: string }, account: string | null,
|
||||||
|
outOfDate: string): string {
|
||||||
|
const own = account ?? `**Merge check** at \`${c.commit.slice(0, 8)}\`: ${judgedWords(c.commit, c.trunk ?? "")}, ${c.verdict}`;
|
||||||
|
return `${own}\n\n**Out of date when it landed**: ${outOfDate}`;
|
||||||
|
}
|
||||||
|
|
||||||
// --- a trunk's move, weighed --------------------------------------------------------------------------------------
|
// --- a trunk's move, weighed --------------------------------------------------------------------------------------
|
||||||
|
|
||||||
/** What weighing a trunk's move reads of the forge. */
|
/** What weighing a trunk's move reads of the forge. */
|
||||||
@@ -171,6 +181,50 @@ export async function weighTrunks(forge: TrunkForge, gates: OutOfDateWriter, tru
|
|||||||
return changed;
|
return changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- how long the last check of a repository took -------------------------------------------------------------
|
||||||
|
|
||||||
|
/** How long one repository's last check took, and when it was heard: kept by the watcher, read by the merge tool. */
|
||||||
|
export interface Took {
|
||||||
|
took: string;
|
||||||
|
at: string;
|
||||||
|
head?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The file the watcher keeps each repository's last check's duration in, beside its other records. */
|
||||||
|
export function tookFile(stateDir: string | undefined): string | null {
|
||||||
|
return stateDir ? `${stateDir.replace(/\/+$/, "")}/checks-took.json` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTookAll(file: string | null): Record<string, Took> {
|
||||||
|
if (!file || !existsSync(file)) return {};
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(file, "utf8")) as Record<string, Took>;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep how long a repository's check took. A record that cannot be written is said, never fatal to the verdict. */
|
||||||
|
export function keepTook(file: string | null, repository: string, took: Took): void {
|
||||||
|
if (!file || !took.took) return;
|
||||||
|
try {
|
||||||
|
const all = readTookAll(file);
|
||||||
|
all[repository.toLowerCase()] = took;
|
||||||
|
mkdirSync(dirname(file), { recursive: true });
|
||||||
|
writeFileSync(file + ".tmp", JSON.stringify(all));
|
||||||
|
renameSync(file + ".tmp", file);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[gitea] how long ${repository}'s check took could not be kept — ${err instanceof Error ? err.message : err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long a repository's last check took, in words for the merge tool's refusal. */
|
||||||
|
export function tookWords(file: string | null, repository: string): string {
|
||||||
|
const t = readTookAll(file)[repository.toLowerCase()];
|
||||||
|
if (!t) return `how long a check of ${repository} takes is not known yet: no verdict of it was heard since this was kept`;
|
||||||
|
return `the last check of ${repository} took ${t.took} (heard ${t.at})`;
|
||||||
|
}
|
||||||
|
|
||||||
// --- the announcement of a head, shared by the watcher and the merge tool ---------------------------------------
|
// --- the announcement of a head, shared by the watcher and the merge tool ---------------------------------------
|
||||||
|
|
||||||
/** `pull.updated` for one open pull request's head: what the controller asks the build seat to check (novox/hq to-be
|
/** `pull.updated` for one open pull request's head: what the controller asks the build seat to check (novox/hq to-be
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ module gnomekeyring
|
|||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
|
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/klauspost/compress v1.20.0 // indirect
|
github.com/klauspost/compress v1.20.0 // indirect
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ module hostname
|
|||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
|
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/klauspost/compress v1.20.0 // indirect
|
github.com/klauspost/compress v1.20.0 // indirect
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ module i3
|
|||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
|
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/klauspost/compress v1.20.0 // indirect
|
github.com/klauspost/compress v1.20.0 // indirect
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user