Compare commits
307
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e377fe862 | ||
|
|
af89893d42 | ||
|
|
105c4d628c | ||
|
|
59cad7154a | ||
|
|
3b35e78733 | ||
|
|
e072abb35e | ||
|
|
e389e770d9 | ||
|
|
00613b438c | ||
|
|
d1f42564e3 | ||
|
|
5548e1030c | ||
|
|
7bb07ce349 | ||
|
|
4e3b140d43 | ||
|
|
b5aa226649 | ||
|
|
77b511a79a | ||
|
|
d836a31aa7 | ||
|
|
683ce7e37f | ||
|
|
3494548c88 | ||
|
|
3feeefc70f | ||
|
|
3f552a17f6 | ||
|
|
16ea3824b5 | ||
|
|
fabb62da00 | ||
|
|
8068cf74a7 | ||
|
|
6e97ba3867 | ||
|
|
448310908c | ||
|
|
58309a9729 | ||
|
|
6724dab0cf | ||
|
|
387ee59140 | ||
|
|
85bbeb37e3 | ||
|
|
363039f337 | ||
|
|
a9d96b30be | ||
|
|
644a34cf49 | ||
|
|
277a4d0060 | ||
|
|
69056948bf | ||
|
|
9cdac09ad9 | ||
|
|
86d37a14e7 | ||
|
|
d13f74df40 | ||
|
|
dfe490d9e2 | ||
|
|
4f317201c9 | ||
|
|
25f6154564 | ||
|
|
b8ba9dd966 | ||
|
|
002a0e1a12 | ||
|
|
9bb85a37f2 | ||
|
|
774fcdfa93 | ||
|
|
470687ea4c | ||
|
|
2a03c106f4 | ||
|
|
f5d4b2f86e | ||
|
|
7c2aa0a739 | ||
|
|
e057670bc4 | ||
|
|
0c6b7b5d64 | ||
|
|
9fd95693db | ||
|
|
1a9570f5e9 | ||
|
|
d871258f8b | ||
|
|
e7abe3e3e4 | ||
|
|
0974d1a57d | ||
|
|
6cea12a023 | ||
|
|
a17356a162 | ||
|
|
35ad58e6e2 | ||
|
|
da5da4f6f7 | ||
|
|
671a6a1f47 | ||
|
|
47ed384d97 | ||
|
|
3fb7951524 | ||
|
|
ae96afcaa8 | ||
|
|
0c63b4c906 | ||
|
|
354d62340d | ||
|
|
dea390ac97 | ||
|
|
00d155da60 | ||
|
|
89c30c44be | ||
|
|
bd65c839a8 | ||
|
|
b7a036e186 | ||
|
|
3abf7cd50b | ||
|
|
d7e8a8824e | ||
|
|
83e9ce5b19 | ||
|
|
653cead338 | ||
|
|
d1ca199265 | ||
|
|
bf94af0a82 | ||
|
|
476a59162f | ||
|
|
36d5993323 | ||
|
|
f8ddcc98f6 | ||
|
|
9109524502 | ||
|
|
9bd8247915 | ||
|
|
116751130f | ||
|
|
c05aa5e6ea | ||
|
|
b95b0a4750 | ||
|
|
04fa2e3286 | ||
|
|
0c61bad102 | ||
|
|
02597b66d7 | ||
|
|
457c60972c | ||
|
|
5ae337b14d | ||
|
|
2ac511ce6b | ||
|
|
e955b53bf0 | ||
|
|
454ac0fec7 | ||
|
|
3c6f2dc274 | ||
|
|
39419b1892 | ||
|
|
b51f03c4ff | ||
|
|
3d42988afa | ||
|
|
64530a8dcb | ||
|
|
e8a953818c | ||
|
|
f0f7b6737e | ||
|
|
c1cd8461bc | ||
|
|
53bf4bb84c | ||
|
|
4677b8a2f5 | ||
|
|
cc6e478c4f | ||
|
|
c17ab34a28 | ||
|
|
099605ec70 | ||
|
|
488b7bfc58 | ||
|
|
d58249d0d8 | ||
|
|
d224b74ff1 | ||
|
|
234d2d5534 | ||
|
|
78d7ef71cc | ||
|
|
1a738ed4fc | ||
|
|
c085975146 | ||
|
|
ba49a7f0f9 | ||
|
|
be7e98d25c | ||
|
|
b1413949f0 | ||
|
|
eba4bab00a | ||
|
|
216701fcba | ||
|
|
8e116cb0e6 | ||
|
|
dcf32576a8 | ||
|
|
3f8b0b4248 | ||
|
|
7c5c29421f | ||
|
|
9e41438f1b | ||
|
|
3690c149c4 | ||
|
|
671fdb7dc2 | ||
|
|
eab6918459 | ||
|
|
ff2632356b | ||
|
|
1e6b157865 | ||
|
|
5b75233406 | ||
|
|
4548b97910 | ||
|
|
1faf317069 | ||
|
|
245143d948 | ||
|
|
358ef45536 | ||
|
|
851e8782d5 | ||
|
|
175b2cd791 | ||
|
|
8530a9730f | ||
|
|
ee48e5a01e | ||
|
|
da29f77b10 | ||
|
|
854dbfb3f3 | ||
|
|
5a4ac49913 | ||
|
|
69a3b3f7e0 | ||
|
|
3cd826830b | ||
|
|
94dab68635 | ||
|
|
76e4ec4dc6 | ||
|
|
c584dc57b9 | ||
|
|
e1e5a8ff0d | ||
|
|
c85472285d | ||
|
|
f0fbe193fa | ||
|
|
26908a8950 | ||
|
|
27a7757b97 | ||
|
|
87fe2c45cf | ||
|
|
24b244bc88 | ||
|
|
5baf8ad1d7 | ||
|
|
8da33e43d4 | ||
|
|
52de4c320e | ||
|
|
5e516317e9 | ||
|
|
6b6d5cba71 | ||
|
|
8393e29621 | ||
|
|
61563abf2f | ||
|
|
071c6885d9 | ||
|
|
8c5f27627b | ||
|
|
82bd6f49ff | ||
|
|
ceb3aac9b3 | ||
|
|
98c66fb1e4 | ||
|
|
07ec186031 | ||
|
|
2aecbe8055 | ||
|
|
3def293d94 | ||
|
|
9a8e54d988 | ||
|
|
2da9329df4 | ||
|
|
ecbee53cfd | ||
|
|
dcdf4238c7 | ||
|
|
d3423f54ed | ||
|
|
0e322d087f | ||
|
|
0300a6fd99 | ||
|
|
d21b81b00a | ||
|
|
b1e610b213 | ||
|
|
0134697f97 | ||
|
|
21583d14ad | ||
|
|
a4f5df797b | ||
|
|
ac2c56f2a9 | ||
|
|
b4a7bb9dea | ||
|
|
64b9ded2db | ||
|
|
67d6a5a7c1 | ||
|
|
5fe5f26e7e | ||
|
|
b3221b6ab8 | ||
|
|
0e7e220d49 | ||
|
|
879600202a | ||
|
|
565d0a848a | ||
|
|
71e094b2de | ||
|
|
f96704fa0f | ||
|
|
86fdf74a0a | ||
|
|
843281c0e5 | ||
|
|
e9279ce3c2 | ||
|
|
c9414b1c98 | ||
|
|
ea53b2f920 | ||
|
|
99cc073e2a | ||
|
|
b94150fcdb | ||
|
|
a01715ddf5 | ||
|
|
e2136ec0c9 | ||
|
|
a30c168512 | ||
|
|
f885250913 | ||
|
|
7627503d9d | ||
|
|
351c7c4bf4 | ||
|
|
55a26b22cf | ||
|
|
100e74f283 | ||
|
|
c8e1e2b2fa | ||
|
|
d0d00f922a | ||
|
|
c2bd7760b2 | ||
|
|
f0fd53281b | ||
|
|
fb039250f6 | ||
|
|
447e6a13cd | ||
|
|
c51a46b802 | ||
|
|
15a5fe09b6 | ||
|
|
3ded9e7800 | ||
|
|
e7aaa8420e | ||
|
|
22381d7522 | ||
|
|
1c55e5d44d | ||
|
|
32b912cfe5 | ||
|
|
e572937d97 | ||
|
|
567736f779 | ||
|
|
e901769cb2 | ||
|
|
8f9c78bcba | ||
|
|
82e7e3e326 | ||
|
|
363a3d3f7e | ||
|
|
609013a263 | ||
|
|
32ad59fc64 | ||
|
|
93f655dac9 | ||
|
|
573a59e3cc | ||
|
|
d0566f9ade | ||
|
|
9d9dfda19b | ||
|
|
cdbe2945cf | ||
|
|
4e82a824be | ||
|
|
78d26867d2 | ||
|
|
0c216d99d0 | ||
|
|
145c54d19c | ||
|
|
67092161bd | ||
|
|
2df0503034 | ||
|
|
23c827e791 | ||
|
|
3138570c6b | ||
|
|
a85d903741 | ||
|
|
1cc54959af | ||
|
|
962bc359a7 | ||
|
|
67b13c42d3 | ||
|
|
4021724ca4 | ||
|
|
f27f4c8cd5 | ||
|
|
155124b59a | ||
|
|
6ec6fdaad2 | ||
|
|
663f3de975 | ||
|
|
68f7431d98 | ||
|
|
53749833bf | ||
|
|
2167b95a36 | ||
|
|
d09cbf1523 | ||
|
|
ac6712cbb5 | ||
|
|
5b32342ac2 | ||
|
|
6fb0ceeef8 | ||
|
|
c651161787 | ||
|
|
ee216e4e63 | ||
|
|
b67b5af5e6 | ||
|
|
d700ceca20 | ||
|
|
3a1017f559 | ||
|
|
a17d0c8e93 | ||
|
|
9fa6710296 | ||
|
|
05f849979c | ||
|
|
4dee77df23 | ||
|
|
8a79877a15 | ||
|
|
2139e83a8a | ||
|
|
e7ca8f7af1 | ||
|
|
eb79996336 | ||
|
|
1a271a1fb1 | ||
|
|
30d04f6bef | ||
|
|
5ff337e0dc | ||
|
|
2fabbe556e | ||
|
|
89b9212c4d | ||
|
|
40da368ee3 | ||
|
|
865f3b6455 | ||
|
|
03524ecd13 | ||
|
|
15101c9ce6 | ||
|
|
f910b0ceb8 | ||
|
|
91c3e954ae | ||
|
|
545c72bb59 | ||
|
|
7afcd0282d | ||
|
|
75156fce00 | ||
|
|
16d7b1898d | ||
|
|
f04465537b | ||
|
|
e870a7f65f | ||
|
|
e8a59171c7 | ||
|
|
ebb25757a2 | ||
|
|
6a70735489 | ||
|
|
a715c8821f | ||
|
|
b6ed8599c8 | ||
|
|
ae6f7d5b54 | ||
|
|
25176e1ccd | ||
|
|
7e1f3de77a | ||
|
|
4b1bed4b9d | ||
|
|
25d69406b7 | ||
|
|
f836c021e1 | ||
|
|
450f0bbe79 | ||
|
|
5a7076a6a2 | ||
|
|
30b22c198e | ||
|
|
753e8fc202 | ||
|
|
e5202445ba | ||
|
|
dcae699323 | ||
|
|
70ef99595a | ||
|
|
746eb885a3 | ||
|
|
2cdb9b8fc4 | ||
|
|
e830045545 | ||
|
|
454fb8a918 | ||
|
|
c32474ee40 | ||
|
|
f95f3f430e |
+12
@@ -14,6 +14,7 @@ modules/claude-code/claude-code
|
||||
modules/mesh-watcher/mesh-watcher
|
||||
modules/dnsmasq/dnsmasq
|
||||
modules/mesh-issues/cmd/mesh-issues/mesh-issues
|
||||
modules/mesh-issues/mesh-issues
|
||||
modules/mounts/cmd/mounts/mounts
|
||||
modules/mesh-knowledge/cmd/mesh-knowledge/mesh-knowledge
|
||||
modules/mesh-knowledge/mesh-knowledge
|
||||
@@ -29,3 +30,14 @@ modules/mesh-build-go/cmd/mesh-build-go/mesh-build-go
|
||||
modules/mesh-config/mesh-config
|
||||
modules/mesh-build-image/cmd/mesh-build-image/mesh-build-image
|
||||
*.test
|
||||
|
||||
# binaries a local go build leaves in a module root
|
||||
/checks/paths/paths
|
||||
/modules/mesh-build-go/mesh-build-go
|
||||
/modules/mesh-delivery-gate/mesh-delivery-gate
|
||||
modules/mesh-config/cmd/mesh-config/mesh-config
|
||||
|
||||
# Built by their modules' build step (issue 1577), never committed.
|
||||
modules/forticlient/cmd/forticlient-tools/forticlient-tools
|
||||
modules/networkmanager/uplink-tools
|
||||
checks/words/words
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# 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.
|
||||
# Check("../.."): every manifest.
|
||||
modules/*/module.json
|
||||
@@ -0,0 +1,5 @@
|
||||
# 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.
|
||||
# Check("../.."): every manifest and the files it names.
|
||||
modules
|
||||
@@ -0,0 +1,128 @@
|
||||
// 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
|
||||
// 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
|
||||
// exempt from that, and said so. Every build module, test step or not, must also hold a test that calls
|
||||
// conformance.LeftRunning with its own start sweep, so a step a previous process left running is ended at its start
|
||||
// (issue 1890).
|
||||
package failurekit
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Judged is what the check says of one build module: Refused says why it fails the check, "" when it passes; Exempt
|
||||
// says why the kit does not apply.
|
||||
type Judged struct {
|
||||
Module, Refused, Exempt string
|
||||
}
|
||||
|
||||
type manifest struct {
|
||||
Claims []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"claims"`
|
||||
Tools []json.RawMessage `json:"tools"`
|
||||
}
|
||||
|
||||
// Judge reads every modules/<m>/module.json of the catalogue at root and judges each module that claims a node-build-*
|
||||
// seat.
|
||||
func Judge(root string) ([]Judged, error) {
|
||||
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(manifests)
|
||||
var out []Judged
|
||||
for _, p := range manifests {
|
||||
raw, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m manifest
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", p, err)
|
||||
}
|
||||
seat := ""
|
||||
for _, c := range m.Claims {
|
||||
if strings.HasPrefix(c.Name, "node-build-") {
|
||||
seat = c.Name
|
||||
}
|
||||
}
|
||||
if seat == "" {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Dir(p)
|
||||
j := Judged{Module: filepath.Base(dir)}
|
||||
var refused []string
|
||||
test := testTool(m.Tools)
|
||||
switch {
|
||||
case test == "":
|
||||
j.Exempt = fmt.Sprintf("it claims %s and serves no test step, so the failure contract's cases do not apply", seat)
|
||||
default:
|
||||
runs, err := runsTheKit(dir, "conformance.Run(")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !runs {
|
||||
refused = append(refused, fmt.Sprintf("it claims %s and serves the test step %s, and no test of it calls conformance.Run "+
|
||||
"(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).
|
||||
sweeps, err := runsTheKit(dir, "conformance.LeftRunning(")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !sweeps {
|
||||
refused = append(refused, fmt.Sprintf("it claims %s, and no test of it calls conformance.LeftRunning "+
|
||||
"(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, "; ")
|
||||
out = append(out, j)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// testTool is the name of the module's test step tool (<prefix>_test), or "".
|
||||
func testTool(tools []json.RawMessage) string {
|
||||
for _, raw := range tools {
|
||||
var name string
|
||||
if json.Unmarshal(raw, &name) != nil {
|
||||
var t struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if json.Unmarshal(raw, &t) != nil {
|
||||
continue
|
||||
}
|
||||
name = t.Name
|
||||
}
|
||||
if strings.HasSuffix(name, "_test") {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// runsTheKit says a _test.go file under dir calls the kit's function call (such as "conformance.Run(").
|
||||
func runsTheKit(dir, call string) (bool, error) {
|
||||
found := false
|
||||
err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil || found || d.IsDir() || !strings.HasSuffix(p, "_test.go") {
|
||||
return err
|
||||
}
|
||||
raw, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
found = strings.Contains(string(raw), call) &&
|
||||
strings.Contains(string(raw), `"git.novox.be/novox/mesh-sdk/go/buildstep/conformance"`)
|
||||
return nil
|
||||
})
|
||||
return found, err
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package failurekit
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 1901/AC7 and 1890/AC8: a build module with a test step and no kit is refused, one that runs the kit passes, one with no
|
||||
// test step is exempt from the failure cases but not from the left-running case, a module that runs only the failure
|
||||
// cases is refused, and a module that claims no node-build-* seat is not judged.
|
||||
func TestABuildModuleWithATestStepMustRunTheKit(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
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"
|
||||
kit := imp + "func TestKit(t *testing.T) { conformance.Run(t, conformance.GoTest, nil) }\n" + sweep
|
||||
for path, body := range map[string]string{
|
||||
"modules/a/module.json": `{"claims":[{"name":"node-build-a"}],"tools":["build_a_fetch","build_a_test"]}`,
|
||||
"modules/a/cmd/a/check_test.go": "package main\n",
|
||||
"modules/b/module.json": `{"claims":[{"name":"node-build-b"}],"tools":[{"name":"build_b_test"}]}`,
|
||||
"modules/b/cmd/b/kit_test.go": kit,
|
||||
"modules/c/module.json": `{"claims":[{"name":"node-build-c"}],"tools":["build_c_build"]}`,
|
||||
"modules/c/cmd/c/kit_test.go": imp + sweep,
|
||||
"modules/f/module.json": `{"claims":[{"name":"node-build-f"}],"tools":["build_f_build"]}`,
|
||||
"modules/g/module.json": `{"claims":[{"name":"node-build-g"}],"tools":["build_g_test"]}`,
|
||||
"modules/g/cmd/g/kit_test.go": imp + "func TestKit(t *testing.T) { conformance.Run(t, conformance.GoTest, nil) }\n",
|
||||
"modules/d/module.json": `{"claims":[{"name":"other"}],"tools":["d_test"]}`,
|
||||
"modules/e/module.json": `{"claims":[{"name":"node-build-e"}],"tools":["build_e_test"]}`,
|
||||
"modules/e/cmd/e/kit.go": kit, // not a test file
|
||||
} {
|
||||
p := filepath.Join(root, path)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
got, err := Judge(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]string{"a": "refused", "b": "passes", "c": "exempt", "e": "refused", "f": "refused", "g": "refused"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("judged %+v, want a, b, c, e, f and g", got)
|
||||
}
|
||||
for _, j := range got {
|
||||
is := "passes"
|
||||
if j.Refused != "" {
|
||||
is = "refused"
|
||||
} else if j.Exempt != "" {
|
||||
is = "exempt"
|
||||
}
|
||||
if is != want[j.Module] {
|
||||
t.Errorf("%s %s (%+v), want it %s", j.Module, is, j, want[j.Module])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every build module of this catalogue with a test step runs the kit.
|
||||
func TestTheCataloguesBuildModulesRunTheKit(t *testing.T) {
|
||||
got, err := Judge(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) == 0 {
|
||||
t.Fatal("no module claims a node-build-* seat: the check reads the wrong tree")
|
||||
}
|
||||
for _, j := range got {
|
||||
switch {
|
||||
case j.Refused != "":
|
||||
t.Errorf("%s: %s", j.Module, j.Refused)
|
||||
case j.Exempt != "":
|
||||
t.Logf("%s is exempt: %s", j.Module, j.Exempt)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/failurekit
|
||||
|
||||
go 1.22
|
||||
@@ -0,0 +1,7 @@
|
||||
# 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.
|
||||
# Judge("../.."): every manifest, and the directory of each module claiming a node-build-* seat, walked for a test that
|
||||
# calls the kit. The build modules are the mesh-build-* directories; a build module named otherwise needs its line here.
|
||||
modules/*/module.json
|
||||
modules/mesh-build-*
|
||||
@@ -0,0 +1,10 @@
|
||||
# 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 manifest through mesh-sdk's ReadTreeManifest: the root's predefined keys and each templated module's
|
||||
# templates (files/ by default, or the directory its manifest names); and the root's kept count of resources that do not say how they are ready.
|
||||
modules/*/module.json
|
||||
predefined.json
|
||||
modules/*/files
|
||||
modules/*/templates
|
||||
health-undeclared
|
||||
@@ -0,0 +1,5 @@
|
||||
# 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.
|
||||
# Check("../.."): every manifest.
|
||||
modules/*/module.json
|
||||
@@ -0,0 +1,5 @@
|
||||
# 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.
|
||||
# Check("../.."): every manifest, and whether the path each image artifact builds from is there.
|
||||
modules
|
||||
@@ -0,0 +1,7 @@
|
||||
# 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.
|
||||
# Check("../.."): every manifest, a TypeScript module's package.json and whether its lock is there.
|
||||
modules/*/module.json
|
||||
modules/*/package.json
|
||||
modules/*/package-lock.json
|
||||
@@ -0,0 +1,5 @@
|
||||
# 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.
|
||||
# Check("../.."): every manifest.
|
||||
modules/*/module.json
|
||||
@@ -0,0 +1,3 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/nobinaries
|
||||
|
||||
go 1.22
|
||||
@@ -0,0 +1,84 @@
|
||||
// Package nobinaries refuses a compiled program anywhere in the repository (issue 1577; first a checks/ binary slipped past a modules/-only rule): it is build output, made by the module's build step,
|
||||
// and `go build` in a module's folder writes one beside go.mod where `git add -A` takes it.
|
||||
package nobinaries
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Kind says what executable format the first bytes of a file are, or "" when they are none.
|
||||
func Kind(head []byte) string {
|
||||
switch {
|
||||
case bytes.HasPrefix(head, []byte("\x7fELF")):
|
||||
return "ELF"
|
||||
case bytes.HasPrefix(head, []byte("MZ")):
|
||||
return "PE"
|
||||
case bytes.HasPrefix(head, []byte{0xcf, 0xfa, 0xed, 0xfe}), bytes.HasPrefix(head, []byte{0xce, 0xfa, 0xed, 0xfe}),
|
||||
bytes.HasPrefix(head, []byte{0xfe, 0xed, 0xfa, 0xcf}), bytes.HasPrefix(head, []byte{0xfe, 0xed, 0xfa, 0xce}),
|
||||
bytes.HasPrefix(head, []byte{0xca, 0xfe, 0xba, 0xbe}) && len(head) >= 8 && head[7] < 0x20 && head[6] == 0 && head[5] == 0 && head[4] == 0:
|
||||
return "Mach-O"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Files are the files of the catalogue's root that the change holds, anywhere: the tracked ones when the root is a git
|
||||
// checkout (an ignored binary a local build left is not the change's), else every file there but .git's.
|
||||
func Files(root string) ([]string, error) {
|
||||
cmd := exec.Command("git", "-C", root, "ls-files", "-z")
|
||||
if out, err := cmd.Output(); err == nil && len(out) > 0 {
|
||||
var files []string
|
||||
for _, f := range strings.Split(strings.TrimRight(string(out), "\x00"), "\x00") {
|
||||
if _, err := os.Lstat(filepath.Join(root, f)); err == nil { // a file the change deletes is not there
|
||||
files = append(files, f)
|
||||
}
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
var files []string
|
||||
err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() && d.Name() == ".git" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if d.IsDir() || !d.Type().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
rel, _ := filepath.Rel(root, p)
|
||||
files = append(files, rel)
|
||||
return nil
|
||||
})
|
||||
return files, err
|
||||
}
|
||||
|
||||
// Find names each file of files (paths from root) that is a compiled program, with its format.
|
||||
func Find(root string, files []string) ([]string, error) {
|
||||
var found []string
|
||||
for _, f := range files {
|
||||
p := filepath.Join(root, f)
|
||||
if st, err := os.Lstat(p); err != nil || !st.Mode().IsRegular() {
|
||||
continue
|
||||
}
|
||||
fh, err := os.Open(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
head := make([]byte, 8)
|
||||
n, err := io.ReadFull(fh, head)
|
||||
fh.Close()
|
||||
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
|
||||
return nil, err
|
||||
}
|
||||
if k := Kind(head[:n]); k != "" {
|
||||
found = append(found, fmt.Sprintf("%s (%s)", f, k))
|
||||
}
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package nobinaries
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFindNamesEachCompiledProgramAndNothingElse(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
files := map[string][]byte{
|
||||
"modules/a/a": []byte("\x7fELF\x02\x01\x01\x00rest"),
|
||||
"modules/b/cmd/b/b.exe": []byte("MZ\x90\x00"),
|
||||
"modules/c/c": {0xcf, 0xfa, 0xed, 0xfe, 7, 0, 0, 1},
|
||||
"modules/d/main.go": []byte("package main\n"),
|
||||
"modules/e/short": []byte("\x7f"),
|
||||
"modules/f/empty": {},
|
||||
"modules/g/Foo.class": {0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 52}, // Java, not Mach-O
|
||||
"checks/words/words": []byte("\x7fELF\x02\x01\x01\x00rest"), // outside modules/ too
|
||||
}
|
||||
var names []string
|
||||
for f, b := range files {
|
||||
p := filepath.Join(root, f)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, b, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names = append(names, f)
|
||||
}
|
||||
got, err := Find(root, names)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 4 {
|
||||
t.Fatalf("found %v; wanted modules/a/a, modules/b/cmd/b/b.exe and modules/c/c and checks/words/words only", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The catalogue's own tree holds no compiled program: a compiled program is build output, so remove it and add it to .gitignore.
|
||||
func TestTheCatalogueHoldsNoCompiledProgram(t *testing.T) {
|
||||
root := filepath.Join("..", "..")
|
||||
files, err := Files(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found, err := Find(root, files)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, f := range found {
|
||||
t.Errorf("%s: a compiled program is build output: remove it and add it to .gitignore (issue 1577)", f)
|
||||
}
|
||||
}
|
||||
|
||||
// A compiled program outside modules/ is refused too (a binary under checks/ slipped past the modules/-only rule): Files holds
|
||||
// every tracked file of a git checkout and every file of a plain folder, and Find names the binary in both.
|
||||
func TestAProgramUnderChecksIsRefused(t *testing.T) {
|
||||
for _, git := range []bool{true, false} {
|
||||
root := t.TempDir()
|
||||
p := filepath.Join(root, "checks", "words", "words")
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte("\x7fELF\x02\x01\x01\x00rest"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if git {
|
||||
for _, args := range [][]string{{"init", "-q"}, {"add", "-A"}} {
|
||||
if out, err := exec.Command("git", append([]string{"-C", root}, args...)...).CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v: %s", args, err, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
files, err := Files(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found, err := Find(root, files)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(found) != 1 {
|
||||
t.Errorf("git=%v: found %v; wanted checks/words/words (ELF)", git, found)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
# every tracked file of the repository (git ls-files, no path limit): the whole tree, so this package's verdict is never kept.
|
||||
@@ -0,0 +1,3 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/noplacement
|
||||
|
||||
go 1.22
|
||||
@@ -0,0 +1,99 @@
|
||||
// Command noplacement refuses what the repository path of placement left behind (novox/hq ADR 0567 §10, §12).
|
||||
//
|
||||
// Placement, which module runs on which node, is bus state held by the seat mesh-placement; no file of the
|
||||
// catalogue says it, and no manifest reads it from the delivery indexer, which no longer keeps it. It refuses:
|
||||
//
|
||||
// 1. a `placement` directory (or file) at the root of the catalogue;
|
||||
//
|
||||
// 2. a `particulars` file at the root (the mesh's node names come from the checker's run, §16);
|
||||
//
|
||||
// 3. a manifest naming a state, stream, event or bucket of the indexer's placement (`mesh-delivery-indexer.placement…`
|
||||
// or `mesh-delivery-indexer_placement…`) in any string value, such as `reads`.
|
||||
//
|
||||
// go run . <catalogue root>
|
||||
//
|
||||
// Exits 1 on a finding.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Prefixes are the names of the indexer's placement parts; a manifest string starting with one names them.
|
||||
var Prefixes = []string{"mesh-delivery-indexer.placement", "mesh-delivery-indexer_placement"}
|
||||
|
||||
// Findings is every refusal in the catalogue at root, sorted.
|
||||
func Findings(root string) ([]string, error) {
|
||||
var out []string
|
||||
if _, err := os.Lstat(filepath.Join(root, "placement")); err == nil {
|
||||
out = append(out, "placement: the repository holds no placement; it is bus state of the seat mesh-placement (novox/hq ADR 0567 §10)")
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(root, "particulars")); err == nil {
|
||||
out = append(out, "particulars: the repository lists no node names; the checker hands them to the check (novox/hq ADR 0567 §16)")
|
||||
}
|
||||
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(manifests)
|
||||
for _, file := range manifests {
|
||||
raw, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var doc any
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", file, err)
|
||||
}
|
||||
module := filepath.Base(filepath.Dir(file))
|
||||
for _, s := range named(doc, nil) {
|
||||
out = append(out, fmt.Sprintf("%s: names %q, which the indexer no longer keeps; placement is the seat mesh-placement's (novox/hq ADR 0567 §12)", module, s))
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// named collects the strings of a decoded manifest that start with one of the Prefixes.
|
||||
func named(v any, into []string) []string {
|
||||
switch v := v.(type) {
|
||||
case string:
|
||||
for _, p := range Prefixes {
|
||||
if strings.HasPrefix(v, p) {
|
||||
return append(into, v)
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, e := range v {
|
||||
into = named(e, into)
|
||||
}
|
||||
case map[string]any:
|
||||
for _, e := range v {
|
||||
into = named(e, into)
|
||||
}
|
||||
}
|
||||
return into
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) != 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: noplacement <catalogue root>")
|
||||
os.Exit(2)
|
||||
}
|
||||
found, err := Findings(os.Args[1])
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
for _, f := range found {
|
||||
fmt.Println(f)
|
||||
}
|
||||
if len(found) > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("no placement directory, and no manifest reads the indexer's placement")
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFindingsRefuseWhatThePlacementPathLeft(t *testing.T) {
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
files map[string]string
|
||||
want string // empty: clean
|
||||
}{
|
||||
{"clean", map[string]string{"modules/a/module.json": `{"module":"a","reads":["mesh-controller.assignments"]}`}, ""},
|
||||
{"a placement directory", map[string]string{"placement/ace.json": `{}`}, "placement: the repository holds no placement"},
|
||||
{"a particulars file", map[string]string{"particulars": "ace\n"}, "particulars: the repository lists no node names"},
|
||||
{"a read of the indexer's placement", map[string]string{"modules/a/module.json": `{"module":"a","reads":["mesh-delivery-indexer.placement"]}`}, `a: names "mesh-delivery-indexer.placement"`},
|
||||
{"a read of its candidates", map[string]string{"modules/b/module.json": `{"module":"b","reads":["mesh-delivery-indexer.placement-candidates"]}`}, "placement-candidates"},
|
||||
{"its event", map[string]string{"modules/c/module.json": `{"module":"c","on":[{"event":"mesh-delivery-indexer.placement-changed"}]}`}, "placement-changed"},
|
||||
{"the seat's own state is allowed", map[string]string{"modules/d/module.json": `{"module":"d","reads":["mesh-placement.placement"]}`}, ""},
|
||||
} {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for path, text := range row.files {
|
||||
p := filepath.Join(root, path)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(text), 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 TestTheCatalogueHoldsNoPlacementOfTheRepositoryPath(t *testing.T) {
|
||||
got, err := Findings(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, f := range got {
|
||||
t.Error(f)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
# 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.
|
||||
# the root, for a placement directory, and every manifest, for a read of the indexer's placement states.
|
||||
?placement
|
||||
modules/*/module.json
|
||||
@@ -0,0 +1,7 @@
|
||||
# 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.
|
||||
# that none of the old check scripts stands at the root.
|
||||
?merge-check.sh
|
||||
?merge-check-typescript.sh
|
||||
?check-nested-modules.sh
|
||||
@@ -0,0 +1,5 @@
|
||||
# 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.
|
||||
# Check("../.."): every manifest; its lists are this package's own files.
|
||||
modules/*/module.json
|
||||
@@ -2,8 +2,9 @@
|
||||
// issue 1838): a module is for any mesh, so no manifest, file, code string, identifier, comment, test name or
|
||||
// fixture of a covered module names a node of this one.
|
||||
//
|
||||
// The names are in the file `particulars` at the catalogue's root, one a line, `#` comments — the one place
|
||||
// they may be written. The modules covered are in `covered` at checks/particulars/covered, one a line. A name is matched
|
||||
// The names are the mesh's own, not the repository's: the checker hands them in the environment value
|
||||
// MESH_NODE_NAMES, comma-separated, from the node records it reads (novox/hq ADR 0567 §16). A run with none fails
|
||||
// loudly, since it would pass on anything. The modules covered are in `covered` at checks/particulars/covered, one a line. A name is matched
|
||||
// whole and in any case; a hyphen, an underscore or a CamelCase boundary parts one, so a longer word holding a name is another word.
|
||||
// The forge is not a node name: its host (git.novox.be) and the organisation path (novox/<repo>) are blanked
|
||||
// before a line is read.
|
||||
@@ -34,14 +35,28 @@ func main() {
|
||||
if len(os.Args) > 1 {
|
||||
root = os.Args[1]
|
||||
}
|
||||
os.Exit(check(root))
|
||||
os.Exit(check(root, namesFromEnv()))
|
||||
}
|
||||
|
||||
// NamesEnv is the environment value that carries the mesh's node names (mesh-sdk delivery.NodeNamesEnv).
|
||||
const NamesEnv = "MESH_NODE_NAMES"
|
||||
|
||||
// namesFromEnv is the names NamesEnv holds, comma-separated, trimmed, empty entries left out.
|
||||
func namesFromEnv() []string {
|
||||
var out []string
|
||||
for _, n := range strings.Split(os.Getenv(NamesEnv), ",") {
|
||||
if n = strings.TrimSpace(n); n != "" {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// check runs the check over a catalogue and returns the exit code: 0 clean, 1 a finding, 2 a list unreadable.
|
||||
func check(root string) int {
|
||||
names, err := readList(filepath.Join(root, "particulars"))
|
||||
if err == nil && len(names) == 0 {
|
||||
err = fmt.Errorf("particulars lists no name — the check would pass on anything")
|
||||
func check(root string, names []string) int {
|
||||
var err error
|
||||
if len(names) == 0 {
|
||||
err = fmt.Errorf("no node names (%s is empty) — the check would pass on anything", NamesEnv)
|
||||
}
|
||||
var covered []string
|
||||
if err == nil {
|
||||
|
||||
@@ -74,26 +74,24 @@ func TestANodeNamedPathIsRefusedThroughRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckReturnsOneOnAFindingAndTwoOnAnUnreadableList(t *testing.T) {
|
||||
func TestCheckReturnsOneOnAFindingAndTwoOnNoNames(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
write := func(p, s string) {
|
||||
p = filepath.Join(root, p)
|
||||
os.MkdirAll(filepath.Dir(p), 0o755)
|
||||
os.WriteFile(p, []byte(s), 0o644)
|
||||
}
|
||||
write("particulars", "ace\n")
|
||||
write("checks/particulars/covered", "kernel\n")
|
||||
write("modules/kernel/a", "clean\n")
|
||||
if got := check(root); got != 0 {
|
||||
if got := check(root, []string{"ace"}); got != 0 {
|
||||
t.Errorf("clean = %d, want 0", got)
|
||||
}
|
||||
write("modules/kernel/a", "on ace\n")
|
||||
if got := check(root); got != 1 {
|
||||
if got := check(root, []string{"ace"}); got != 1 {
|
||||
t.Errorf("finding = %d, want 1", got)
|
||||
}
|
||||
os.Remove(filepath.Join(root, "particulars"))
|
||||
if got := check(root); got != 2 {
|
||||
t.Errorf("no list = %d, want 2", got)
|
||||
if got := check(root, nil); got != 2 {
|
||||
t.Errorf("no names = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +115,10 @@ func TestOnlyCoveredModulesAreRead(t *testing.T) {
|
||||
|
||||
// The script's run of this check over the real tree (novox/hq ADR 0529 clause 2): no node name of this mesh in a covered module.
|
||||
func TestTheCoveredModulesNameNoNode(t *testing.T) {
|
||||
if code := check("../.."); code != 0 {
|
||||
if _, set := os.LookupEnv(NamesEnv); !set {
|
||||
t.Skip("ADR 0567 §16: this checker hands no node names (before D14 is delivered); the skip goes once it is")
|
||||
}
|
||||
if code := check("../..", namesFromEnv()); code != 0 {
|
||||
t.Fatalf("the check of the real catalogue exits %d (see what it printed above)", code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# 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.
|
||||
# check("../.."): every file of the modules the covered list names. The node names come from the run (MESH_NODE_NAMES).
|
||||
modules
|
||||
@@ -251,7 +251,8 @@ func main() {
|
||||
}
|
||||
var grown []string
|
||||
if baseDir == "" {
|
||||
fmt.Println("NOT COMPARED: no base branch's lists were given, so additions to them were not judged")
|
||||
fmt.Fprintln(os.Stderr, "NOT COMPARED: no base branch's lists were given (-base <dir>), so additions to them were not judged; that is a failure, not a pass (issue 1295)")
|
||||
os.Exit(2)
|
||||
} else {
|
||||
baseShared, _, err1 := list(filepath.Join(baseDir, "shared"))
|
||||
baseKnown, _, err2 := list(filepath.Join(baseDir, "known"))
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# 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.
|
||||
# Check("../.."): every manifest and every file of a module.
|
||||
modules
|
||||
@@ -32,8 +32,8 @@ func TestNoModuleNamesAnothersState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// And neither list gains a line the trunk's copies at the merge base do not hold, unless the line says why after `# allow:`. Skipped,
|
||||
// and said, where no trunk is beside.
|
||||
// And neither list gains a line the trunk's copies at the merge base do not hold, unless the line says why after `# allow:`. Fails, saying
|
||||
// NOT COMPARED, where no trunk is beside (issue 1295).
|
||||
func TestTheListsOfPathsHoldNoLineTheTrunksDoNot(t *testing.T) {
|
||||
sharedPath, okShared := trunk.Copy(t, "checks/paths/shared")
|
||||
knownPath, okKnown := trunk.Copy(t, "checks/paths/known")
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# 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.
|
||||
# the root's list of predefined keys and every manifest that names one.
|
||||
predefined.json
|
||||
modules/*/module.json
|
||||
@@ -0,0 +1,5 @@
|
||||
# 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.
|
||||
# Check("../.."): every manifest and the files it names.
|
||||
modules
|
||||
@@ -0,0 +1,5 @@
|
||||
# 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.
|
||||
# Check("../.."): every manifest.
|
||||
modules/*/module.json
|
||||
@@ -0,0 +1,5 @@
|
||||
# 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.
|
||||
# Check("../.."): every manifest and its templates.
|
||||
modules
|
||||
@@ -0,0 +1,5 @@
|
||||
# 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.
|
||||
# Check("../.."): every manifest.
|
||||
modules/*/module.json
|
||||
+14
-18
@@ -3,14 +3,14 @@
|
||||
//
|
||||
// The checker fetches the change's own repository at its base commit beside the change, as it fetches a sibling, and says where
|
||||
// in MESH_CHECK_BESIDE; the copy is under the repository's name (the same convention the other repositories' tests use). With
|
||||
// MESH_CHECK_BESIDE unset, or set without the catalogue's copy in it, the comparison is skipped and the test says NOT COMPARED
|
||||
// WITH THE TRUNK, naming why: the checker that runs before R4g's delivery places siblings but not the change's own repository,
|
||||
// so R4g's own check cannot hold the copy (issue 1295 notes that a missing copy becomes a failure once that checker is gone). A trunk that holds no such file had
|
||||
// MESH_CHECK_BESIDE unset, or set without the catalogue's copy in it, the test fails saying NOT COMPARED WITH THE TRUNK and
|
||||
// why: the checker places the change's own repository since R4g (issue 1295), so a missing copy is a fault, never a pass. A trunk that holds no such file had
|
||||
// none: the list's first copy, which nothing was added to.
|
||||
package trunk
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -23,13 +23,11 @@ const Repository = "mesh-catalog"
|
||||
const BesideEnv = "MESH_CHECK_BESIDE"
|
||||
|
||||
// Copy is the trunk's copy of the file at rel (a path from the catalogue's root), written to a file of the test's own: its path,
|
||||
// and whether the trunk holds one (false: its first copy). It skips the test, saying so, when no copy of the catalogue is beside.
|
||||
// and whether the trunk holds one (false: its first copy). It fails the test when no copy of the catalogue is beside.
|
||||
func Copy(t testing.TB, rel string) (string, bool) {
|
||||
t.Helper()
|
||||
raw, found, skip, err := Read(rel)
|
||||
raw, found, err := Read(rel)
|
||||
switch {
|
||||
case skip != "":
|
||||
t.Skip(skip)
|
||||
case err != nil:
|
||||
t.Fatal(err)
|
||||
case !found:
|
||||
@@ -43,27 +41,25 @@ func Copy(t testing.TB, rel string) (string, bool) {
|
||||
return path, true
|
||||
}
|
||||
|
||||
// Read is the trunk's copy of rel and whether the trunk holds one. skip is the words to skip the comparison with, when no trunk is
|
||||
// named; err is a trunk named and not there, or unreadable.
|
||||
func Read(rel string) (content []byte, found bool, skip string, err error) {
|
||||
// Read is the trunk's copy of rel and whether the trunk holds one. err is a trunk not beside the check (MESH_CHECK_BESIDE unset,
|
||||
// or without the catalogue's directory), or a copy unreadable: a comparison that cannot be made is never a pass.
|
||||
func Read(rel string) (content []byte, found bool, err error) {
|
||||
beside := os.Getenv(BesideEnv)
|
||||
if beside == "" {
|
||||
return nil, false, "NOT COMPARED WITH THE TRUNK: " + BesideEnv + " is not set, so the trunk's copy of " + rel +
|
||||
" is not beside this run; the checker places it", nil
|
||||
return nil, false, fmt.Errorf("NOT COMPARED WITH THE TRUNK: %s is not set, so the trunk's copy of %s is not beside this run; the checker places the change's own repository there (issue 1295)", BesideEnv, rel)
|
||||
}
|
||||
dir := filepath.Join(beside, Repository)
|
||||
if _, err := os.Stat(dir); errors.Is(err, os.ErrNotExist) {
|
||||
return nil, false, "NOT COMPARED WITH THE TRUNK: " + Repository + " is not beside this check in " + BesideEnv + "=" + beside +
|
||||
", so " + rel + " is not judged against the trunk's copy (the checker before R4g places no copy of the change's own repository)", nil
|
||||
return nil, false, fmt.Errorf("NOT COMPARED WITH THE TRUNK: %s is not beside this check in %s=%s, so %s is not judged against the trunk's copy; the checker places the change's own repository there at its trunk (issue 1295)", Repository, BesideEnv, beside, rel)
|
||||
} else if err != nil {
|
||||
return nil, false, "", err
|
||||
return nil, false, err
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, rel))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, false, "", nil
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, "", err
|
||||
return nil, false, err
|
||||
}
|
||||
return raw, true, "", nil
|
||||
return raw, true, nil
|
||||
}
|
||||
|
||||
+14
-14
@@ -17,27 +17,27 @@ func TestTheCloneBesideTheCheckIsTheTrunk(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(BesideEnv, beside)
|
||||
raw, found, skip, err := Read("checks/x/known")
|
||||
if err != nil || skip != "" || !found || string(raw) != "a\nb\n" {
|
||||
t.Fatalf("read %q found %v skip %q err %v", raw, found, skip, err)
|
||||
raw, found, err := Read("checks/x/known")
|
||||
if err != nil || !found || string(raw) != "a\nb\n" {
|
||||
t.Fatalf("read %q found %v err %v", raw, found, err)
|
||||
}
|
||||
if _, found, skip, err := Read("checks/x/absent"); err != nil || skip != "" || found {
|
||||
t.Fatalf("a file the trunk does not hold: found %v skip %q err %v; wanted its first copy", found, skip, err)
|
||||
if _, found, err := Read("checks/x/absent"); err != nil || found {
|
||||
t.Fatalf("a file the trunk does not hold: found %v err %v; wanted its first copy", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithNothingBesideTheComparisonIsSkippedAndSaidNotMade(t *testing.T) {
|
||||
func TestWithNothingBesideTheComparisonFailsAndSaysNotMade(t *testing.T) {
|
||||
t.Setenv(BesideEnv, "")
|
||||
_, found, skip, err := Read("checks/x/known")
|
||||
if found || err != nil || len(skip) < 12 || skip[:12] != "NOT COMPARED" {
|
||||
t.Fatalf("found %v, skip %q, err %v; wanted NOT COMPARED", found, skip, err)
|
||||
_, found, err := Read("checks/x/known")
|
||||
if found || err == nil || !strings.Contains(err.Error(), "NOT COMPARED") {
|
||||
t.Fatalf("found %v, err %v; wanted a failure saying NOT COMPARED", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACloneBesideWithoutTheCatalogueIsSkippedAndSaidNotMade(t *testing.T) {
|
||||
t.Setenv(BesideEnv, t.TempDir()) // the siblings are there, the catalogue's own base is not (the checker before R4g)
|
||||
_, found, skip, err := Read("checks/x/known")
|
||||
if found || err != nil || len(skip) < 12 || skip[:12] != "NOT COMPARED" || !strings.Contains(skip, "not beside") {
|
||||
t.Fatalf("found %v, skip %q, err %v; wanted NOT COMPARED naming the missing copy", found, skip, err)
|
||||
func TestACloneBesideWithoutTheCatalogueFailsAndSaysNotMade(t *testing.T) {
|
||||
t.Setenv(BesideEnv, t.TempDir()) // the siblings are there, the catalogue's own base is not
|
||||
_, found, err := Read("checks/x/known")
|
||||
if found || err == nil || !strings.Contains(err.Error(), "NOT COMPARED") || !strings.Contains(err.Error(), "not beside") {
|
||||
t.Fatalf("found %v, err %v; wanted a failure naming the missing copy", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,6 +206,9 @@ func excerpt(s string, from, to int) string {
|
||||
return strings.Join(strings.Fields(s[a:b]), " ")
|
||||
}
|
||||
|
||||
// modulePath matches a string that is only a module or import path ("git.example.org/org/repo/go/pkg").
|
||||
var modulePath = regexp.MustCompile(`^[a-z0-9.-]+\.[a-z]+/[A-Za-z0-9_./-]+$`)
|
||||
|
||||
// goStrings returns a Go file's string literals, adjacent ones joined across `+` as the program joins them.
|
||||
func goStrings(path, rel string) ([]text, error) {
|
||||
src, err := os.ReadFile(path)
|
||||
@@ -231,6 +234,11 @@ func goStrings(path, rel string) ([]text, error) {
|
||||
if err != nil {
|
||||
v = lit
|
||||
}
|
||||
if modulePath.MatchString(v) {
|
||||
// An import path or another module path names code, not words an agent reads.
|
||||
cur, joining = nil, false
|
||||
continue
|
||||
}
|
||||
if cur != nil && joining {
|
||||
cur.s += v
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# 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.
|
||||
# run("../.."): the root's lists of retired words and lines, and every module's texts.
|
||||
retired-words
|
||||
retired-lines
|
||||
modules
|
||||
@@ -0,0 +1,9 @@
|
||||
# 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.
|
||||
# the copies of this package in every module that carries one.
|
||||
modules/xorg/internal/desktop/*.go
|
||||
modules/lemurs/internal/desktop/*.go
|
||||
modules/i3/internal/desktop/*.go
|
||||
modules/xterm/internal/desktop/*.go
|
||||
modules/adwaita/internal/desktop/*.go
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
// manifest's.
|
||||
const NamesFileEnv = "MESH_ARTIFACT_STORE_NAMES_FILE"
|
||||
|
||||
// CatalogueModules and CatalogueModule are the catalogue's tools these definitions are read through.
|
||||
// CatalogueModules and CatalogueModule are the catalog's tools these definitions are read through.
|
||||
const (
|
||||
CatalogueModules = "mesh-catalog.catalog_modules"
|
||||
CatalogueModule = "mesh-catalog.catalog_module"
|
||||
@@ -143,21 +143,21 @@ func PinsIn(manifest json.RawMessage, names StoreNames) []pinned {
|
||||
return out
|
||||
}
|
||||
|
||||
// Definitions is what the catalogue's definitions pin in the store, and what of them could not be read.
|
||||
// Definitions is what the catalog's definitions pin in the store, and what of them could not be read.
|
||||
type Definitions struct {
|
||||
// Pins are by Key(repository, digest).
|
||||
Pins map[string][]Pin
|
||||
// Read is how many definitions were read; Unread says, per module, why its definition was not.
|
||||
Read int
|
||||
Unread map[string]string
|
||||
// NotAsked says why the catalogue could not be asked at all, when it could not.
|
||||
// NotAsked says why the catalog could not be asked at all, when it could not.
|
||||
NotAsked string
|
||||
// NamesNotRead says why the store's older names could not be read, when they could not.
|
||||
NamesNotRead string
|
||||
Names StoreNames
|
||||
}
|
||||
|
||||
// Catalogue reads the definitions through the catalogue's tools, and keeps each definition it read by its
|
||||
// Catalogue reads the definitions through the catalog's tools, and keeps each definition it read by its
|
||||
// module and commit, which never change together.
|
||||
type Catalogue struct {
|
||||
Ask Asking
|
||||
@@ -171,21 +171,21 @@ type catalogueEntry struct {
|
||||
Commit string `json:"commit"`
|
||||
}
|
||||
|
||||
// Read asks the catalogue for every module and reads each definition, bounded by DefinitionsTimeout.
|
||||
// Read asks the catalog for every module and reads each definition, bounded by DefinitionsTimeout.
|
||||
func (c *Catalogue) Read(ctx context.Context, names StoreNames) *Definitions {
|
||||
defs := &Definitions{Pins: map[string][]Pin{}, Unread: map[string]string{}, Names: names}
|
||||
if c == nil || c.Ask == nil {
|
||||
defs.NotAsked = "this bundle has no way to ask the catalogue"
|
||||
defs.NotAsked = "this bundle has no way to ask the catalog"
|
||||
return defs
|
||||
}
|
||||
raw, err := c.Ask(CatalogueModules, map[string]any{})
|
||||
if err != nil {
|
||||
defs.NotAsked = "the catalogue's modules could not be asked: " + err.Error()
|
||||
defs.NotAsked = "the catalog's modules could not be asked: " + err.Error()
|
||||
return defs
|
||||
}
|
||||
answer, output, ok := answerOf(raw)
|
||||
if !ok {
|
||||
defs.NotAsked = "the catalogue refused its modules: " + lastLine(output)
|
||||
defs.NotAsked = "the catalog refused its modules: " + lastLine(output)
|
||||
return defs
|
||||
}
|
||||
var list struct {
|
||||
@@ -193,7 +193,7 @@ func (c *Catalogue) Read(ctx context.Context, names StoreNames) *Definitions {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(answer, &list); err != nil || list.Error != "" || list.Modules == nil {
|
||||
defs.NotAsked = "the catalogue's modules answer is not readable: " + firstOf(list.Error, errText(err), lastLine(output))
|
||||
defs.NotAsked = "the catalog's modules answer is not readable: " + firstOf(list.Error, errText(err), lastLine(output))
|
||||
return defs
|
||||
}
|
||||
c.mu.Lock()
|
||||
|
||||
@@ -54,7 +54,7 @@ func Tools(s *Store, c Controller, cat *Catalogue) []stdio.Tool {
|
||||
Name: "artifact_store_references",
|
||||
Description: "What the controller's records say of each manifest the artifact store holds: kept (a definition " +
|
||||
"names it, or one of the five most recent builds of a module the mesh holds), named by a definition (no " +
|
||||
"record names it, and a definition in the catalogue pins it by digest, under the store's current name or " +
|
||||
"record names it, and a definition in the catalog pins it by digest, under the store's current name or " +
|
||||
"an older one: the module is said), the holder of a kept archive, " +
|
||||
"eligible (the mesh made it and keeps it for no reason), the holder of an eligible archive, let go yet present, " +
|
||||
"a named document the controller keeps under a tag, or unrecorded — no record names it. A platform manifest " +
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# 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.
|
||||
# the copies of desktop.go and its tests in every module that carries one.
|
||||
modules/blueman/cmd/blueman-tools/desktop.go
|
||||
modules/blueman/cmd/blueman-tools/desktop_test.go
|
||||
modules/blueman/cmd/blueman-tools/copies_test.go
|
||||
modules/forticlient/cmd/forticlient-tools/desktop.go
|
||||
modules/forticlient/cmd/forticlient-tools/desktop_test.go
|
||||
modules/forticlient/cmd/forticlient-tools/copies_test.go
|
||||
modules/nextcloud-client/cmd/nextcloud-client-tools/desktop.go
|
||||
modules/nextcloud-client/cmd/nextcloud-client-tools/desktop_test.go
|
||||
modules/nextcloud-client/cmd/nextcloud-client-tools/copies_test.go
|
||||
modules/nm-applet/cmd/nm-applet-tools/desktop.go
|
||||
modules/nm-applet/cmd/nm-applet-tools/desktop_test.go
|
||||
modules/nm-applet/cmd/nm-applet-tools/copies_test.go
|
||||
modules/openrazer/cmd/openrazer-tools/desktop.go
|
||||
modules/openrazer/cmd/openrazer-tools/desktop_test.go
|
||||
modules/openrazer/cmd/openrazer-tools/copies_test.go
|
||||
modules/polychromatic/cmd/polychromatic-tools/desktop.go
|
||||
modules/polychromatic/cmd/polychromatic-tools/desktop_test.go
|
||||
modules/polychromatic/cmd/polychromatic-tools/copies_test.go
|
||||
modules/slack/cmd/slack-tools/desktop.go
|
||||
modules/slack/cmd/slack-tools/desktop_test.go
|
||||
modules/slack/cmd/slack-tools/copies_test.go
|
||||
@@ -0,0 +1,6 @@
|
||||
# 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.
|
||||
# mesh-mail's store.json is the same as brother's; no module scan is left.
|
||||
modules/mesh-mail/files/store.json
|
||||
?modules/scan
|
||||
@@ -45,8 +45,8 @@ whenever the node's tool runtime collects the module's tools:
|
||||
|
||||
| file | holds |
|
||||
|---|---|
|
||||
| `managed-mcp.json` | the tool servers every session loads: the mesh's console as `mesh`, and the servers set in this module's `mcp_servers` setting. **Exclusive**: a server not listed here does not load — not one added with `claude mcp add`, not a project's `.mcp.json`, not a plugin's |
|
||||
| `managed-settings.json` | the keys set in this module's `managed_settings` setting — at the controller's terminal alone (hq issue 340) — under the mesh's own keys: the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, the key-helper while the node holds an API-key licence, git's credential file while the node holds the forge's read credential (below), and the two that name the `nox-mesh` marketplace and enable its plugin |
|
||||
| `managed-mcp.json` | the tool servers every session loads: the mesh's console as `mesh` (with a `timeout` of 660000 ms, mesh_wait's longest wait and a minute, so the agent's one-minute default does not cut a wait, issue 1871), and the servers set in this module's `mcp_servers` setting. **Exclusive**: a server not listed here does not load — not one added with `claude mcp add`, not a project's `.mcp.json`, not a plugin's |
|
||||
| `managed-settings.json` | the keys set in this module's `managed_settings` setting — at the controller's terminal alone (hq issue 340) — under the mesh's own keys: the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, the key-helper while the node holds an API-key licence, git's credential file while the node holds the forge's read credential (below), and the two that name the `nox-mesh` marketplace and enable its plugin, and `CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT` (660000) in `env` beside the operator's own, the mesh's key winning |
|
||||
| `CLAUDE.md` | how a session on this mesh works, this node's name and role, the conventions, the **"instead of" table** — then the instruction sections registered for every node and for this one |
|
||||
| `marketplace/` | the `nox-mesh` plugin (hq ADR 0216): the skills, subagents, commands and output styles registered for every node and for this one, offered in a session as `nox-mesh:<name>` — and the **guard on the agent's shell** (`guard/`), the mesh's own hook and the plugin's only one (hq issue 340). Replaced whole, staged beside and swapped in |
|
||||
|
||||
|
||||
@@ -83,6 +83,20 @@ func TestTheRendererWritesWhatTheTypeScriptOneWrote(t *testing.T) {
|
||||
for k := range MarketplaceKeys() {
|
||||
delete(m, k)
|
||||
}
|
||||
// The wait bound is new since the TypeScript (issue 1871), and is held below.
|
||||
if env, ok := m["env"].(map[string]any); ok {
|
||||
delete(env, "CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT")
|
||||
if len(env) == 0 {
|
||||
delete(m, "env")
|
||||
}
|
||||
}
|
||||
}
|
||||
if m, ok := a.(map[string]any); ok && file == "managed-mcp.json" {
|
||||
if servers, ok := m["mcpServers"].(map[string]any); ok {
|
||||
if entry, ok := servers["mesh"].(map[string]any); ok {
|
||||
delete(entry, "timeout")
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = json.Unmarshal([]byte(want[file]), &b)
|
||||
if !reflect.DeepEqual(a, b) {
|
||||
@@ -104,6 +118,21 @@ func TestASettingCannotReplaceTheMeshsOwnEntryAndABadNameIsLeftOut(t *testing.T)
|
||||
if mcp.MCPServers["mesh"]["url"] != "http://127.0.0.1:4270/mcp" || mcp.MCPServers["bad name"] != nil {
|
||||
t.Fatalf("%v", mcp.MCPServers)
|
||||
}
|
||||
// The entry bounds a call by mesh_wait's longest wait and the idle bound is laid over the operator's env
|
||||
// (issue 1871): a session's wait is not cut at the agent's one-minute default.
|
||||
if mcp.MCPServers["mesh"]["timeout"] != float64(660000) {
|
||||
t.Errorf("the mesh entry's timeout is %v", mcp.MCPServers["mesh"]["timeout"])
|
||||
}
|
||||
for _, held := range []map[string]any{nil, {"CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT": "1", "FOO": "bar"}} {
|
||||
var m struct {
|
||||
Env map[string]any `json:"env"`
|
||||
}
|
||||
got := Render(Facts{Console: "x"}, Settings{ManagedSettings: map[string]any{"env": held}}, nil, "/h", nil, Config{})
|
||||
_ = json.Unmarshal([]byte(got["managed-settings.json"]), &m)
|
||||
if m.Env["CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT"] != "660000" || (held != nil && m.Env["FOO"] != "bar") {
|
||||
t.Errorf("env %v rendered as %v", held, m.Env)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil, Config{}), Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil, Config{})) {
|
||||
t.Fatal("rendering is not deterministic")
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ package main
|
||||
// the file under the agent's managed directory did not change by a byte when the module moved to Go;
|
||||
// a test holds it to that renderer's own output (testdata/rendered-by-typescript.json).
|
||||
func instructionsText(node, role string) string {
|
||||
return "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalogue, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `" +
|
||||
return "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalog, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `" +
|
||||
node +
|
||||
"`\n- **Role:** " +
|
||||
role +
|
||||
"\n- The other nodes, their roles and what runs where: ask the controller (`mesh-controller.nodes`,\n `mesh-controller.node`). Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own verbs\n are `mesh-controller.<verb>`: `status`, `plan`, `node`, `assign`, `push`, `settings`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the controller's verbs or the catalogue.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalogue that owns it, never on disk. If\n unsure, `mesh-controller.plan` for the node says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
|
||||
"\n- The other nodes, their roles and what runs where: ask the controller (`mesh-controller.nodes`,\n `mesh-controller.node`). Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own verbs\n are `mesh-controller.<verb>`: `status`, `plan`, `node`, `assign`, `push`, `settings`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the controller's verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `mesh-controller.plan` for the node says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -98,6 +99,27 @@ func jsonFile(v any) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// MeshWaitBoundMillis is how long the agent may wait on the mesh's console for one call: mesh_wait's longest
|
||||
// wait (600 s, mesh-tools node-tools console.WaitLongest) plus a minute's margin. The agent bounds an HTTP
|
||||
// server's request by the largest of 60 s, the server entry's "timeout" and MCP_TIMEOUT, and a call that
|
||||
// receives nothing for CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT (5 minutes by default) is aborted
|
||||
// (code.claude.com/docs/en/mcp.md). Without these every mesh_wait ended "The operation timed out." at a
|
||||
// minute, whatever its timeout (issue 1871).
|
||||
const MeshWaitBoundMillis = 660000
|
||||
|
||||
// meshWaitEnv lays the idle bound into the environment the settings give every session, the mesh's key winning
|
||||
// over the operator's, like the forge's keys.
|
||||
func meshWaitEnv(managed map[string]any) {
|
||||
env := map[string]any{}
|
||||
if held, ok := managed["env"].(map[string]any); ok {
|
||||
for k, v := range held {
|
||||
env[k] = v
|
||||
}
|
||||
}
|
||||
env["CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT"] = strconv.Itoa(MeshWaitBoundMillis)
|
||||
managed["env"] = env
|
||||
}
|
||||
|
||||
// Render composes the three files. registered — what was registered through this module and applies
|
||||
// here — is laid over the servers the operator set in its settings; config is the rest of what was
|
||||
// registered (ADR 0216): its settings laid over the operator's `managed_settings`, its instruction sections
|
||||
@@ -119,7 +141,7 @@ func RenderWithMesh(facts Facts, settings Settings, binding *Binding, helperPath
|
||||
servers[name] = entry
|
||||
}
|
||||
}
|
||||
servers[meshEntry] = map[string]any{"type": "http", "url": facts.Console}
|
||||
servers[meshEntry] = map[string]any{"type": "http", "url": facts.Console, "timeout": MeshWaitBoundMillis}
|
||||
|
||||
// The operator's `managed_settings` (ADR 0213), set at the controller's terminal alone: nothing registered
|
||||
// through this module's tools is laid over it (novox/hq issue 340).
|
||||
@@ -143,6 +165,7 @@ func RenderWithMesh(facts Facts, settings Settings, binding *Binding, helperPath
|
||||
managed[key] = entries
|
||||
}
|
||||
forgeEnv(managed, facts.ForgeCredentials)
|
||||
meshWaitEnv(managed)
|
||||
if binding != nil && binding.Kind == "api-key" {
|
||||
managed["apiKeyHelper"] = helperPath
|
||||
}
|
||||
|
||||
@@ -32,11 +32,11 @@
|
||||
"withKey": {
|
||||
"managed-mcp.json": "{\n \"mcpServers\": {\n \"anton\": {\n \"type\": \"stdio\",\n \"command\": \"node\",\n \"args\": [\n \"/a/b.js\"\n ],\n \"env\": {}\n },\n \"docs\": {\n \"type\": \"stdio\",\n \"command\": \"docs-mcp\",\n \"args\": [\n \"--x\"\n ]\n },\n \"mesh\": {\n \"type\": \"http\",\n \"url\": \"http://127.0.0.1:4270/mcp\"\n },\n \"search\": {\n \"type\": \"http\",\n \"url\": \"https://s.example/mcp\"\n }\n }\n}\n",
|
||||
"managed-settings.json": "{\n \"attribution\": {\n \"commit\": \"\",\n \"pr\": \"\"\n },\n \"allowAllClaudeAiMcps\": true,\n \"apiKeyHelper\": \"/state/api-key-helper\"\n}\n",
|
||||
"CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalogue, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** the laptop\n- The other nodes, their roles and what runs where: ask the controller (`mesh-controller.nodes`,\n `mesh-controller.node`). Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own verbs\n are `mesh-controller.<verb>`: `status`, `plan`, `node`, `assign`, `push`, `settings`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the controller's verbs or the catalogue.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalogue that owns it, never on disk. If\n unsure, `mesh-controller.plan` for the node says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
|
||||
"CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalog, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** the laptop\n- The other nodes, their roles and what runs where: ask the controller (`mesh-controller.nodes`,\n `mesh-controller.node`). Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own verbs\n are `mesh-controller.<verb>`: `status`, `plan`, `node`, `assign`, `push`, `settings`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the controller's verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `mesh-controller.plan` for the node says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
|
||||
},
|
||||
"plain": {
|
||||
"managed-mcp.json": "{\n \"mcpServers\": {\n \"mesh\": {\n \"type\": \"http\",\n \"url\": \"http://127.0.0.1:4270/mcp\"\n }\n }\n}\n",
|
||||
"managed-settings.json": "{\n \"attribution\": {\n \"commit\": \"\",\n \"pr\": \"\"\n },\n \"allowAllClaudeAiMcps\": true\n}\n",
|
||||
"CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalogue, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** not stated — set it in this module's settings for the node\n- The other nodes, their roles and what runs where: ask the controller (`mesh-controller.nodes`,\n `mesh-controller.node`). Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own verbs\n are `mesh-controller.<verb>`: `status`, `plan`, `node`, `assign`, `push`, `settings`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the controller's verbs or the catalogue.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalogue that owns it, never on disk. If\n unsure, `mesh-controller.plan` for the node says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
|
||||
"CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalog, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** not stated — set it in this module's settings for the node\n- The other nodes, their roles and what runs where: ask the controller (`mesh-controller.nodes`,\n `mesh-controller.node`). Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own verbs\n are `mesh-controller.<verb>`: `status`, `plan`, `node`, `assign`, `push`, `settings`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the controller's verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `mesh-controller.plan` for the node says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,13 @@ package main
|
||||
// the desk says in its standing when that happened (`answers-lost-at`), and the router, which alone knows
|
||||
// what is still open and unanswered, says that again under its handle — replacing the notification in place,
|
||||
// so its buttons work again. Nothing resolved, silenced or cancelled comes back.
|
||||
// - **A store that does not answer does not lose where a message is shown, while the desk runs.** A message
|
||||
// is shown first and its notification ids kept after; when the state store does not answer then (the bus
|
||||
// reloading its accounts, 2026-10-26 09:18: the very outage that raised the condition), the work is taken
|
||||
// all the same and the ids are held in memory, recalled before the store, and kept at the start of the next
|
||||
// work and at the minute's standing. Otherwise the clearing of that message finds no notification to
|
||||
// replace and the urgent one stays on screen for good. A desk that restarts while it still holds some loses
|
||||
// where those are shown; the log says so when they are held.
|
||||
// - **Standing**: it can send when machines are named in its settings; which of them answered last is in its
|
||||
// status. A message no machine took is said to the router as failed, and tried again by it.
|
||||
|
||||
@@ -93,6 +100,9 @@ type Desk struct {
|
||||
pause map[string]pauseSeen
|
||||
// lost is when each machine's notifier last started again: the answers its notifications waited for are lost.
|
||||
lost map[string]time.Time
|
||||
// unkept is what was shown and not yet kept in the store, because it did not answer: recalled from here
|
||||
// before the store, and kept at the next piece of work.
|
||||
unkept map[string]Shown
|
||||
}
|
||||
|
||||
func (d *Desk) init() {
|
||||
@@ -107,6 +117,9 @@ func (d *Desk) init() {
|
||||
if d.lost == nil {
|
||||
d.lost = map[string]time.Time{}
|
||||
}
|
||||
if d.unkept == nil {
|
||||
d.unkept = map[string]Shown{}
|
||||
}
|
||||
if d.Now == nil {
|
||||
d.Now = time.Now
|
||||
}
|
||||
@@ -138,6 +151,7 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
|
||||
d.Logf("[desk-channel] work on %s could not be read; taken and dropped", subject)
|
||||
return nil
|
||||
}
|
||||
d.KeepUnkept()
|
||||
machines := d.Machines()
|
||||
if m.Secret {
|
||||
// Shown only where the notifier said lately it is not paused (the review of 2026-10-09): one held back
|
||||
@@ -167,12 +181,16 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
|
||||
switch {
|
||||
case edit:
|
||||
var err error
|
||||
if before, err = d.Store.Get(m.Handle); err != nil {
|
||||
if before, err = d.recall(m.Handle); err != nil {
|
||||
return err
|
||||
}
|
||||
if before == nil {
|
||||
d.Logf("[desk-channel] an edit of %s, which nothing here records as shown: shown as a new message; "+
|
||||
"a notification it was to replace, if any, stays", m.Handle)
|
||||
}
|
||||
case !m.Secret:
|
||||
var err error
|
||||
if before, err = d.Store.Get(m.Handle); err != nil {
|
||||
if before, err = d.recall(m.Handle); err != nil {
|
||||
d.Logf("[desk-channel] what is shown under this handle could not be read (%v): shown as a new message", err)
|
||||
before = nil
|
||||
}
|
||||
@@ -218,12 +236,91 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
|
||||
// Shown once and kept nowhere of this module's (a link's code): no edit or re-showing reaches it.
|
||||
return nil
|
||||
}
|
||||
if err := d.Store.Put(m.Handle, s); err != nil {
|
||||
d.Logf("[desk-channel] shown, and what was shown could not be kept: an edit of it will not reach it: %v", err)
|
||||
}
|
||||
d.keep(m.Handle, s)
|
||||
return nil
|
||||
}
|
||||
|
||||
// keep keeps what a handle shows in the store. When the store does not answer it is held in memory, where an
|
||||
// edit, a reminder and a click still find it, and kept later (KeepUnkept). The work is taken either way: offered
|
||||
// again it would race the message's own later edits, and a redelivery is bounded where an outage is not.
|
||||
func (d *Desk) keep(handle string, s Shown) {
|
||||
err := d.Store.Put(handle, s)
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if err == nil {
|
||||
delete(d.unkept, handle)
|
||||
return
|
||||
}
|
||||
d.unkept[handle] = s
|
||||
d.Logf("[desk-channel] shown, and where it is shown could not be kept yet (%v): held in memory, so its edit "+
|
||||
"and clearing still reach it, and kept when the store answers; %d held, lost if the desk restarts first",
|
||||
err, len(d.unkept))
|
||||
}
|
||||
|
||||
// KeepUnkept keeps in the store what it did not answer for before: at the start of each piece of work and at
|
||||
// the minute's standing. It stops at the first write the store does not answer, so one call waits on it once.
|
||||
func (d *Desk) KeepUnkept() {
|
||||
d.init()
|
||||
d.mu.Lock()
|
||||
held := make(map[string]Shown, len(d.unkept))
|
||||
for h, s := range d.unkept {
|
||||
held[h] = s
|
||||
}
|
||||
d.mu.Unlock()
|
||||
for h, s := range held {
|
||||
if err := d.Store.Put(h, s); err != nil {
|
||||
return
|
||||
}
|
||||
d.mu.Lock()
|
||||
if cur, ok := d.unkept[h]; ok && sameShown(cur, s) {
|
||||
delete(d.unkept, h)
|
||||
}
|
||||
d.mu.Unlock()
|
||||
d.Logf("[desk-channel] where %s is shown is kept now", h)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Desk) unkeptOf(handle string) (Shown, bool) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
s, ok := d.unkept[handle]
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// recall is what a handle shows: from memory when the store did not keep it yet, else from the store.
|
||||
func (d *Desk) recall(handle string) (*Shown, error) {
|
||||
if s, ok := d.unkeptOf(handle); ok {
|
||||
return &s, nil
|
||||
}
|
||||
return d.Store.Get(handle)
|
||||
}
|
||||
|
||||
// handles is every handle shown: the store's and those held in memory.
|
||||
func (d *Desk) handles() ([]string, error) {
|
||||
hs, err := d.Store.Handles()
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
seen := map[string]bool{}
|
||||
for _, h := range hs {
|
||||
seen[h] = true
|
||||
}
|
||||
for h := range d.unkept {
|
||||
if !seen[h] {
|
||||
hs = append(hs, h)
|
||||
}
|
||||
}
|
||||
if err != nil && len(d.unkept) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
return hs, nil
|
||||
}
|
||||
|
||||
func sameShown(a, b Shown) bool {
|
||||
x, err1 := json.Marshal(a)
|
||||
y, err2 := json.Marshal(b)
|
||||
return err1 == nil && err2 == nil && string(x) == string(y)
|
||||
}
|
||||
|
||||
// show puts one message on one machine's notifier, in place of `replace` when it names one.
|
||||
func (d *Desk) show(machine string, m asks.Message, replace int) (int, error) {
|
||||
urgency := "normal"
|
||||
@@ -308,12 +405,12 @@ func (d *Desk) Chosen(event, machine, ticket string) {
|
||||
}
|
||||
|
||||
func (d *Desk) find(ticket string) (string, *Shown) {
|
||||
handles, err := d.Store.Handles()
|
||||
handles, err := d.handles()
|
||||
if err != nil {
|
||||
return "", nil
|
||||
}
|
||||
for _, h := range handles {
|
||||
s, err := d.Store.Get(h)
|
||||
s, err := d.recall(h)
|
||||
if err != nil || s == nil {
|
||||
continue
|
||||
}
|
||||
@@ -356,9 +453,9 @@ func (d *Desk) Started(event, machine string) int {
|
||||
d.lost[machine] = d.Now()
|
||||
d.mu.Unlock()
|
||||
n := 0
|
||||
if handles, err := d.Store.Handles(); err == nil {
|
||||
if handles, err := d.handles(); err == nil {
|
||||
for _, h := range handles {
|
||||
if s, err := d.Store.Get(h); err == nil && s != nil && len(s.Message.Buttons) > 0 {
|
||||
if s, err := d.recall(h); err == nil && s != nil && len(s.Message.Buttons) > 0 {
|
||||
if _, here := s.On[machine]; here {
|
||||
n++
|
||||
}
|
||||
@@ -472,6 +569,7 @@ func (d *Desk) Status() map[string]any {
|
||||
"it proves nothing about who clicked: any process of the operator's account can choose an action, so it answers acknowledgements only",
|
||||
"an answer is taken only on a machine its message was shown on; a message with buttons stays until dismissed",
|
||||
"what it remembers is never shown again: when a notifier started again, the answers its notifications waited for are lost; the desk says when in its standing (answers-lost-at), and the router says again what is still open and unanswered, replacing those notifications in place — nothing resolved, silenced or cancelled comes back (issue 384)",
|
||||
"a store that does not answer does not lose where a message is shown while the desk runs: it is held in memory, its edit and clearing still replace it in place, and it is kept when the store answers again; a desk restarted before then loses where those are shown, and its log said how many it held",
|
||||
"it can send while a named machine's notifier said lately that it is not paused; a link's code is shown only on such a machine, kept nowhere, and sent secret so the notifier keeps it out of its history",
|
||||
}}
|
||||
if s.Why != "" {
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -363,9 +364,64 @@ func TestAMessageSaidAgainUnderItsHandleReplacesItsNotification(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// downStore is a store that does not answer while down: the bus reloading its accounts (2026-10-26 09:18).
|
||||
type downStore struct {
|
||||
memShown
|
||||
down *bool
|
||||
}
|
||||
|
||||
func (s downStore) Get(h string) (*Shown, error) {
|
||||
if *s.down {
|
||||
return nil, errors.New("nats: no responders available for request")
|
||||
}
|
||||
return s.memShown.Get(h)
|
||||
}
|
||||
func (s downStore) Put(h string, v Shown) error {
|
||||
if *s.down {
|
||||
return errors.New("nats: no responders available for request")
|
||||
}
|
||||
return s.memShown.Put(h, v)
|
||||
}
|
||||
|
||||
// The review of PR 176: a replace that fails on a machine keeps that machine's notification on record, so a
|
||||
// later edit or clearing still reaches it; failing everywhere, it is said to the router as failed.
|
||||
// later edit or clearing still reaches it; failing everywhere, it is said to the router as failed. And
|
||||
// (2026-10-26 09:18, an urgent "A new build of mesh-controller cannot be delivered" left on screen for hours
|
||||
// after its clearing): a message shown while the store does not answer keeps where it is shown in memory, its
|
||||
// work is taken, its clearing replaces it in place, and it is kept once the store answers.
|
||||
func TestAFailedReplaceKeepsTheNotificationItWasToReplace(t *testing.T) {
|
||||
// The raise, then its clearing, taken while the store does not answer; the store then stays down or comes
|
||||
// back. Each is taken (never offered again, where a redelivered raise would follow its clearing), the
|
||||
// clearing replaces the urgent notification in place, and the screen and the store end on the clearing.
|
||||
for _, back := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("the store does not answer, back %v", back), func(t *testing.T) {
|
||||
d, shown, _, store := newDesk(t, "laptop")
|
||||
down := true
|
||||
d.Store = downStore{store, &down}
|
||||
raised, _ := json.Marshal(asks.Message{Handle: "m9", Title: "A new build cannot be delivered", Urgent: true})
|
||||
if err := d.Take(asks.ChannelSubject(asks.Send, Kind), raised); err != nil {
|
||||
t.Fatalf("the raise was not taken: %v", err)
|
||||
}
|
||||
cleared, _ := json.Marshal(asks.Message{Handle: "m9", Title: "Cleared: a new build can be delivered"})
|
||||
if err := d.Take(asks.ChannelSubject(asks.Edit, Kind), cleared); err != nil {
|
||||
t.Fatalf("the clearing was not taken: %v", err)
|
||||
}
|
||||
d.KeepUnkept() // the minute's standing, store still down
|
||||
down = !back
|
||||
d.KeepUnkept()
|
||||
take(t, d, asks.Send, asks.Message{Handle: "m10", Title: "another"})
|
||||
if len(*shown) != 3 || (*shown)[1].args["replace_id"] != 1 || (*shown)[1].args["summary"] != "Cleared: a new build can be delivered" {
|
||||
t.Fatalf("the clearing did not replace the urgent notification: %+v", *shown)
|
||||
}
|
||||
s, kept := store["m9"]
|
||||
if back != kept || (kept && s.Message.Title != "Cleared: a new build can be delivered") {
|
||||
t.Fatalf("store back %v, kept %v: %+v", back, kept, store)
|
||||
}
|
||||
if got, _ := d.recall("m9"); got == nil || got.Message.Title != "Cleared: a new build can be delivered" {
|
||||
t.Fatalf("recalled %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
d, shown, said, store := newDesk(t, "laptop", "desktop")
|
||||
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "2 failed services on shanks"})
|
||||
first := store["m1"].On
|
||||
|
||||
@@ -100,6 +100,7 @@ func run(d *Desk) {
|
||||
}()
|
||||
go func() {
|
||||
for {
|
||||
d.KeepUnkept()
|
||||
d.SayStanding()
|
||||
time.Sleep(time.Minute)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -62,3 +65,60 @@ func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
|
||||
t.Errorf("the build packs files/ into %d archives, not one", packs)
|
||||
}
|
||||
}
|
||||
|
||||
// The packaged dnsmasq.service orders itself Before= network-online.target and nss-lookup.target. A drop-in
|
||||
// that orders it After= either of them makes an ordering cycle, and systemd drops the start job: the mesh
|
||||
// resolver stayed down for 90 minutes at a boot (novox/hq issue 1126). Wants=, Requires= and BindsTo= are
|
||||
// refused too: they pull the target in, and with the packaged ordering they are the same mistake. Because the
|
||||
// tunnel is ordered after those targets, the drop-in cannot wait for the address either: a start that finds it
|
||||
// missing fails at once and Restart=on-failure brings it up (see the next test).
|
||||
func TestDropInDoesNotOrderAfterATargetThePackagedUnitOrdersBefore(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join(moduleDir, "files", "mesh.conf"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
forbidden := []string{"network-online.target", "nss-lookup.target"}
|
||||
for n, line := range strings.Split(string(raw), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
key, val, ok := strings.Cut(line, "=")
|
||||
key, val = strings.TrimSpace(key), strings.TrimSpace(val)
|
||||
if !ok || (key != "After" && key != "Requires" && key != "Wants" && key != "BindsTo") {
|
||||
continue
|
||||
}
|
||||
for _, target := range forbidden {
|
||||
if strings.Contains(val, target) {
|
||||
t.Errorf("mesh.conf line %d: %s=%s orders dnsmasq after a target its packaged unit orders itself before: a cycle", n+1, key, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The wait for the address in ExecStartPre is at most two seconds: the tunnel that brings the address is ordered
|
||||
// after network-online.target, which the resolver is ordered before, so a longer wait would hold the tunnel back.
|
||||
func TestAddressWaitIsAtMostTwoSeconds(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join(moduleDir, "files", "mesh.conf"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, "ExecStartPre=") {
|
||||
continue
|
||||
}
|
||||
m := regexp.MustCompile(`seq 1 (\d+)`).FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
t.Fatalf("ExecStartPre has no bounded wait (seq 1 N): %s", line)
|
||||
}
|
||||
found = true
|
||||
if n, _ := strconv.Atoi(m[1]); n > 2 {
|
||||
t.Errorf("ExecStartPre waits up to %d s for the address, more than 2 s", n)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("mesh.conf has no ExecStartPre")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
# Written by the mesh (module dnsmasq, novox/hq issue 348). Replaced on every push.
|
||||
#
|
||||
# The resolver binds its addresses once, at start (bind-interfaces), so it starts only once this
|
||||
# machine's private address is there: it waits up to 80 seconds for it, and a start that found it
|
||||
# missing is tried again every ten seconds rather than left failed.
|
||||
# machine's private address is there: a start that finds it missing fails after at most two seconds and
|
||||
# is tried again every ten seconds (Restart=on-failure, no start limit) until the tunnel has brought it.
|
||||
# It does not order itself After= or Wants= network-online.target, and its wait is short: the packaged
|
||||
# unit is Before= network-online.target and nss-lookup.target, and the tunnel is ordered after them, so
|
||||
# a long wait would hold the tunnel back and a drop-in ordering would be a cycle (issue 1126).
|
||||
[Unit]
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
StartLimitIntervalSec=0
|
||||
|
||||
[Service]
|
||||
ExecStartPre=/usr/bin/sh -c 'for i in $(seq 1 80); do ip -4 -o addr show to ${machine:address} | grep -q . && exit 0; sleep 1; done; echo "${machine:address} is not on this machine" >&2; exit 1'
|
||||
ExecStartPre=/usr/bin/sh -c 'for i in $(seq 1 2); do ip -4 -o addr show to ${machine:address} | grep -q . && exit 0; sleep 1; done; echo "${machine:address} is not on this machine" >&2; exit 1'
|
||||
Restart=on-failure
|
||||
RestartSec=10s
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,25 @@
|
||||
# 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.
|
||||
# the copies of desktop.go and its tests in every module that carries one.
|
||||
modules/blueman/cmd/blueman-tools/desktop.go
|
||||
modules/blueman/cmd/blueman-tools/desktop_test.go
|
||||
modules/blueman/cmd/blueman-tools/copies_test.go
|
||||
modules/forticlient/cmd/forticlient-tools/desktop.go
|
||||
modules/forticlient/cmd/forticlient-tools/desktop_test.go
|
||||
modules/forticlient/cmd/forticlient-tools/copies_test.go
|
||||
modules/nextcloud-client/cmd/nextcloud-client-tools/desktop.go
|
||||
modules/nextcloud-client/cmd/nextcloud-client-tools/desktop_test.go
|
||||
modules/nextcloud-client/cmd/nextcloud-client-tools/copies_test.go
|
||||
modules/nm-applet/cmd/nm-applet-tools/desktop.go
|
||||
modules/nm-applet/cmd/nm-applet-tools/desktop_test.go
|
||||
modules/nm-applet/cmd/nm-applet-tools/copies_test.go
|
||||
modules/openrazer/cmd/openrazer-tools/desktop.go
|
||||
modules/openrazer/cmd/openrazer-tools/desktop_test.go
|
||||
modules/openrazer/cmd/openrazer-tools/copies_test.go
|
||||
modules/polychromatic/cmd/polychromatic-tools/desktop.go
|
||||
modules/polychromatic/cmd/polychromatic-tools/desktop_test.go
|
||||
modules/polychromatic/cmd/polychromatic-tools/copies_test.go
|
||||
modules/slack/cmd/slack-tools/desktop.go
|
||||
modules/slack/cmd/slack-tools/desktop_test.go
|
||||
modules/slack/cmd/slack-tools/copies_test.go
|
||||
@@ -275,6 +275,70 @@ reported no key gets no entry, and its client says it waits. Keys titled `mesh:`
|
||||
a key added by hand never is. Every consumer is made again every ten minutes and at once when a key changes.
|
||||
`go test ./cmd/forge-account-provider ./internal/forgeaccount` runs it against a fake forge.
|
||||
|
||||
## The mesh's accounts read every repository (the operator, 2026-10-26)
|
||||
|
||||
The tools act as the plain account `mesh-tools` (ADR 0365 §2, issue 469), which writes to the organisation `novox`
|
||||
through its team; the build modules fetch over ssh as `nox-builder` (ADR 0466 §3), in a team that reads the
|
||||
organisation's code; the machines' agents use `nox-agent` (ADR 0410 §1). Any other repository each saw only when public,
|
||||
so a private repository of a user answered every tool 404 and every build's fetch "not authorized to read". `readall.ts`
|
||||
makes each of these accounts (one list, `meshAccounts` in `tools-account.ts`, whose two Go names
|
||||
`internal/forgeaccount`'s test holds to its own constants) a collaborator that **reads only** on each repository it
|
||||
cannot read: one repository at a time, no organisation membership, the least the forge offers. An account the forge
|
||||
does not hold yet (the forge-account provider makes `nox-builder` and `nox-agent` when their first consumer asks) is
|
||||
named and asked nothing; the start's pass is run again ten minutes apart until every account was there once.
|
||||
|
||||
- **When:** one pass over every repository of every user and organisation at the start (a forge not answering yet is
|
||||
asked again a minute apart until one pass ran), and on the forge's system webhook: `forge.repository.created` and
|
||||
`forge.fork` at once, and the first event of any repository this process has not seen readable (the forge may not
|
||||
send a creation for every owner). Nothing polls for it.
|
||||
- **Not caught:** a repository made private after it was seen readable. The forge sends no event for a change of
|
||||
visibility, and the start's pass already saw it readable, so its events ask nothing. It is granted at the module's
|
||||
next start, or now by `gitea_grant_read`; `gitea_unreadable_repos` shows it meanwhile. A repository whose permission
|
||||
the forge will not answer is named as refused (or, in the listing, with permission `unknown`) and the pass goes on.
|
||||
- **With what:** the site administrator's login (`MESH_GITEA_ADMIN_PASSWORD_FILE`), because adding a collaborator asks a
|
||||
repository's administration, which on another user's repository only the site administrator holds. It is used for
|
||||
five calls alone: list every repository, read one, whether an account exists, read an account's permission on one,
|
||||
add an account as a read collaborator. The tools' own calls keep their token.
|
||||
- **Tools:** `gitea_unreadable_repos` (per account, every repository it cannot read, the permission the forge gives
|
||||
it there and why, a count per account and the accounts the forge does not hold) and `gitea_grant_read` (the same
|
||||
grant by hand, now, on every repository or on those named, for every account or those named; each grant read back
|
||||
from the forge, a refusal answered in the forge's own words).
|
||||
|
||||
### Teams are read with the administrator's login
|
||||
|
||||
`gitea_team_rights` (issue 1563) answers the forge's teams of an organisation (novox when not given), or one team: its
|
||||
members, permission, units (read, write or admin per unit), whether it covers all repositories, and whether it is
|
||||
read-only. With a `user` it adds the teams that user is in and its effective right on every repository (or those named),
|
||||
listing those above read: the check that a build key's user, `nox-builder` in `builders`, reads and pushes nothing. The
|
||||
tools' token lacks `read:organization` (the forge answers 403), so the read goes with the administrator's login, GET only.
|
||||
|
||||
### Two settings have named tools, and a retargeted pull request is judged again
|
||||
|
||||
`gitea_api` refuses a repository's settings and a pull request's base (guard.ts). Issue 1422 note 7 gave each its tool:
|
||||
`gitea_default_branch_set` (owner, repo, branch, why) and `gitea_pull_base_set` (owner, repo, number, base, why). Both
|
||||
act as the tools' account, need a reason, refuse a branch the repository does not have, and answer the value before and
|
||||
after; a default must also be a branch a protection rule guards, and only an open pull request has its base changed.
|
||||
A pull request's announced head is kept under `owner/repo#number@base`, so a retarget (same head, new base) is
|
||||
announced again into the new base by the next poll instead of being skipped as already announced.
|
||||
|
||||
### The merging account writes the repositories the mesh delivers
|
||||
|
||||
`mesh-tools` sets every commit status and merges; outside the organisation it only reads (above), so a pull request of
|
||||
a user's repository could be checked and asked but never merged (issue 1945: car-hunter #2's status refused 403 after
|
||||
the operator's Approve). `writes.ts` makes it a collaborator that **writes** on each repository that builds a registered
|
||||
module, and on no other (the operator, 2026-10-26): the build modules' and the agents' accounts keep read.
|
||||
|
||||
- **Which:** the indexer's state `mesh-delivery-indexer.modules` (in this module's `reads`), one key per registered
|
||||
module, each naming its repository. A repository of `novox` needs nothing: the team writes it. Every other repository
|
||||
of a user stays read-only.
|
||||
- **When:** the state's watch hands every module at the start and each one registered after; a repository not yet
|
||||
written is granted then, read back from the forge, and said on the bus as `gitea.repo.writable` {owner, repo,
|
||||
account}, on which mesh-delivery-gate reads that repository again. Nothing polls.
|
||||
- **Tools:** `gitea_unwritable_repos` (every delivered repository the account cannot write, its permission and why)
|
||||
and `gitea_grant_write` (the same grant by hand, on every delivered repository or those named; what it granted, what
|
||||
was written already, what the forge refused in its own words, and the repositories left read-only and why).
|
||||
`gitea_commit_statuses` answers `merge.writes`, whether the account may write the repository at all.
|
||||
|
||||
## Files it does not know
|
||||
|
||||
Each directory or archive this module declares says how it treats a file it did not write (ADR 0422, ADR 0416).
|
||||
|
||||
+28
-9
@@ -83,6 +83,9 @@ export function checkStatusOf(change: OwedChange): { owner: string; repo: string
|
||||
return { owner, repo, sha, revision, status };
|
||||
}
|
||||
|
||||
/** How many mesh/check writes this module has at the forge at once. */
|
||||
export const WRITES_AT_ONCE = 4;
|
||||
|
||||
/** The one writer of mesh/check: each owed revision of the checker's, in revision order per key. */
|
||||
export class CheckStatuses {
|
||||
/** The newest revision written of each key, and the newest seen; the oldest keys go past what any forge holds open. */
|
||||
@@ -91,6 +94,9 @@ export class CheckStatuses {
|
||||
/** The turn each key's writes wait on, and the asks again still running. */
|
||||
private turns = new Map<string, Promise<unknown>>();
|
||||
private again = new Set<Promise<void>>();
|
||||
/** Writes at the forge at once, at most WRITES_AT_ONCE: a watch's start hands over every key together (issue 1726). */
|
||||
private writing = 0;
|
||||
private queued: (() => void)[] = [];
|
||||
private forge: CheckForge;
|
||||
private say: (line: string) => void;
|
||||
private sleep: (ms: number) => Promise<void>;
|
||||
@@ -123,7 +129,7 @@ export class CheckStatuses {
|
||||
this.say(`[gitea] ${asked.owner}/${asked.repo}@${asked.sha.slice(0, 12)}: mesh/check at revision ${asked.revision} is older than ${newest}, already written, and is dropped`);
|
||||
return "dropped";
|
||||
}
|
||||
await this.forge.setCommitStatus(asked.owner, asked.repo, asked.sha, asked.status);
|
||||
await this.atTheForge(() => this.forge.setCommitStatus(asked.owner, asked.repo, asked.sha, asked.status));
|
||||
this.remember(this.written, key, asked.revision);
|
||||
return "written";
|
||||
});
|
||||
@@ -133,19 +139,32 @@ export class CheckStatuses {
|
||||
return turn;
|
||||
}
|
||||
|
||||
/** What the watch hands over: written, or when the forge refuses, asked again on a bound in the background until it is
|
||||
* written or a newer revision of the key was seen. Never throws: a watch's handler that throws loses the change. */
|
||||
/** What the watch hands over: taken at once and written in the background, or when the forge refuses, asked again on a
|
||||
* bound until it is written or a newer revision of the key was seen. Never throws: a watch's handler that throws loses
|
||||
* the change. It never waits on the forge (novox/hq issue 1726): the runtime hands a watch's current values one at a
|
||||
* time, waits for each to be taken and gives the whole set 10 s; a forge write per key made a large owed outlast that
|
||||
* bound at every attempt, so the watch never held and no verdict reached mesh/check. Each key's writes still take
|
||||
* turns in revision order (apply). */
|
||||
async take(change: OwedChange): Promise<void> {
|
||||
const run = this.apply(change).then(() => {}, (err) => this.askAgain(change, err));
|
||||
this.again.add(run);
|
||||
void run.finally(() => this.again.delete(run));
|
||||
}
|
||||
|
||||
/** Runs one forge write when fewer than WRITES_AT_ONCE are running, else after one ends. */
|
||||
private async atTheForge<T>(write: () => Promise<T>): Promise<T> {
|
||||
if (this.writing >= WRITES_AT_ONCE) await new Promise<void>((r) => this.queued.push(r));
|
||||
else this.writing++;
|
||||
try {
|
||||
await this.apply(change);
|
||||
} catch (err) {
|
||||
const run = this.askAgain(change, err);
|
||||
this.again.add(run);
|
||||
void run.finally(() => this.again.delete(run));
|
||||
return await write();
|
||||
} finally {
|
||||
const next = this.queued.shift();
|
||||
if (next) next();
|
||||
else this.writing--;
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolves when no ask again is running. */
|
||||
/** Resolves when no write or ask again is running. */
|
||||
async settled(): Promise<void> {
|
||||
while (this.again.size) await Promise.all([...this.again]);
|
||||
}
|
||||
|
||||
+284
-11
@@ -9,6 +9,7 @@ import { ConfiguredToken, MintedToken, type TokenSource } from "./token.js";
|
||||
import { MAIN_BRANCH, mainRuleBody, protectionBody, type BranchProtection, type ProtectionWanted } from "./protection.js";
|
||||
import { Refused, checkSentPath, ruleCovers, pathSegment, pathSegments } from "./guard.js";
|
||||
import { isCommit } from "./checks.js";
|
||||
import type { ForgeTeam } from "./teamrights.js";
|
||||
|
||||
/** A repository's path on the forge, its owner and name each one segment (guard.ts pathSegment): uniform for every
|
||||
* call, whether the names came from a caller or from the forge's own answer. */
|
||||
@@ -50,6 +51,9 @@ export interface GiteaRepo {
|
||||
default_branch?: string;
|
||||
/** When anything last moved in it — a push, and so a merge. */
|
||||
updated_at?: string;
|
||||
/** Whether the account asking may write it (the forge's permissions.push or admin): a commit status and a merge need
|
||||
* it. Absent when the forge did not say. */
|
||||
writes?: boolean;
|
||||
}
|
||||
|
||||
/** An issue, with its labels flattened to names. */
|
||||
@@ -98,6 +102,10 @@ export interface GiteaPull {
|
||||
html_url: string;
|
||||
/** The description: where a pull request says it goes after another (`after: <repository>`, novox/hq ADR 0239). */
|
||||
body?: string;
|
||||
/** Whether the forge would merge it into its base now (gitea's `mergeable`): false while it is in conflict, while the
|
||||
* forge is still working that out after its base moved, or while it is a work in progress; absent when the forge did not
|
||||
* say. What a group's merge reads of every member before it merges any (novox/hq issue 1275). */
|
||||
mergeable?: boolean;
|
||||
}
|
||||
|
||||
/** An open pull request with what its base requires and what its head has: what gitea_open_pulls answers. */
|
||||
@@ -167,8 +175,27 @@ function ignoreSecretSetting(cfg: Record<string, unknown>, key: string): void {
|
||||
console.error(`the setting ${key} is ignored: it comes from the vault`);
|
||||
}
|
||||
|
||||
/** How long the forge may take to answer one request before it is cut and said (novox/hq issue 1577: a request for a 5 MB
|
||||
* file never ended, and every read through the module timed out behind it). */
|
||||
export const FORGE_REQUEST_TIMEOUT_MS = 20_000;
|
||||
|
||||
/** The most a tool's answer carries of one file: the bus's message bound is 1 MiB and a file travels as text (issue 1577). */
|
||||
export const MAX_FILE_BYTES = 512 * 1024;
|
||||
|
||||
/** One request to the forge, cut at the limit and failing loud naming the request. */
|
||||
async function timedFetch(url: string, init: RequestInit, label: string, timeoutMs: number): Promise<Response> {
|
||||
try {
|
||||
return await fetch(url, { ...init, signal: init.signal ? AbortSignal.any([init.signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs) });
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "TimeoutError") throw new Error(`Gitea API ${label}: no answer in ${timeoutMs / 1000} s, the request was cut`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export class GiteaClient {
|
||||
readonly baseUrl: string;
|
||||
/** The limit of one request; a test sets it short. */
|
||||
requestTimeoutMs = FORGE_REQUEST_TIMEOUT_MS;
|
||||
private readonly tokens: TokenSource;
|
||||
|
||||
/** A token given as a string is one somebody configured; a source decides for itself (token.ts). */
|
||||
@@ -223,14 +250,14 @@ export class GiteaClient {
|
||||
|
||||
private send(path: string, options: RequestInit, token: string): Promise<Response> {
|
||||
checkSentPath(path);
|
||||
return fetch(`${this.baseUrl}/api/v1${path}`, {
|
||||
return timedFetch(`${this.baseUrl}/api/v1${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `token ${token}`,
|
||||
...(options.headers as Record<string, string> | undefined),
|
||||
},
|
||||
});
|
||||
}, `${options.method ?? "GET"} ${path}`, this.requestTimeoutMs);
|
||||
}
|
||||
|
||||
/** Generic authenticated API call — the escape hatch for endpoints without a dedicated method.
|
||||
@@ -715,6 +742,15 @@ export class GiteaClient {
|
||||
return { created: true, rule };
|
||||
}
|
||||
|
||||
/** Write a branch's rule with exactly the fields given (gitea_branch_protection_set_full): an edit of the rule named
|
||||
* for the branch, or a new one. As this client's account, which the forge refuses where it is no administrator. */
|
||||
async writeBranchProtection(owner: string, repo: string, branch: string, body: Record<string, unknown>, creating: boolean): Promise<BranchProtection> {
|
||||
return creating
|
||||
? this.request<BranchProtection>(`${repoPath(owner, repo)}/branch_protections`, { method: "POST", body: JSON.stringify({ rule_name: branch, ...body }) })
|
||||
: this.request<BranchProtection>(`${repoPath(owner, repo)}/branch_protections/${pathSegments("branch", branch)}`,
|
||||
{ method: "PATCH", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
async createPullRequest(
|
||||
owner: string,
|
||||
repo: string,
|
||||
@@ -764,6 +800,22 @@ export class GiteaClient {
|
||||
/** One file's contents at a ref (default the repository's default branch), decoded. */
|
||||
async getFile(owner: string, repo: string, path: string, ref?: string): Promise<{ path: string; ref?: string; sha: string; size: number; content: string }> {
|
||||
const qs = ref ? `?ref=${encodeURIComponent(ref)}` : "";
|
||||
// The size first, from the file's directory listing, which carries no content: a file over what an answer can carry is
|
||||
// refused without its body being fetched (issue 1577). A file the listing does not show is asked for as before.
|
||||
const clean = path.replace(/^\/+|\/+$/g, "");
|
||||
pathSegments("file path", clean); // a path that is another path or user is refused before anything reaches the forge
|
||||
const slash = clean.lastIndexOf("/");
|
||||
const dir = slash < 0 ? "" : clean.slice(0, slash);
|
||||
let listing: any[] | null = null;
|
||||
try {
|
||||
listing = await this.request<any[]>(`${repoPath(owner, repo)}/contents${dir ? "/" + pathSegments("file path", dir) : ""}${qs}`);
|
||||
} catch (err) {
|
||||
if (!(err instanceof Error) || !/: 404 /.test(err.message)) throw err;
|
||||
}
|
||||
const entry = Array.isArray(listing) ? listing.find((e) => e?.path === clean) : undefined;
|
||||
if (entry && Number(entry.size) > MAX_FILE_BYTES) {
|
||||
throw new Error(`the file is ${Number(entry.size)} bytes, over the ${MAX_FILE_BYTES / 1024} KiB a tool answer can carry; read it from a clone`);
|
||||
}
|
||||
const f = await this.request<any>(`${repoPath(owner, repo)}/contents/${pathSegments("file path", path.replace(/^\/+|\/+$/g, ""))}${qs}`);
|
||||
if (!f || f.type !== "file") throw new Error(`Gitea API: ${path} is not a file`);
|
||||
const content = f.encoding === "base64" ? Buffer.from(String(f.content ?? ""), "base64").toString("utf8") : String(f.content ?? "");
|
||||
@@ -775,6 +827,30 @@ export class GiteaClient {
|
||||
return (raw ?? []).map((b) => ({ name: String(b?.name ?? ""), commit: String(b?.commit?.id ?? ""), protected: Boolean(b?.protected) }));
|
||||
}
|
||||
|
||||
/** One branch as the forge keeps it: whether a protection rule guards it; null when the repository has no such branch. */
|
||||
async branchState(owner: string, repo: string, branch: string): Promise<{ protected: boolean } | null> {
|
||||
try {
|
||||
const b = await this.request<any>(`${repoPath(owner, repo)}/branches/${pathSegments("branch", branch)}`);
|
||||
return { protected: Boolean(b?.protected) };
|
||||
} catch (err) {
|
||||
if (/: 404 /.test(err instanceof Error ? err.message : "")) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Make a branch the repository's default (issue 1422): what the forge shows, clones and opens pull requests against. */
|
||||
async setDefaultBranch(owner: string, repo: string, branch: string): Promise<GiteaRepo> {
|
||||
return GiteaClient.mapRepo(await this.request<any>(repoPath(owner, repo), { method: "PATCH", body: JSON.stringify({ default_branch: branch }) }));
|
||||
}
|
||||
|
||||
/** Change the branch an open pull request merges into (issue 1422). The head stays; the forge recomputes the diff. */
|
||||
async setPullBase(owner: string, repo: string, index: number, base: string): Promise<GiteaPull> {
|
||||
indexOf(index);
|
||||
return GiteaClient.mapPull(
|
||||
await this.request<any>(`${repoPath(owner, repo)}/pulls/${index}`, { method: "PATCH", body: JSON.stringify({ base }) }),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteBranch(owner: string, repo: string, branch: string): Promise<void> {
|
||||
await this.request(`${repoPath(owner, repo)}/branches/${pathSegments("branch", branch)}`, { method: "DELETE" });
|
||||
}
|
||||
@@ -789,14 +865,29 @@ export class GiteaClient {
|
||||
|
||||
/** Merge a pull request — never forced past its checks, and, given the head that was checked, only that head:
|
||||
* the forge refuses the merge when the branch moved since (novox/hq ADR 0266). */
|
||||
/** How long a merge waits before it asks again after the forge's 405 "Please try again later" (novox/hq issue 1275):
|
||||
* the forge says so while it works out whether the pull request merges, for a few seconds after its base moved. */
|
||||
static mergeRetryAfter: number[] = [1000, 2000, 4000];
|
||||
|
||||
async mergePullRequest(owner: string, repo: string, index: number, method = "merge", deleteBranch = false,
|
||||
headCommit?: string): Promise<void> {
|
||||
indexOf(index);
|
||||
await this.request(`${repoPath(owner, repo)}/pulls/${index}/merge`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ Do: method, delete_branch_after_merge: deleteBranch, force_merge: false,
|
||||
...(headCommit ? { head_commit_id: headCommit } : {}) }),
|
||||
});
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
await this.request(`${repoPath(owner, repo)}/pulls/${index}/merge`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ Do: method, delete_branch_after_merge: deleteBranch, force_merge: false,
|
||||
...(headCommit ? { head_commit_id: headCommit } : {}) }),
|
||||
});
|
||||
return;
|
||||
} catch (err) {
|
||||
// Only the forge's transient answer is asked again, briefly; every other refusal, and this one past the last
|
||||
// wait, is the caller's to say. head_commit_id keeps a head that moved in between from landing.
|
||||
const wait = GiteaClient.mergeRetryAfter[attempt];
|
||||
if (wait === undefined || !tryAgainLater(err)) throw err;
|
||||
await new Promise((r) => setTimeout(r, wait));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Mappers: the wire shape is broad and unstable; the mesh sees only these fields. ----
|
||||
@@ -812,6 +903,8 @@ export class GiteaClient {
|
||||
html_url: r.html_url,
|
||||
default_branch: r.default_branch,
|
||||
updated_at: r.updated_at ?? undefined,
|
||||
...(r.permissions && typeof r.permissions === "object"
|
||||
? { writes: r.permissions.push === true || r.permissions.admin === true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -842,6 +935,7 @@ export class GiteaClient {
|
||||
updated_at: p.updated_at ?? undefined,
|
||||
html_url: p.html_url,
|
||||
body: typeof p.body === "string" ? p.body : undefined,
|
||||
mergeable: typeof p.mergeable === "boolean" && !p.merged ? p.mergeable : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -853,6 +947,25 @@ interface AdminResponse {
|
||||
readonly body: any;
|
||||
}
|
||||
|
||||
/** A repository as the site administrator sees it: what saying why an account cannot read it needs (readall.ts). */
|
||||
export interface ForgeRepository {
|
||||
full_name: string;
|
||||
owner: string;
|
||||
name: string;
|
||||
private: boolean;
|
||||
archived: boolean;
|
||||
/** The owner's own visibility: public, limited or private. A public repository of an owner not public is hidden
|
||||
* from whoever is no member. */
|
||||
owner_visibility: string;
|
||||
}
|
||||
|
||||
function forgeRepository(r: any): ForgeRepository {
|
||||
const owner = String(r?.owner?.login ?? r?.owner?.username ?? "");
|
||||
const name = String(r?.name ?? "");
|
||||
return { full_name: String(r?.full_name ?? `${owner}/${name}`), owner, name, private: r?.private === true,
|
||||
archived: r?.archived === true, owner_visibility: String(r?.owner?.visibility ?? "public") };
|
||||
}
|
||||
|
||||
/**
|
||||
* The forge's admin client, over **basic auth** — gitea's own code, living in the module, used only
|
||||
* by the provisioner (novox/hq ADR 0048/0076).
|
||||
@@ -896,14 +1009,14 @@ export class GiteaAdmin {
|
||||
* throwing on it — the caller decides which non-2xx codes are idempotent successes. Only an
|
||||
* unexpected status becomes an error, and only where the caller says so. */
|
||||
private async request(path: string, options: RequestInit = {}): Promise<AdminResponse> {
|
||||
const res = await fetch(`${this.baseUrl}/api/v1${path}`, {
|
||||
const res = await timedFetch(`${this.baseUrl}/api/v1${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: this.authorization,
|
||||
...(options.headers as Record<string, string> | undefined),
|
||||
},
|
||||
});
|
||||
}, `${options.method ?? "GET"} ${path}`, FORGE_REQUEST_TIMEOUT_MS);
|
||||
const text = await res.text();
|
||||
let body: any = null;
|
||||
if (text) {
|
||||
@@ -932,6 +1045,31 @@ export class GiteaAdmin {
|
||||
return Array.isArray(res.body) ? res.body : [];
|
||||
}
|
||||
|
||||
/** Write a branch's rule with exactly the fields given, as the forge's administrator (gitea_branch_protection_set_full;
|
||||
* the operator, 2026-10-28): an edit of the rule named for the branch, or a new one. */
|
||||
async writeBranchProtection(owner: string, repo: string, branch: string, body: Record<string, unknown>, creating: boolean): Promise<BranchProtection> {
|
||||
const path = creating ? `${repoPath(owner, repo)}/branch_protections` : `${repoPath(owner, repo)}/branch_protections/${pathSegments("branch", branch)}`;
|
||||
const res = await this.request(path, { method: creating ? "POST" : "PATCH", body: JSON.stringify(creating ? { rule_name: branch, ...body } : body) });
|
||||
if (res.status !== 200 && res.status !== 201) GiteaAdmin.fail(path, res);
|
||||
return res.body as BranchProtection;
|
||||
}
|
||||
|
||||
/** A repository as the forge answers it to its administrator: every setting (gitea_repo_settings_set_full). */
|
||||
async repoSettings(owner: string, repo: string): Promise<Record<string, unknown>> {
|
||||
const path = repoPath(owner, repo);
|
||||
const res = await this.request(path);
|
||||
if (res.status !== 200) GiteaAdmin.fail(path, res);
|
||||
return res.body as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Edit a repository's settings with exactly the fields given, as the forge's administrator. */
|
||||
async editRepoSettings(owner: string, repo: string, body: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
const path = repoPath(owner, repo);
|
||||
const res = await this.request(path, { method: "PATCH", body: JSON.stringify(body) });
|
||||
if (res.status !== 200) GiteaAdmin.fail(path, res);
|
||||
return res.body as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Put main's rule right in every repository of an organisation (novox/hq ADR 0398 §2, issue 948): no push, only
|
||||
* the tools' account merges, and no status required (ADR 0534 §1). The rule there is edited; a repository with no
|
||||
* rule for main gets one. Answers the repositories it touched. With the administrator's login:
|
||||
@@ -970,6 +1108,105 @@ export class GiteaAdmin {
|
||||
return repos;
|
||||
}
|
||||
|
||||
// ---- The tools' account reads every repository (readall.ts) ----
|
||||
|
||||
/** Every repository on the forge, of every user and organisation: the search as the site administrator, whom the
|
||||
* forge answers every repository, private ones and those of private owners included. Only a read. */
|
||||
async everyRepository(): Promise<ForgeRepository[]> {
|
||||
const all: ForgeRepository[] = [];
|
||||
for (let page = 1; page <= 200; page++) {
|
||||
const res = await this.request(`/repos/search?limit=50&page=${page}`);
|
||||
if (res.status !== 200) GiteaAdmin.fail("/repos/search", res);
|
||||
const list: any[] = Array.isArray(res.body?.data) ? res.body.data : [];
|
||||
all.push(...list.map(forgeRepository));
|
||||
if (list.length < 50) break;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
/** One repository as the site administrator sees it, or null when the forge holds none by that name. */
|
||||
async repository(owner: string, repo: string): Promise<ForgeRepository | null> {
|
||||
const res = await this.request(repoPath(owner, repo));
|
||||
if (res.status === 404) return null;
|
||||
if (res.status !== 200) GiteaAdmin.fail(repoPath(owner, repo), res);
|
||||
return forgeRepository(res.body);
|
||||
}
|
||||
|
||||
/** Whether the forge holds an account of this name. */
|
||||
async accountExists(user: string): Promise<boolean> {
|
||||
const path = `/users/${encodeURIComponent(user)}`;
|
||||
const res = await this.request(path);
|
||||
if (res.status === 404) return false;
|
||||
if (res.status !== 200) GiteaAdmin.fail(path, res);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** What the forge gives an account on a repository, effective: none, read, write, admin or owner. It is the repository as
|
||||
* that account sees it (the site administrator acts as the account with `sudo`), so access through an organisation's team
|
||||
* counts. The collaborator endpoint is not used: it names only a collaborator's own permission, and says "read" of an
|
||||
* account that writes through a team (novox/hq issue 1945). */
|
||||
async repositoryPermission(owner: string, repo: string, user: string): Promise<string> {
|
||||
const path = `${repoPath(owner, repo)}?sudo=${encodeURIComponent(user)}`;
|
||||
const res = await this.request(path);
|
||||
if (res.status === 404) return "none";
|
||||
if (res.status !== 200) GiteaAdmin.fail(path, res);
|
||||
const p = res.body?.permissions;
|
||||
if (p?.admin === true) return res.body?.owner?.login === user ? "owner" : "admin";
|
||||
if (p?.push === true) return "write";
|
||||
return p?.pull === true ? "read" : "none";
|
||||
}
|
||||
|
||||
/** Make an account a collaborator of a repository that reads only. The forge asks a repository's administrator for
|
||||
* it, which on a repository of another user only the site administrator is. Idempotent: the forge answers 204 again. */
|
||||
async addReadCollaborator(owner: string, repo: string, user: string): Promise<void> {
|
||||
await this.addCollaborator(owner, repo, user, "read");
|
||||
}
|
||||
|
||||
/** Make an account a collaborator of a repository that writes: pushes a branch, sets a commit status, merges (writes.ts:
|
||||
* only on a repository that builds a registered module). An existing read collaborator is changed to write. */
|
||||
async addWriteCollaborator(owner: string, repo: string, user: string): Promise<void> {
|
||||
await this.addCollaborator(owner, repo, user, "write");
|
||||
}
|
||||
|
||||
/** The rule that applies to main in a repository, as its administrator reads it: a rule named main, else the first glob
|
||||
* rule covering main; null when none does. With the administrator's login. */
|
||||
async mainRule(owner: string, repo: string): Promise<BranchProtection | null> {
|
||||
const rules = await this.branchProtections(owner, repo);
|
||||
return rules.find((r) => (r.rule_name ?? r.branch_name) === MAIN_BRANCH)
|
||||
?? rules.find((r) => ruleCovers(String(r.rule_name ?? r.branch_name ?? ""), MAIN_BRANCH)) ?? null;
|
||||
}
|
||||
|
||||
/** Put an account on main's merge whitelist in a repository of a user, and nothing else (the operator, 2026-10-26:
|
||||
* write, the rule untouched). Only an existing rule whose merge whitelist is on is changed, and only its whitelist: the
|
||||
* account is added to the logins already there. Push, status checks, approvals and the administrator override stay as
|
||||
* the operator set them; with no rule, or the whitelist off, nothing is written (whoever writes may merge). Read back:
|
||||
* the forge drops a whitelist login that cannot write the repository and still answers success, so a whitelist read back
|
||||
* without the account fails loud. Answers the rule as the forge holds it after. With the administrator's login. */
|
||||
async ensureMergerListed(owner: string, repo: string, account: string): Promise<{ changed: boolean; rule: BranchProtection | null }> {
|
||||
const rule = await this.mainRule(owner, repo);
|
||||
const listed = rule?.merge_whitelist_usernames ?? [];
|
||||
if (!rule || rule.enable_merge_whitelist !== true || listed.includes(account)) return { changed: false, rule };
|
||||
const name = String(rule.rule_name ?? rule.branch_name);
|
||||
const path = `${repoPath(owner, repo)}/branch_protections/${encodeURIComponent(name)}`;
|
||||
const res = await this.request(path, { method: "PATCH", body: JSON.stringify({ merge_whitelist_usernames: [...listed, account] }) });
|
||||
if (res.status !== 200) GiteaAdmin.fail(path, res);
|
||||
const after = (await this.branchProtections(owner, repo)).find((r) => (r.rule_name ?? r.branch_name) === name);
|
||||
if (!after) throw new Error(`main's rule ${name} of ${owner}/${repo} was written, but the forge does not list it`);
|
||||
const held = after.merge_whitelist_usernames ?? [];
|
||||
if (after.enable_merge_whitelist === true && !held.includes(account)) {
|
||||
throw new Error(`${account} was added to the merge whitelist of main's rule ${name} of ${owner}/${repo}, but the forge ` +
|
||||
`holds [${held.join(", ")}]: it drops a login that cannot write the repository`);
|
||||
}
|
||||
return { changed: true, rule: after };
|
||||
}
|
||||
|
||||
private async addCollaborator(owner: string, repo: string, user: string, permission: "read" | "write"): Promise<void> {
|
||||
const path = `${repoPath(owner, repo)}/collaborators/${encodeURIComponent(user)}`;
|
||||
const res = await this.request(path, { method: "PUT", body: JSON.stringify({ permission }) });
|
||||
if (res.status === 204 || res.status === 200) return;
|
||||
GiteaAdmin.fail(path, res);
|
||||
}
|
||||
|
||||
/** Ensure the npm-owner org exists. 201 created, 2xx/404-then-created, and 422/409 (a concurrent
|
||||
* create won the race) are all success. */
|
||||
async ensureOrg(name: string): Promise<void> {
|
||||
@@ -1031,6 +1268,35 @@ export class GiteaAdmin {
|
||||
return match ? Number(match.id) : null;
|
||||
}
|
||||
|
||||
/** The teams of an organisation, as the forge's Team objects (issue 1563). With the administrator's login: the tools'
|
||||
* account lacks read:organization, so the list answers it 403. Only a read. */
|
||||
async teams(org: string): Promise<ForgeTeam[]> {
|
||||
const all: ForgeTeam[] = [];
|
||||
for (let page = 1; page <= 50; page++) {
|
||||
const path = `/orgs/${encodeURIComponent(org)}/teams?limit=50&page=${page}`;
|
||||
const res = await this.request(path);
|
||||
if (res.status !== 200) GiteaAdmin.fail(`/orgs/${org}/teams`, res);
|
||||
const list: ForgeTeam[] = Array.isArray(res.body) ? res.body : [];
|
||||
all.push(...list);
|
||||
if (list.length < 50) break;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
/** The logins of a team's members (GET /teams/{id}/members). Only a read. */
|
||||
async teamMembers(teamId: number): Promise<string[]> {
|
||||
const all: string[] = [];
|
||||
for (let page = 1; page <= 50; page++) {
|
||||
const path = `/teams/${teamId}/members?limit=50&page=${page}`;
|
||||
const res = await this.request(path);
|
||||
if (res.status !== 200) GiteaAdmin.fail(`/teams/${teamId}/members`, res);
|
||||
const list: any[] = Array.isArray(res.body) ? res.body : [];
|
||||
all.push(...list.map((u) => String(u?.login ?? u?.username ?? "")).filter(Boolean));
|
||||
if (list.length < 50) break;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
/** Ensure a user exists with exactly this password. Created if absent; if already there, its
|
||||
* password is patched — so the mesh minting a new secret takes on the next reconcile.
|
||||
*
|
||||
@@ -1088,9 +1354,9 @@ export class GiteaAdmin {
|
||||
* other answer rejects (novox/hq issue 120).
|
||||
*/
|
||||
async holdsTeamMember(org: string, team: string, username: string, password: string): Promise<boolean> {
|
||||
const me = await fetch(`${this.baseUrl}/api/v1/user`, {
|
||||
const me = await timedFetch(`${this.baseUrl}/api/v1/user`, {
|
||||
headers: { Authorization: "Basic " + Buffer.from(`${username}:${password}`).toString("base64") },
|
||||
});
|
||||
}, "GET /user", FORGE_REQUEST_TIMEOUT_MS);
|
||||
if (me.status === 401 || me.status === 403) return false;
|
||||
if (me.status !== 200) throw new Error(`Gitea GET /user as ${username}: ${me.status}`);
|
||||
const teams = await this.request(`/orgs/${encodeURIComponent(org)}/teams?limit=50`);
|
||||
@@ -1160,3 +1426,10 @@ export function directoriesAbove(paths: string[]): string[] {
|
||||
function encodePath(path: string): string {
|
||||
return pathSegments("file path", path);
|
||||
}
|
||||
|
||||
/** Whether an error is the forge's 405 "Please try again later" on a merge: its answer while the pull request's
|
||||
* mergeability is being worked out (or it is in conflict, which a later ask says the same way). */
|
||||
export function tryAgainLater(err: unknown): boolean {
|
||||
const words = err instanceof Error ? err.message : String(err);
|
||||
return / 405 /.test(words) && /try again later/i.test(words);
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ func configFromEnv(getenv func(string) string) (Config, error) {
|
||||
HostFile: getenv("MESH_FORGE_HOST_FILE"),
|
||||
OriginsFile: getenv("MESH_GITEA_ORIGINS_FILE"),
|
||||
Port: port,
|
||||
HostKeyFile: getenv("MESH_GITEA_HOST_KEY_FILE"), // optional
|
||||
Forge: forgehttp.Client{URL: strings.TrimRight(getenv("MESH_GITEA_URL"), "/"), User: getenv("MESH_GITEA_ADMIN_USER"),
|
||||
PasswordFile: getenv("MESH_GITEA_ADMIN_PASSWORD_FILE"), Timeout: 20 * time.Second},
|
||||
Runner: execRunner{},
|
||||
|
||||
@@ -44,10 +44,13 @@ type Config struct {
|
||||
// origins, one per line.
|
||||
HostFile, OriginsFile string
|
||||
// Port is where the forge's sshd is published on the mesh.
|
||||
Port int
|
||||
Forge forgehttp.Client
|
||||
Runner Runner
|
||||
Log func(format string, a ...any)
|
||||
Port int
|
||||
// HostKeyFile is the forge's own ed25519 host key (the .pub file in its data directory). It is read first; when it
|
||||
// is unset or unreadable the forge's sshd is asked with ssh-keyscan instead (issue 1939).
|
||||
HostKeyFile string
|
||||
Forge forgehttp.Client
|
||||
Runner Runner
|
||||
Log func(format string, a ...any)
|
||||
}
|
||||
|
||||
// Service answers the consumers of the provision `forge-account` (novox/hq ADR 0399, ADR 0400; issue 955): for each
|
||||
@@ -448,14 +451,30 @@ func (s *Service) httpsBase() (string, error) {
|
||||
return "", fmt.Errorf("%s names no https origin", s.cfg.OriginsFile)
|
||||
}
|
||||
|
||||
// scan reads the forge's ed25519 host key from its own sshd and writes it as the machines' known_hosts line.
|
||||
// scan gives the forge's ed25519 host key as the machines' known_hosts line. The key the forge itself holds is read
|
||||
// from its file first; only when that cannot be read is the forge's sshd asked over the network, and when that fails
|
||||
// too (the forge's container is restarting) the key read before is kept, as a host key does not change (issue 1939).
|
||||
func (s *Service) scan(ctx context.Context) (string, error) {
|
||||
host, err := s.hostName()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if s.cfg.HostKeyFile != "" {
|
||||
raw, ferr := os.ReadFile(s.cfg.HostKeyFile)
|
||||
if ferr == nil {
|
||||
if line, perr := knownHostLine("127.0.0.1 "+strings.TrimSpace(string(raw)), host, s.cfg.Port); perr == nil {
|
||||
return line, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
out, err := s.cfg.Runner.Run(ctx, "ssh-keyscan", "-t", "ed25519", "-p", fmt.Sprint(s.cfg.Port), "127.0.0.1")
|
||||
if err != nil {
|
||||
s.mu.Lock()
|
||||
kept := s.known
|
||||
s.mu.Unlock()
|
||||
if kept != "" {
|
||||
return kept, nil
|
||||
}
|
||||
return "", fmt.Errorf("the forge's ssh host key cannot be read: %w", err)
|
||||
}
|
||||
return knownHostLine(string(out), host, s.cfg.Port)
|
||||
|
||||
@@ -696,3 +696,42 @@ func TestNothingIsSaidForAPerNodeUserWithoutMeshKeys(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The forge's own host key file is read first: no scan over the network is needed (issue 1939).
|
||||
func TestTheHostKeyIsReadFromTheForgesOwnFile(t *testing.T) {
|
||||
r := newRig(t)
|
||||
f := filepath.Join(t.TempDir(), "ssh_host_ed25519_key.pub")
|
||||
if err := os.WriteFile(f, []byte("ssh-ed25519 AAAAC3FILEKEY forge@host\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.svc.cfg.HostKeyFile = f
|
||||
r.runner.err = errors.New("sshd down")
|
||||
got, err := r.svc.scan(context.Background())
|
||||
if err != nil || got != "[novox.internal]:222 ssh-ed25519 AAAAC3FILEKEY" {
|
||||
t.Fatalf("scan = %q, %v", got, err)
|
||||
}
|
||||
if r.runner.args != nil {
|
||||
t.Errorf("the forge's sshd was asked: %v", r.runner.args)
|
||||
}
|
||||
}
|
||||
|
||||
// A file that cannot be read falls back to the forge's sshd.
|
||||
func TestAnUnreadableHostKeyFileFallsBackToTheScan(t *testing.T) {
|
||||
r := newRig(t)
|
||||
r.svc.cfg.HostKeyFile = filepath.Join(t.TempDir(), "missing.pub")
|
||||
got, err := r.svc.scan(context.Background())
|
||||
if err != nil || got != "[novox.internal]:222 ssh-ed25519 AAAAC3HOSTKEY" {
|
||||
t.Fatalf("scan = %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// While the forge restarts, a key read before is kept: the sshd is not up, the key has not changed.
|
||||
func TestARestartingForgeKeepsTheKeyReadBefore(t *testing.T) {
|
||||
r := newRig(t)
|
||||
r.svc.known = "[novox.internal]:222 ssh-ed25519 AAAAC3HOSTKEY"
|
||||
r.runner.err = errors.New("connection refused")
|
||||
got, err := r.svc.scan(context.Background())
|
||||
if err != nil || got != r.svc.known {
|
||||
t.Fatalf("scan = %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
// forge as themselves;
|
||||
// - `gitea_branch_protection_set` lets no person push and no administrator merge past a rule, and on main keeps
|
||||
// the tools' account the only one that merges; it requires no status, as no rule of the mesh does (ADR 0534 §5);
|
||||
// - `gitea_default_branch_set` and `gitea_pull_base_set` are the two settings gitea_api refuses, as named tools: a branch that
|
||||
// exists (and, for a default, that a rule guards), a stated reason, the tools' account, the value before and after (issue 1422);
|
||||
// - `gitea_merge_pull_request` merges only into a branch that refuses its push, only a head whose mesh/check passed
|
||||
// against the trunk's head, approved by the operator where the branch requires no status (ADR 0534 §4), and only
|
||||
// that head;
|
||||
|
||||
+118
-66
@@ -53,13 +53,17 @@ import { announce, keepWatermark, readWatermark } from "./announce.js";
|
||||
import { mergeTool } from "./merge.js";
|
||||
import { GiteaAdmin, GiteaClient, movedSince } from "./client.js";
|
||||
import { keepMainRules, mainRulesStep } from "./mainrules.js";
|
||||
import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, commentFor, composedGate, groupGateStatus, headsToAnnounce, maintained,
|
||||
requiredOf, statusesFor, writesGate, type Announced, type Checked, repoCheckFacts, reposToLook } 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 { GateWriter } from "./gate.js";
|
||||
import { HOOK_PATH, HOOK_PORT, Pace, claimHead, ensureSystemHook, eventsTool, keptSecret, receiver, type ForgeEvent } from "./webhook.js";
|
||||
import { keepSiblings, noteComposed, noteVerdict, readSiblings, siblingsFile, siblingsOf, weighSiblings } from "./siblings.js";
|
||||
import { bindInBackground } from "./bind.js";
|
||||
import { bindInBackground, bindUntilHeld } from "./bind.js";
|
||||
import { announcementOf, keepTook, lateVerdictComment, outOfDateWords, sameCommit, tookFile, weighTrunks } from "./trunk.js";
|
||||
import { ReadAll, readAllTools } from "./readall.js";
|
||||
import { DeliveredWrites, INDEXER_MODULES, writeTools, type ModulesChange } from "./writes.js";
|
||||
import { TOOLS_ACCOUNT, TOOLS_ORG, meshAccounts } from "./tools-account.js";
|
||||
|
||||
// Without a way to a token — configured, or mintable with the admin account (token.ts) — there is
|
||||
// nothing to watch; log and stay quiet rather than crash the runtime. With one, the first poll mints
|
||||
@@ -135,12 +139,7 @@ async function pollMerged(client: GiteaClient): Promise<void> {
|
||||
// Closed unmerged (novox/hq ADR 0239): announced once, since the watching began, so its delivery stops.
|
||||
const closedKey = `closed:${repo.full_name}#${pull.number}`;
|
||||
if (!pull.merged && pull.state === "closed" && !announced.has(closedKey)) {
|
||||
if (primedMerges && !!pull.updated_at && !!since && pull.updated_at > since) {
|
||||
await announce("pull.closed", { owner: repo.owner, repo: repo.name, number: pull.number, title: pull.title,
|
||||
head: pull.head, head_sha: pull.head_sha, base: pull.base, html_url: pull.html_url });
|
||||
console.log(`[gitea] announced ${repo.full_name}#${pull.number} closed unmerged`);
|
||||
await siblingMoves(client, repo.full_name, pull.number);
|
||||
}
|
||||
if (primedMerges && !!pull.updated_at && !!since && pull.updated_at > since) await announceClosed(client, repo, pull);
|
||||
announced.add(closedKey);
|
||||
continue;
|
||||
}
|
||||
@@ -165,6 +164,23 @@ async function pollMerged(client: GiteaClient): Promise<void> {
|
||||
type RepoOf = { owner: string; name: string; full_name: string; clone_url?: string };
|
||||
type PullOf = Awaited<ReturnType<GiteaClient["listPullRequests"]>>[number];
|
||||
|
||||
/** A pull request closed without a merge, announced once (ADR 0239): by its event, or by the start's pass. */
|
||||
async function announceClosed(client: GiteaClient, repo: RepoOf, pull: PullOf): Promise<boolean> {
|
||||
const closedKey = `closed:${repo.full_name}#${pull.number}`;
|
||||
if (pull.merged || pull.state !== "closed" || announced.has(closedKey)) return false;
|
||||
announced.add(closedKey);
|
||||
try {
|
||||
await announce("pull.closed", { owner: repo.owner, repo: repo.name, number: pull.number, title: pull.title,
|
||||
head: pull.head, head_sha: pull.head_sha, base: pull.base, html_url: pull.html_url });
|
||||
} catch (err) {
|
||||
announced.delete(closedKey);
|
||||
throw err;
|
||||
}
|
||||
console.log(`[gitea] announced ${repo.full_name}#${pull.number} closed unmerged`);
|
||||
await siblingMoves(client, repo.full_name, pull.number);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** **A merge, announced once, whichever path saw it first** (novox/hq issue 729): the poll or the forge's event. */
|
||||
async function announceMerge(client: GiteaClient, repo: RepoOf, pull: PullOf): Promise<boolean> {
|
||||
if (!pull.merged || !pull.merge_commit_sha || announced.has(pull.merge_commit_sha)) return false;
|
||||
@@ -240,7 +256,7 @@ let heads: Announced = {};
|
||||
let primedPulls = false;
|
||||
/** **A head, announced once, whichever path saw it first** (novox/hq issue 729): the poll or the forge's event. */
|
||||
async function announceHead(client: GiteaClient, repo: RepoOf, pull: OpenOf): Promise<boolean> {
|
||||
const key = `${repo.full_name}#${pull.number}`;
|
||||
const key = headKey(repo.full_name, pull.number, pull.base);
|
||||
const sha = String(pull.head_sha ?? "");
|
||||
const before = heads[key];
|
||||
// Claimed before the first wait, so the other path finds it taken while this one is still announcing.
|
||||
@@ -278,43 +294,18 @@ async function alreadyChecked(client: GiteaClient, repo: RepoOf, pull: OpenOf):
|
||||
}
|
||||
|
||||
let lastPullLook = "";
|
||||
// **A sweep now and then looks at every repository** (novox/hq issue 347), whatever moved: the floor is a
|
||||
// heuristic, and a heuristic that misses is caught by the next sweep rather than never.
|
||||
const SWEEP_MS = 10 * 60_000;
|
||||
let lastSweep = 0;
|
||||
async function pollPulls(client: GiteaClient): Promise<void> {
|
||||
/** The pass over every open pull request: run at the start and when the receiver listens, never on a timer (issue 1422). */
|
||||
async function passPulls(client: GiteaClient): Promise<void> {
|
||||
const began = new Date().toISOString();
|
||||
const floor = lastPullLook ? new Date(Date.parse(lastPullLook) - MARGIN_MS).toISOString() : "";
|
||||
const dayAgo = new Date(Date.now() - 24 * 3600_000).toISOString();
|
||||
// A pull request opened, reopened or retargeted moves its own update time and not its repository's: the
|
||||
// pull requests updated since the floor are found by their own (issue 347). A search that fails is a sweep.
|
||||
let sweep = Date.now() - lastSweep >= SWEEP_MS;
|
||||
let updated: { owner: string; name: string; updated_at?: string }[] = [];
|
||||
if (floor && !sweep) {
|
||||
try {
|
||||
updated = await client.searchOpenPulls(floor);
|
||||
} catch (err) {
|
||||
console.error(`[gitea] the pull requests updated lately could not be searched, so every repository is looked at — ${err instanceof Error ? err.message : err}`);
|
||||
sweep = true;
|
||||
}
|
||||
}
|
||||
if (sweep) lastSweep = Date.now();
|
||||
for (const repo of reposToLook(await client.listAllRepos(), floor, updated, sweep)) {
|
||||
// Every open pull request updated since the floor, paged (novox/hq issue 579): the newest 20 alone left a head
|
||||
// beyond them unannounced. A sweep and the first pass read every page.
|
||||
const open = await client.listOpenPullsSince(repo.owner, repo.name, sweep ? "" : floor);
|
||||
for (const pull of headsToAnnounce(repo.full_name, open, heads)) {
|
||||
const key = `${repo.full_name}#${pull.number}`;
|
||||
const fresh = primedPulls || (!!pull.updated_at && pull.updated_at > dayAgo);
|
||||
// The first look after a start remembers nothing: a head the forge already shows a merge check of was announced.
|
||||
if (fresh && !primedPulls && await alreadyChecked(client, repo, pull)) {
|
||||
heads[key] = String(pull.head_sha);
|
||||
continue;
|
||||
}
|
||||
if (fresh) {
|
||||
if (await announceHead(client, repo, pull)) pace.miss(`${key} at ${String(pull.head_sha).slice(0, 8)}`);
|
||||
} else heads[key] = String(pull.head_sha);
|
||||
}
|
||||
for (const repo of await client.listAllRepos()) {
|
||||
// Every open pull request, paged (novox/hq issue 579).
|
||||
const open = await client.listOpenPullsSince(repo.owner, repo.name, "");
|
||||
await announceNewHeads(repo.full_name, open, heads, primedPulls, dayAgo, {
|
||||
alreadyChecked: (pull) => alreadyChecked(client, repo, pull),
|
||||
announceHead: (pull) => announceHead(client, repo, pull),
|
||||
missed: (what) => pace.miss(what),
|
||||
});
|
||||
if (await trunksMoved(client, repo.owner, repo.name)) changedTrunks = true;
|
||||
}
|
||||
if (changedTrunks) keepTrunks();
|
||||
@@ -494,34 +485,43 @@ const checks = new CheckStatuses({
|
||||
/** How fast the polls look: 30 s until the forge's events are heard, five minutes while they are; and what
|
||||
* gitea_events says of them (webhook.ts). */
|
||||
const pace = new Pace();
|
||||
/** Asks the pass over every repository again, shortly and once (set where the pass is made): an event that could not be
|
||||
* acted on is not lost until the next start. */
|
||||
let catchUp: () => void = () => {};
|
||||
if (!gitea) pace.unheard("the forge is not watched: no way to a token", false);
|
||||
|
||||
// The states this module keeps and which others may read, said on the bus before it serves (novox/hq ADR 0495 clause 2).
|
||||
// Said with `emit`, not the SDK's `keeps`, which the toolchain's SDK does not have yet: switch to `keeps` once that SDK is 0.1.15 or later (issue 1852).
|
||||
await emit("states", statesAnnouncement(process.env.MESH_MODULE || "gitea", process.env.MESH_NODE));
|
||||
|
||||
// **The mesh's accounts read every repository** (readall.ts; the operator, 2026-10-26): the tools', the build modules'
|
||||
// and the machines' agents' (meshAccounts, tools-account.ts), granted with the site
|
||||
// administrator's login, which the module holds for setting the forge up and uses here for that grant alone. Made on
|
||||
// first use, so a module whose admin login is missing still serves the rest and the tools say why.
|
||||
let readAllOnce: ReadAll | null = null;
|
||||
const readAll = (): ReadAll => (readAllOnce ??= new ReadAll(GiteaAdmin.fromEnv(), meshAccounts(process.env.MESH_GITEA_TOOLS_USER || TOOLS_ACCOUNT)));
|
||||
|
||||
// **The merging account writes every repository that builds a registered module** (writes.ts; the operator,
|
||||
// 2026-10-26; issue 1945): which those are is the indexer's state, watched below. Made on first use, as readAll.
|
||||
let writesOnce: DeliveredWrites | null = null;
|
||||
const writes = (): DeliveredWrites => (writesOnce ??= new DeliveredWrites(GiteaAdmin.fromEnv(),
|
||||
process.env.MESH_GITEA_TOOLS_USER || TOOLS_ACCOUNT, TOOLS_ORG, (l) => console.log(`[gitea] ${l}`), (type, body) => emit(type, body)));
|
||||
|
||||
// gitea_events is served whether or not the forge is watched: it says so (novox/hq issue 729).
|
||||
registerModuleTools("gitea", () => [eventsTool(pace), ...(gitea ? [commitStatusTool({
|
||||
registerModuleTools("gitea", () => [eventsTool(pace), ...readAllTools(readAll), ...writeTools(writes), ...(gitea ? [commitStatusTool({
|
||||
setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status),
|
||||
gates,
|
||||
}), mergeTool(gitea, { gates })] : [])]);
|
||||
|
||||
/** What the forge's event asks of the announcer at once (issue 729): a new head is announced, and a merge. */
|
||||
async function actOn(client: GiteaClient, event: ForgeEvent): Promise<void> {
|
||||
const b = event.body as { owner?: string; repo?: string; number?: number };
|
||||
if (!b.owner || !b.repo || !b.number) return;
|
||||
if (event.name === "forge.pull-request.closed") {
|
||||
// Closed unmerged is a sibling's move; a merge is weighed with its announcement below (ADR 0382 §5).
|
||||
if (!(event.body as { merged?: boolean }).merged) await siblingMoves(client, `${b.owner}/${b.repo}`, b.number);
|
||||
return;
|
||||
}
|
||||
const head = ["forge.pull-request.opened", "forge.pull-request.synchronized", "forge.pull-request.reopened"].includes(event.name);
|
||||
const merged = event.name === "forge.pull-request.merged";
|
||||
if (!head && !merged) return;
|
||||
const [repo, pull] = await Promise.all([client.getRepo(b.owner, b.repo), client.getPullRequest(b.owner, b.repo, b.number)]);
|
||||
if (head && pull.state === "open") await announceHead(client, repo, pull);
|
||||
if (merged) await announceMerge(client, repo, pull);
|
||||
}
|
||||
/** What the forge's event asks of the announcer at once (issue 729, 1422): see watch.ts. */
|
||||
const actOnEvent = (client: GiteaClient, event: ForgeEvent): Promise<void> => actOn<RepoOf, PullOf>(event, {
|
||||
getRepo: (o, r) => client.getRepo(o, r),
|
||||
getPull: (o, r, n) => client.getPullRequest(o, r, n) as Promise<PullOf & { state?: string; merged?: boolean }>,
|
||||
announceHead: (repo, pull) => announceHead(client, repo, pull as OpenOf),
|
||||
announceMerge: (repo, pull) => announceMerge(client, repo, pull),
|
||||
announceClosed: (repo, pull) => announceClosed(client, repo, pull),
|
||||
siblingMoves: (full, n) => siblingMoves(client, full, n),
|
||||
});
|
||||
|
||||
/**
|
||||
* **The receiver, and the forge's one system webhook pointing at it** (issue 729). The secret is minted and kept beside
|
||||
@@ -543,7 +543,17 @@ async function listenForForge(client: GiteaClient): Promise<boolean> {
|
||||
const server = receiver({ secret, port, heard: (kind, delivery) => pace.heard(kind, delivery),
|
||||
refused: (reason, from) => pace.refused(reason, from),
|
||||
publish: (event) => emit(event.name, event.body),
|
||||
act: (event) => actOn(client, event) });
|
||||
act: async (event) => {
|
||||
// A repository made, or one not yet seen readable, is granted read before anything acts on it (readall.ts).
|
||||
await Promise.resolve().then(() => readAll().onEvent(event)).catch((err) =>
|
||||
console.error(`[gitea] whether the mesh's accounts read the repository of ${event.name} could not be settled — ${err instanceof Error ? err.message : err}`));
|
||||
try {
|
||||
await actOnEvent(client, event);
|
||||
} catch (err) {
|
||||
catchUp();
|
||||
throw err;
|
||||
}
|
||||
} });
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(port, host, () => resolve());
|
||||
@@ -589,14 +599,56 @@ if (gitea) {
|
||||
};
|
||||
// **The forge's events first, the polls as the safety net** (novox/hq issue 729): while verified deliveries arrive the
|
||||
// polls slow to every five minutes and announce only what no event said (Pace); otherwise they keep 30 s.
|
||||
await listenForForge(client);
|
||||
// **Not awaited** (novox/hq issue 1726): the receiver's set-up asks the forge's admin API, and the runtime kills a bundle
|
||||
// whose import has not ended in its 10 s handshake and never starts it again; a forge slow to answer while every bundle
|
||||
// of the node starts left gitea_commit_status, gitea_merge_pull_request and gitea_events served nowhere. The pass below
|
||||
// runs again once it is listening, so nothing said before is missed; listenForForge says its own failure.
|
||||
const listenOk = listenForForge(client);
|
||||
// The start's one pass over every repository (readall.ts), not awaited for the same reason. A forge not answering yet
|
||||
// is asked again, a minute apart, until one pass has run; after that only the forge's events grant.
|
||||
// An account of the list the forge does not hold yet (the forge-account provider makes nox-builder and nox-agent when
|
||||
// their first consumer asks, and the forge sends no event for a user made) is looked for again ten minutes apart
|
||||
// until every account was granted once; the line of each pass names it.
|
||||
const readAllAtStart = (): void => void Promise.resolve().then(() => readAll().atStart()).then((r) => {
|
||||
if (r.absent.length) setTimeout(readAllAtStart, 600_000);
|
||||
}, (err) => {
|
||||
console.error(`[gitea] whether the mesh's accounts read every repository could not be settled; asked again in a minute — ${err instanceof Error ? err.message : err}`);
|
||||
setTimeout(readAllAtStart, 60_000);
|
||||
});
|
||||
try {
|
||||
readAll();
|
||||
readAllAtStart();
|
||||
} catch (err) {
|
||||
console.error(`[gitea] the mesh's accounts are not given read on every repository: no site administrator's login — ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
// The indexer's modules, watched: the watch hands every key's latest value at its start and then each change, so a
|
||||
// repository that builds a registered module is made writable at the start and when its first module is registered
|
||||
// (writes.ts). A read not granted yet is said and asked again until it holds.
|
||||
try {
|
||||
const w = writes();
|
||||
void bindUntilHeld(INDEXER_MODULES, () => state<unknown>(INDEXER_MODULES).watch((change) => w.take(change as ModulesChange)));
|
||||
} catch (err) {
|
||||
console.error(`[gitea] the merging account is not given write on the delivered repositories: no site administrator's login — ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
// Main takes no push and only the tools' account merges, in every repository, kept and not only when a token is
|
||||
// minted: at the start and every ten minutes after, so a repository made later gets it too (issue 948, ADR 0398 §2).
|
||||
// Only while the setting keep-main-rules is true (mainrules.ts); false, the default, leaves every repository's rule as set.
|
||||
tick(mainRulesStep(keepMainRules(), () => GiteaAdmin.fromEnv().ensureMainMerges("novox"), (line) => console.log(line)), 600_000);
|
||||
tick(() => pollRepos(client), 60_000);
|
||||
tick(() => pollMerged(client), () => pace.every());
|
||||
tick(() => pollPulls(client), () => pace.every());
|
||||
// **No timer asks the forge for pull requests or merges** (issue 1422): the forge's events announce them. One pass over
|
||||
// every repository runs at the start and again once the receiver listens, so what happened while the module was away or
|
||||
// not yet listening is announced; passes run one at a time, a failed one is asked again a minute later.
|
||||
let passes: Promise<void> = Promise.resolve();
|
||||
const pass = (): Promise<void> => (passes = passes.catch(() => {}).then(() =>
|
||||
startPass(async () => { await pollMerged(client); await passPulls(client); }, { retryMs: 60_000 })));
|
||||
void pass();
|
||||
let catchUpAsked = false;
|
||||
catchUp = () => {
|
||||
if (catchUpAsked) return;
|
||||
catchUpAsked = true;
|
||||
setTimeout(() => { catchUpAsked = false; void pass(); }, 30_000);
|
||||
};
|
||||
void listenOk.then((ok) => (ok ? pass() : undefined));
|
||||
// Bound again until they hold, and not awaited (bind.ts, issue 1793): the runtime kills a bundle that has not answered its
|
||||
// handshake in 10 s, so the import must end while a refused bind is still asked again.
|
||||
bindInBackground([
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package forgeaccount
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestForgeUserOfAMachineAccountIsTheOneAgentUser(t *testing.T) {
|
||||
if got := ForgeUser("g14", "machine"); got != "nox-agent" {
|
||||
@@ -15,6 +19,18 @@ func TestForgeUserOfAMachineAccountIsTheOneAgentUser(t *testing.T) {
|
||||
if got := NodeAccount("g14"); got != "agent-g14" {
|
||||
t.Fatalf("the token tool's per-node account = %q", got)
|
||||
}
|
||||
// The accounts that read every repository (readall.ts, meshAccounts in tools-account.ts) name these two users: the
|
||||
// list is held to the Go constants here, so the two cannot drift apart (the operator, 2026-10-26).
|
||||
ts, err := os.ReadFile("../../tools-account.ts")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, want := range map[string]string{"BUILDER_ACCOUNT": BuilderUser, "AGENT_ACCOUNT": AgentUser} {
|
||||
m := regexp.MustCompile(`export const ` + name + ` = "([^"]+)";`).FindSubmatch(ts)
|
||||
if m == nil || string(m[1]) != want {
|
||||
t.Errorf("tools-account.ts %s = %q, want %q (internal/forgeaccount)", name, m, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveKeysTitledTakesOnlyTheMeshKeysOfThatTitle(t *testing.T) {
|
||||
|
||||
@@ -15,6 +15,19 @@ export function keepMainRules(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
/** The setting check-gates-merge (the operator, 2026-10-25 22:56: "run the check locally only, but not as a gate, it loses
|
||||
* us too much time"): whether the merge tool judges mesh/check before it merges. Absent, unreadable or anything but false
|
||||
* is true; false lets a head the operator's Approve names merge with mesh/check pending, failed, missing or judged on an
|
||||
* older trunk (mesh-delivery-gate's setting of the same name stops waiting for it before the Approve). */
|
||||
export function checkGatesMerge(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
const file = env.MESH_GITEA_CONFIG_FILE;
|
||||
if (!file) return true;
|
||||
try {
|
||||
const v = (JSON.parse(readFileSync(file, "utf8")) as Record<string, unknown>)["check-gates-merge"];
|
||||
return !(v === false || v === "false");
|
||||
} catch { return true; }
|
||||
}
|
||||
|
||||
/** The 10-minute step: puts the rules right only when kept; else says once that they are left and writes nothing. */
|
||||
export function mainRulesStep(keep: boolean, put: () => Promise<string[]>, say: (line: string) => void): () => Promise<void> {
|
||||
let said = false;
|
||||
|
||||
@@ -19,6 +19,7 @@ import { mergeableUnder, newestByContext } from "./checks.js";
|
||||
import { MERGE_METHODS, Refused, checkName } from "./guard.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 { checkGatesMerge } from "./mainrules.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. */
|
||||
@@ -31,11 +32,13 @@ export interface MergeDeps {
|
||||
emit?: (type: string, body: unknown) => Promise<void>;
|
||||
stateDir?: string;
|
||||
now?: () => Date;
|
||||
/** Whether mesh/check gates the merge: the setting check-gates-merge, read at each merge (mainrules.ts). */
|
||||
checkGates?: () => boolean;
|
||||
}
|
||||
|
||||
export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition {
|
||||
const deps = { ...given, emit: given.emit ?? announce, stateDir: given.stateDir ?? process.env.MESH_GITEA_STATE_DIR,
|
||||
now: given.now ?? (() => new Date()) };
|
||||
now: given.now ?? (() => new Date()), checkGates: given.checkGates ?? (() => checkGatesMerge()) };
|
||||
return {
|
||||
name: "gitea_merge_pull_request",
|
||||
description: "Merge a pull request at the head you name — the commit you reviewed — when that is still its head, " +
|
||||
@@ -126,7 +129,10 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
|
||||
}
|
||||
const kept = maintained(required);
|
||||
const byCheck = required.includes(CHECK_STATUS) || kept.length === 0;
|
||||
if (byCheck) {
|
||||
// **check-gates-merge false** (the operator, 2026-10-25): mesh/check is information, not a gate. A head the operator's
|
||||
// Approve names merges whatever its mesh/check says, pending, failed, missing or judged on an older trunk; without
|
||||
// approved_heads the check still judges.
|
||||
if (byCheck && (deps.checkGates() || !approved)) {
|
||||
// **mesh/check is the checker's** (ADR 0447 §4): it is judged here against the trunk's head, and refused when it
|
||||
// is not current; nothing is written or asked — the checker checks a head again when its trunk moves.
|
||||
const check = checks[CHECK_STATUS];
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
],
|
||||
"emits": [
|
||||
"repo.created",
|
||||
"repo.writable",
|
||||
"issue.opened",
|
||||
"pull.merged",
|
||||
"pull.updated",
|
||||
@@ -181,6 +182,12 @@
|
||||
"trusted": true,
|
||||
"default": {}
|
||||
},
|
||||
"check-gates-merge": {
|
||||
"type": "boolean",
|
||||
"description": "Whether gitea_merge_pull_request judges mesh/check before it merges. True (the default): a head merges only on a passing mesh/check judged against the trunk's head (ADR 0447, ADR 0534 §4). False (the operator, 2026-10-25 22:56: \"run the check locally only, but not as a gate, it loses us too much time\"): a head the operator's Approve names in approved_heads merges whatever its mesh/check says (pending, failed, missing or judged on an older trunk); a head that moved, a branch no rule guards and a merge git refuses are still refused. Set together with mesh-delivery-gate's check-gates-merge.",
|
||||
"trusted": true,
|
||||
"default": true
|
||||
},
|
||||
"main-mergers": {
|
||||
"type": "list",
|
||||
"description": "The forge logins that may merge into main beside the tools' account, on every repository of the organisation (ADR 0534 §2): the operator's own, so the operator can always merge by hand at the forge. The tools' account is always on main's merge whitelist; no agent's account belongs here.",
|
||||
@@ -316,7 +323,8 @@
|
||||
"takes-config": [
|
||||
"url",
|
||||
"keep-main-rules",
|
||||
"main-mergers"
|
||||
"main-mergers",
|
||||
"check-gates-merge"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -537,6 +545,7 @@
|
||||
"MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret",
|
||||
"MESH_GITEA_ORIGINS_FILE": "${dir:mesh-state}/origins",
|
||||
"MESH_GITEA_SSH_PORT": "222",
|
||||
"MESH_GITEA_HOST_KEY_FILE": "${dir:data}/ssh/ssh_host_ed25519_key.pub",
|
||||
"MESH_FORGE_HOST_FILE": "/etc/gitea/forge-host"
|
||||
}
|
||||
}
|
||||
@@ -559,6 +568,8 @@
|
||||
"mesh-build-go.keys",
|
||||
"mesh-build-node.keys",
|
||||
"mesh-build-image.keys",
|
||||
"mesh-delivery-checker.owed"
|
||||
"mesh-delivery-checker.owed",
|
||||
"mesh-delivery-indexer.modules",
|
||||
"mesh-delivery-gate.source"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
// Every field of a branch protection rule, set through one tool (gitea_branch_protection_set_full; the operator,
|
||||
// 2026-10-28: "just make a generic tool so you can setup any configuration"). Pure, so it is tested without a forge:
|
||||
// which fields the forge's rule takes and of what kind, the body a call sends, and whether the rule the forge then
|
||||
// applies to the branch is one the controller registers a module from (judgedRule, mesh-control
|
||||
// cmd/mesh-controller/build_source.go).
|
||||
|
||||
import { TOOLS_ACCOUNT } from "./tools-account.js";
|
||||
import { MAIN_BRANCH, type BranchProtection } from "./protection.js";
|
||||
import { ruleCovers } from "./guard.js";
|
||||
|
||||
type Kind = "boolean" | "logins" | "strings" | "number" | "string";
|
||||
/** unread: the forge never answers it, so it is not read back; duration: a Go duration, compared as one ("8h" is the
|
||||
* forge's "8h0m0s"). */
|
||||
type Field = { kind: Kind; says: string; parent?: string; unread?: boolean; duration?: boolean };
|
||||
|
||||
/** Every field gitea's branch protection API takes on an edit at 1.27.3 (EditBranchProtectionOption, 32 fields), by its
|
||||
* own name, and its kind; rule_name is the branch argument, never a field. A field with a parent is read by the forge
|
||||
* only when the same call sets that parent true, and setting the parent false clears it (gitea's
|
||||
* routers/api/v1/repo/branch.go): such a field is refused without its parent true in the call. */
|
||||
export const PROTECTION_FIELDS: Record<string, Field> = {
|
||||
enable_push: { kind: "boolean", says: "whether anyone who writes may push to the branch directly (false: no direct push; also clears enable_push_whitelist and push_whitelist_deploy_keys)" },
|
||||
enable_push_whitelist: { kind: "boolean", says: "only the push whitelist may push; needs enable_push: true in the same call", parent: "enable_push" },
|
||||
push_whitelist_usernames: { kind: "logins", says: "the logins that may push directly" },
|
||||
push_whitelist_teams: { kind: "strings", says: "the teams that may push directly" },
|
||||
push_whitelist_deploy_keys: { kind: "boolean", says: "whether deploy keys may push; needs enable_push: true in the same call", parent: "enable_push" },
|
||||
enable_force_push: { kind: "boolean", says: "whether a force push is taken (false also clears the force-push allowlist flags)" },
|
||||
enable_force_push_allowlist: { kind: "boolean", says: "only the force-push allowlist may force-push; needs enable_force_push: true in the same call", parent: "enable_force_push" },
|
||||
force_push_allowlist_usernames: { kind: "logins", says: "the logins that may force-push" },
|
||||
force_push_allowlist_teams: { kind: "strings", says: "the teams that may force-push" },
|
||||
force_push_allowlist_deploy_keys: { kind: "boolean", says: "whether deploy keys may force-push; needs enable_force_push: true in the same call", parent: "enable_force_push" },
|
||||
enable_merge_whitelist: { kind: "boolean", says: "whether only the merge whitelist may merge" },
|
||||
merge_whitelist_usernames: { kind: "logins", says: "the logins that may merge (the controller's trunk rule wants mesh-tools, no agent-<node>)" },
|
||||
merge_whitelist_teams: { kind: "strings", says: "the teams that may merge" },
|
||||
enable_status_check: { kind: "boolean", says: "whether the statuses in status_check_contexts must pass to merge" },
|
||||
status_check_contexts: { kind: "strings", says: "the required commit statuses, e.g. mesh/check" },
|
||||
required_approvals: { kind: "number", says: "how many approving reviews a merge needs" },
|
||||
enable_approvals_whitelist: { kind: "boolean", says: "whether only the approvals whitelist's reviews count" },
|
||||
approvals_whitelist_username: { kind: "logins", says: "the logins whose approvals count" },
|
||||
approvals_whitelist_teams: { kind: "strings", says: "the teams whose approvals count" },
|
||||
block_on_rejected_reviews: { kind: "boolean", says: "a review asking changes blocks the merge" },
|
||||
block_on_official_review_requests: { kind: "boolean", says: "an open official review request blocks the merge" },
|
||||
block_on_outdated_branch: { kind: "boolean", says: "a head behind the branch blocks the merge" },
|
||||
dismiss_stale_approvals: { kind: "boolean", says: "a new commit dismisses earlier approvals" },
|
||||
ignore_stale_approvals: { kind: "boolean", says: "approvals of an earlier commit do not count" },
|
||||
require_signed_commits: { kind: "boolean", says: "every commit must be signed" },
|
||||
protected_file_patterns: { kind: "string", says: "files no push may change, ';'-separated globs" },
|
||||
unprotected_file_patterns: { kind: "string", says: "files a push may change even without push rights, ';'-separated globs" },
|
||||
block_admin_merge_override: { kind: "boolean", says: "true: an administrator cannot merge past the rule (the controller's trunk rule wants true)" },
|
||||
enable_bypass_allowlist: { kind: "boolean", says: "whether the bypass allowlist may merge past the rule's checks" },
|
||||
bypass_allowlist_usernames: { kind: "logins", says: "the logins that may merge past the rule's checks" },
|
||||
bypass_allowlist_teams: { kind: "strings", says: "the teams that may merge past the rule's checks" },
|
||||
priority: { kind: "number", says: "the rule's order among glob rules" },
|
||||
};
|
||||
|
||||
/** The repository settings gitea_repo_settings_set_full sets (gitea's EditRepoOption at 1.27.3), by their own names. Left
|
||||
* out: name (a rename moves every module's source address), the internal tracker, external tracker and external wiki
|
||||
* objects, and mirror_username, mirror_password and mirror_token, which are credentials. The forge ignores a
|
||||
* default_branch that does not exist; the read-back below says so. */
|
||||
export const REPO_FIELDS: Record<string, Field> = Object.fromEntries(([
|
||||
["description", "string"], ["website", "string"], ["private", "boolean"], ["template", "boolean"],
|
||||
["default_branch", "string"], ["has_code", "boolean"], ["has_issues", "boolean"], ["has_wiki", "boolean"],
|
||||
["has_pull_requests", "boolean"], ["has_projects", "boolean"], ["projects_mode", "string"], ["has_releases", "boolean"],
|
||||
["has_packages", "boolean"], ["has_actions", "boolean"], ["ignore_whitespace_conflicts", "boolean"],
|
||||
["allow_merge_commits", "boolean"], ["allow_rebase", "boolean"], ["allow_rebase_explicit", "boolean"],
|
||||
["allow_squash_merge", "boolean"], ["allow_fast_forward_only_merge", "boolean"], ["allow_manual_merge", "boolean"],
|
||||
["autodetect_manual_merge", "boolean"], ["allow_rebase_update", "boolean"], ["allow_merge_update", "boolean"],
|
||||
["default_update_style", "string"], ["default_delete_branch_after_merge", "boolean"], ["default_merge_style", "string"],
|
||||
["default_allow_maintainer_edit", "boolean"], ["archived", "boolean"], ["mirror_interval", "string"], ["enable_prune", "boolean"],
|
||||
] as [string, Kind][]).map(([name, kind]) => [name, {
|
||||
kind, says: `the repository's ${name.replace(/_/g, " ")}`,
|
||||
...(name === "projects_mode" ? { parent: "has_projects", says: "the repository's projects mode; needs has_projects: true in the same call" } : {}),
|
||||
...(name === "enable_prune" ? { unread: true, says: "a mirror's prune setting; the forge never answers it, so it is not read back" } : {}),
|
||||
...(name === "mirror_interval" ? { duration: true, says: "a mirror's interval, a Go duration such as 8h (the forge answers 8h0m0s)" } : {}),
|
||||
}]));
|
||||
|
||||
/** A Go duration ("8h", "1h30m", "8h0m0s", "90s") in milliseconds; NaN when it is none. */
|
||||
export function durationMs(v: unknown): number {
|
||||
if (typeof v !== "string" || !/^(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))+$/.test(v)) return v === "0" ? 0 : NaN;
|
||||
const unit: Record<string, number> = { ns: 1e-6, us: 1e-3, "µs": 1e-3, ms: 1, s: 1e3, m: 6e4, h: 3.6e6 };
|
||||
let ms = 0;
|
||||
for (const m of v.matchAll(/(\d+(?:\.\d+)?)(ns|us|µs|ms|s|m|h)/g)) ms += Number(m[1]) * unit[m[2]];
|
||||
return ms;
|
||||
}
|
||||
|
||||
/** Fields the forge was sent but does not hold after the write, each with what was sent and what it holds: a write it
|
||||
* answered 200 and did not apply (gitea drops a field whose parent is not set, or a branch that does not exist). */
|
||||
export function unapplied(sent: Record<string, unknown>, held: Record<string, unknown>, fields: Record<string, Field> = PROTECTION_FIELDS): string[] {
|
||||
const norm = (k: string, v: unknown) => fields[k]?.duration ? String(durationMs(v))
|
||||
: Array.isArray(v) ? JSON.stringify([...v].map(String).sort()) : JSON.stringify(v ?? null);
|
||||
return Object.keys(sent).filter((k) => !fields[k]?.unread && norm(k, sent[k]) !== norm(k, held[k]))
|
||||
.map((k) => `${k} (sent ${JSON.stringify(sent[k])}, the forge holds ${JSON.stringify(held[k] ?? null)})`);
|
||||
}
|
||||
|
||||
/** Fields that changed between two reads but were not sent: what the forge changed beside the call (enable_push: false
|
||||
* clears the push whitelist flags). */
|
||||
export function alsoChanged(before: Record<string, unknown> | null, after: Record<string, unknown>, sent: Record<string, unknown>): string[] {
|
||||
if (!before) return [];
|
||||
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
|
||||
return [...keys].filter((k) => !(k in sent) && !["updated_at", "created_at"].includes(k) &&
|
||||
JSON.stringify(before[k] ?? null) !== JSON.stringify(after[k] ?? null));
|
||||
}
|
||||
|
||||
/** A forge login. */
|
||||
const LOGIN = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,39}$/;
|
||||
|
||||
/** The forge body for the fields given: each checked against its kind, a field not given left out (the forge keeps it on
|
||||
* an edit), an unknown field refused before anything is sent. Throws on the first wrong field. */
|
||||
export function fullProtectionBody(args: Record<string, unknown>, given: string[],
|
||||
fields: Record<string, Field> = PROTECTION_FIELDS, what = "a branch protection rule"): Record<string, unknown> {
|
||||
const body: Record<string, unknown> = {};
|
||||
for (const name of given) {
|
||||
const field = fields[name];
|
||||
if (!field) throw new Error(`${name} is no field of ${what}; the fields are ${Object.keys(fields).join(", ")}`);
|
||||
// The parent false clears the field, so only a field false goes beside a parent false.
|
||||
if (field.parent && args[field.parent] !== true && !(args[field.parent] === false && args[name] === false)) {
|
||||
throw new Error(`${name} is read by the forge only when the same call sets ${field.parent}: true (gitea ignores it otherwise). Nothing was sent`);
|
||||
}
|
||||
const v = args[name];
|
||||
switch (field.kind) {
|
||||
case "boolean":
|
||||
if (typeof v !== "boolean") throw new Error(`${name} is true or false`);
|
||||
body[name] = v;
|
||||
break;
|
||||
case "number":
|
||||
if (typeof v !== "number" || !Number.isInteger(v) || v < 0) throw new Error(`${name} is a whole number, 0 or more`);
|
||||
body[name] = v;
|
||||
break;
|
||||
case "string":
|
||||
if (typeof v !== "string") throw new Error(`${name} is a string`);
|
||||
if (field.duration && Number.isNaN(durationMs(v))) throw new Error(`${name} is a duration, such as 8h or 1h30m`);
|
||||
body[name] = v;
|
||||
break;
|
||||
case "strings":
|
||||
case "logins": {
|
||||
if (!Array.isArray(v) || v.some((s) => typeof s !== "string" || s.trim() === "")) throw new Error(`${name} is a list of names`);
|
||||
const names = [...new Set((v as string[]).map((s) => s.trim()))];
|
||||
if (field.kind === "logins") {
|
||||
const bad = names.find((s) => !LOGIN.test(s) || s.includes(".."));
|
||||
if (bad !== undefined) throw new Error(`${name}: ${JSON.stringify(bad)} is no forge login`);
|
||||
}
|
||||
body[name] = names;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/** The fields of a call that are protection fields: every argument but owner, repo, branch and why. */
|
||||
export function givenFields(args: Record<string, unknown>): string[] {
|
||||
return Object.keys(args).filter((k) => !["owner", "repo", "branch", "why"].includes(k) && args[k] !== undefined);
|
||||
}
|
||||
|
||||
/** A rule as the controller reads it from gitea_branch_protection_get (protectionRule in build_source.go). */
|
||||
interface ControllerRule { rule: string; push: boolean; admin_may_override: boolean; merge_whitelist: string[] | null }
|
||||
|
||||
function asControllerRule(p: BranchProtection): ControllerRule {
|
||||
return {
|
||||
rule: String(p.rule_name ?? p.branch_name ?? ""),
|
||||
push: p.enable_push ?? false,
|
||||
admin_may_override: !(p.block_admin_merge_override ?? false),
|
||||
merge_whitelist: p.enable_merge_whitelist ? (p.merge_whitelist_usernames ?? []) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** ruleCovers of build_source.go, as the controller reads a rule's glob: `**` any, `*` within a segment, `?` one. */
|
||||
function controllerCovers(rule: string, branch: string): boolean {
|
||||
if (rule === branch) return true;
|
||||
if (!/[*?[]/.test(rule)) return false;
|
||||
let re = "^";
|
||||
for (let i = 0; i < rule.length; i++) {
|
||||
const c = rule[i];
|
||||
if (c === "*" && rule[i + 1] === "*") { re += ".*"; i++; }
|
||||
else if (c === "*") re += "[^/]*";
|
||||
else if (c === "?") re += "[^/]";
|
||||
else re += c.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
return new RegExp(re + "$").test(branch);
|
||||
}
|
||||
|
||||
/** Whether the controller registers a module from this branch, judged as judgedRule in mesh-control
|
||||
* cmd/mesh-controller/build_source.go does (ADR 0534 §6): the rule named for the branch, else the first glob rule that
|
||||
* covers it; it takes no push, its merge whitelist names mesh-tools and no agent-<node>, and no administrator merges
|
||||
* past it. A mirror, not the judge: the controller's own verdict is the one that counts. */
|
||||
export function trunkRule(rules: BranchProtection[], branch: string, trunks: string[] = [MAIN_BRANCH]): { holds: boolean; why: string; is_trunk: boolean } {
|
||||
const judged = judgedTrunkRule(rules, branch);
|
||||
return { ...judged, is_trunk: trunks.includes(branch) };
|
||||
}
|
||||
|
||||
function judgedTrunkRule(rules: BranchProtection[], branch: string): { holds: boolean; why: string } {
|
||||
const all = rules.map(asControllerRule);
|
||||
const r = all.find((x) => x.rule === branch) ?? all.find((x) => controllerCovers(x.rule, branch));
|
||||
const no = (why: string) => ({ holds: false, why });
|
||||
if (!r) return no(`no protection rule covers ${branch}`);
|
||||
if (r.push) return no(`the rule ${r.rule} lets a person push to ${branch} directly`);
|
||||
if (!r.merge_whitelist || r.merge_whitelist.length === 0) {
|
||||
return no(`the rule ${r.rule} keeps no merge whitelist naming ${TOOLS_ACCOUNT} alone for ${branch}`);
|
||||
}
|
||||
if (!r.merge_whitelist.includes(TOOLS_ACCOUNT)) {
|
||||
return no(`the rule ${r.rule} lets ${r.merge_whitelist.join(", ")} merge into ${branch}, and not ${TOOLS_ACCOUNT}`);
|
||||
}
|
||||
if (r.merge_whitelist.some((l) => l.startsWith("agent-"))) {
|
||||
return no(`the rule ${r.rule} lets an agent's account merge into ${branch}: ${r.merge_whitelist.join(", ")}`);
|
||||
}
|
||||
if (r.admin_may_override) return no(`the rule ${r.rule} lets an administrator merge into ${branch} past it`);
|
||||
return { holds: true, why: `the rule ${r.rule} takes no push, only ${r.merge_whitelist.join(", ")} merge, and no administrator merges past it` };
|
||||
}
|
||||
|
||||
/** The gate's source as it is read: one key per trunk, each with the heads of the repositories installed from it. */
|
||||
export type SourceRead = () => Promise<Record<string, unknown>>;
|
||||
|
||||
/** The state the trunks are read from: mesh-delivery-gate's source, whose key `main` is today's one trunk of each
|
||||
* repository it names (each repository's trunk head the checker judges on); a later trunk is a key of its own. */
|
||||
export const TRUNK_SOURCE = "mesh-delivery-gate.source";
|
||||
|
||||
/** A repository's trunks, the branches the mesh installs from (the operator, 2026-10-28 16:5x): each key of the gate's
|
||||
* source whose heads name the repository. "main" only when the source names none of its trunks or cannot be read, and
|
||||
* `from` says which. The one place trunks are read: several trunks later change only this. */
|
||||
export async function trunksOf(owner: string, repo: string, read: SourceRead): Promise<{ names: string[]; from: string }> {
|
||||
const full = `${owner}/${repo}`;
|
||||
let src: Record<string, unknown>;
|
||||
try {
|
||||
src = await read();
|
||||
} catch (err) {
|
||||
return { names: [MAIN_BRANCH], from: `${TRUNK_SOURCE} could not be read (${err instanceof Error ? err.message : String(err)}): ${MAIN_BRANCH} assumed` };
|
||||
}
|
||||
const names = Object.keys(src).filter((k) => {
|
||||
const heads = (src[k] as { heads?: Record<string, unknown> } | null)?.heads;
|
||||
return !!heads && typeof heads === "object" && full in heads;
|
||||
}).sort();
|
||||
if (names.length) return { names, from: TRUNK_SOURCE };
|
||||
return { names: [MAIN_BRANCH], from: `${TRUNK_SOURCE} names no trunk of ${full}: ${MAIN_BRANCH} assumed` };
|
||||
}
|
||||
|
||||
/** The gate's source read from the bus, every key and its value (the tools bundle's state channel). */
|
||||
export function sourceFromBus(get: (name: string) => { keys(): Promise<string[]>; get(k: string): Promise<{ value: unknown } | null> }): SourceRead {
|
||||
return async () => {
|
||||
const st = get(TRUNK_SOURCE);
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const k of await st.keys()) out[k] = (await st.get(k))?.value ?? null;
|
||||
return out;
|
||||
};
|
||||
}
|
||||
|
||||
/** Why a call is the operator's, or null: it lets a force-push onto a rule covering a trunk (the operator, 2026-10-28
|
||||
* 16:4x: a call waits for their Approve only if it removes branch protection or allows a force-push to a trunk). No
|
||||
* tool of this module can ask for that Approve yet, so such a call is refused with nothing sent. */
|
||||
export function forcePushToTrunk(rule: string, body: Record<string, unknown>, trunks: string[]): string | null {
|
||||
const covered = trunks.filter((t) => ruleCovers(rule, t));
|
||||
if (!covered.length) return null;
|
||||
const opens = body.enable_force_push === true || body.force_push_allowlist_deploy_keys === true ||
|
||||
(["force_push_allowlist_usernames", "force_push_allowlist_teams"] as const).some((k) => Array.isArray(body[k]) && (body[k] as unknown[]).length > 0);
|
||||
if (!opens) return null;
|
||||
return `the rule ${rule} covers ${covered.join(", ")}, and this call lets a force-push onto it: a force-push to a trunk is ` +
|
||||
`the operator's Approve (the operator, 2026-10-28), and no tool of this module can ask it yet, so it is the operator's ` +
|
||||
`at the forge. Nothing was sent`;
|
||||
}
|
||||
@@ -141,13 +141,19 @@ export function planText(plan: ChangePlan): string {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** The heads already announced, keyed `owner/repo#number`, 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>;
|
||||
|
||||
/** The key a pull request's announced head is kept under: `owner/repo#number@base`. The base is in it because a pull
|
||||
* request retargeted keeps its head, and a head judged into one base is not judged into another (issue 1422, de-spiegel #6). */
|
||||
export function headKey(full: string, number: number, base?: string): string {
|
||||
return `${full}#${number}@${base ?? ""}`;
|
||||
}
|
||||
|
||||
/** Which open pull requests have a head not yet announced. A pull request merged or closed is not
|
||||
* open and is never asked about. */
|
||||
export function headsToAnnounce(full: string, pulls: GiteaPull[], announced: Announced): GiteaPull[] {
|
||||
return pulls.filter((p) => p.state === "open" && !!p.head_sha && announced[`${full}#${p.number}`] !== p.head_sha);
|
||||
return pulls.filter((p) => p.state === "open" && !!p.head_sha && announced[headKey(full, p.number, p.base)] !== p.head_sha);
|
||||
}
|
||||
|
||||
/** A pull request the forge found updated, by its repository: what `searchOpenPulls` answers. */
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
// The mesh's own forge accounts read every repository on the forge (the operator, 2026-10-26: "the mesh's forge account
|
||||
// should get read access to all repositories"; and "why wouldn't the forge account get access to all repo's?").
|
||||
//
|
||||
// **Every account the mesh's parts use, from one list** (MESH_ACCOUNTS, tools-account.ts): the tools' `mesh-tools`, the
|
||||
// build modules' `nox-builder` (their fetch over ssh, ADR 0466 §3) and the machines' `nox-agent` (ssh-client's key,
|
||||
// ADR 0410 §1). The first pass granted the tools' account alone, so the build modules' fetch of jschoubben/car-hunter
|
||||
// still failed on ace and g14 ("nox-builder ... is not authorized to read"). The two Go names are the ones of
|
||||
// internal/forgeaccount, whose test holds the list to them. An account the forge does not hold yet (its consumer never
|
||||
// asked) is said and left out, never a refusal per repository.
|
||||
//
|
||||
// Since issue 469 the tools act as the plain account `mesh-tools`, which writes to the organisation novox's
|
||||
// repositories through its team (tools-account.ts). Every other repository it saw only when public: a private
|
||||
// repository of a user (jschoubben/car-hunter, jschoubben/snake) answered its tools 404 and was missing from every
|
||||
// list, while that user's public ones were there. So the module makes its account a collaborator that reads only on
|
||||
// each repository it cannot read, the least the forge offers: one repository, read, no organisation membership.
|
||||
//
|
||||
// - **at the start**, one pass over every repository of every user and organisation;
|
||||
// - **on the forge's own event** (the system webhook, webhook.ts): `forge.repository.created` and `forge.fork` at
|
||||
// once, and the first event of any repository this process has not yet seen readable, since the forge may not
|
||||
// send a repository's creation for every owner. Nothing polls for it.
|
||||
//
|
||||
// **Not caught: a repository made private later.** The forge sends no event for a change of visibility, and a public
|
||||
// repository is already seen readable by the start's pass, so its events ask nothing. Such a repository is granted at
|
||||
// the module's next start, or now by `gitea_grant_read`; `gitea_unreadable_repos` shows it meanwhile.
|
||||
//
|
||||
// **The grant needs the site administrator's login.** Adding a collaborator asks the repository's administration,
|
||||
// which on another user's repository only the site administrator holds; the module holds that login for setting the
|
||||
// forge up (MESH_GITEA_ADMIN_PASSWORD_FILE). Here it is used through `ReadAllForge` alone: list the repositories, read
|
||||
// the account's permission on one, add the account as a collaborator that reads. Nothing else, and never for the tools'
|
||||
// own calls, which keep their token.
|
||||
//
|
||||
// What the forge refuses (a repository that went meanwhile, an archived one if the forge will not take a collaborator
|
||||
// on it) is said per repository and not retried; the tool `gitea_unreadable_repos` shows what is left, and
|
||||
// `gitea_grant_read` grants again now.
|
||||
|
||||
import type { ForgeRepository } from "./client.js";
|
||||
|
||||
/** What the module asks of the forge for this, with the site administrator's login (GiteaAdmin, client.ts). */
|
||||
export interface ReadAllForge {
|
||||
everyRepository(): Promise<ForgeRepository[]>;
|
||||
repository(owner: string, repo: string): Promise<ForgeRepository | null>;
|
||||
/** Whether the forge holds the account at all. */
|
||||
accountExists(user: string): Promise<boolean>;
|
||||
repositoryPermission(owner: string, repo: string, user: string): Promise<string>;
|
||||
addReadCollaborator(owner: string, repo: string, user: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** A repository an account cannot read, and why. */
|
||||
export interface Unreadable {
|
||||
account: string;
|
||||
repository: string;
|
||||
permission: string;
|
||||
private: boolean;
|
||||
archived: boolean;
|
||||
why: string;
|
||||
}
|
||||
|
||||
export interface Granted {
|
||||
/** Repositories looked at (for each account). */
|
||||
checked: number;
|
||||
/** The accounts looked at: those the forge holds. */
|
||||
accounts: string[];
|
||||
/** Accounts of the list the forge does not hold yet: nothing is granted to them until it does. */
|
||||
absent: string[];
|
||||
/** Made readable now, by a read collaborator: each repository and the account. */
|
||||
granted: { account: string; repository: string }[];
|
||||
/** The forge refused the grant: each repository, the account and the forge's own words. */
|
||||
refused: { account: string; repository: string; why: string }[];
|
||||
}
|
||||
|
||||
const READS = new Set(["read", "write", "admin", "owner"]);
|
||||
|
||||
const said = (err: unknown): string => (err instanceof Error ? err.message : String(err));
|
||||
|
||||
/** Why the account cannot read a repository, from what the forge says of it. */
|
||||
export function whyUnreadable(repo: ForgeRepository, account: string): string {
|
||||
const parts: string[] = [];
|
||||
if (repo.private) parts.push(`a private repository of ${repo.owner}, on which ${account} is no collaborator and in no team that covers it`);
|
||||
else if (repo.owner_visibility !== "public") {
|
||||
parts.push(`a public repository of ${repo.owner}, whose own visibility is ${repo.owner_visibility}: the forge shows it to members only`);
|
||||
} else parts.push(`the forge answers ${account} no read on it`);
|
||||
if (repo.archived) parts.push("archived");
|
||||
return parts.join("; ");
|
||||
}
|
||||
|
||||
export class ReadAll {
|
||||
/** Per account, the repositories this process has seen it read, by full name: an event of a repository every account
|
||||
* was seen reading asks nothing more. */
|
||||
readonly readable = new Map<string, Set<string>>();
|
||||
readonly accounts: readonly string[];
|
||||
|
||||
constructor(private readonly forge: ReadAllForge, accounts: readonly string[] | string,
|
||||
private readonly log: (line: string) => void = (l) => console.log(`[gitea] ${l}`)) {
|
||||
this.accounts = [...new Set(typeof accounts === "string" ? [accounts] : accounts)];
|
||||
if (!this.accounts.length) throw new Error("no forge account to give read: the list is empty");
|
||||
for (const a of this.accounts) this.readable.set(a, new Set());
|
||||
}
|
||||
|
||||
/** The accounts of the list (or of those named) the forge holds, and those it does not hold yet. */
|
||||
private async present(only?: string[]): Promise<{ accounts: string[]; absent: string[] }> {
|
||||
const wanted = only && only.length ? only : [...this.accounts];
|
||||
for (const a of wanted) if (!this.accounts.includes(a)) throw new Error(`"${a}" is not one of the mesh's accounts: ${this.accounts.join(", ")}`);
|
||||
const accounts: string[] = [];
|
||||
const absent: string[] = [];
|
||||
for (const a of wanted) ((await this.forge.accountExists(a)) ? accounts : absent).push(a);
|
||||
return { accounts, absent };
|
||||
}
|
||||
|
||||
/** Every repository each account cannot read, and why, as the forge says it now. */
|
||||
async unreadable(only?: string[]): Promise<{ checked: number; accounts: { account: string; unreadable: number }[]; absent: string[]; unreadable: Unreadable[] }> {
|
||||
const { accounts, absent } = await this.present(only);
|
||||
const repos = await this.forge.everyRepository();
|
||||
const out: Unreadable[] = [];
|
||||
for (const account of accounts) {
|
||||
for (const repo of repos) {
|
||||
try {
|
||||
const u = await this.judge(repo, account);
|
||||
if (u) out.push(u);
|
||||
} catch (err) {
|
||||
// A permission the forge would not answer is said for that repository; the rest are still looked at.
|
||||
out.push({ account, repository: repo.full_name, permission: "unknown", private: repo.private, archived: repo.archived,
|
||||
why: `the forge did not answer ${account}'s permission on it — ${said(err)}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
return { checked: repos.length, accounts: accounts.map((account) => ({ account, unreadable: out.filter((u) => u.account === account).length })),
|
||||
absent, unreadable: out };
|
||||
}
|
||||
|
||||
/** Grant read on every repository an account cannot read (or on those named, `owner/name`), for every account of the
|
||||
* list the forge holds (or for those named). */
|
||||
async grant(only?: string[], accountsOnly?: string[]): Promise<Granted> {
|
||||
const { accounts, absent } = await this.present(accountsOnly);
|
||||
let repos: ForgeRepository[];
|
||||
if (only && only.length) {
|
||||
repos = [];
|
||||
for (const full of only) {
|
||||
const [owner, name, ...rest] = full.split("/");
|
||||
if (!owner || !name || rest.length) throw new Error(`"${full}" is not a repository: say owner/name`);
|
||||
const repo = await this.forge.repository(owner, name);
|
||||
if (!repo) throw new Error(`the forge holds no repository ${full}`);
|
||||
repos.push(repo);
|
||||
}
|
||||
} else repos = await this.forge.everyRepository();
|
||||
const result: Granted = { checked: repos.length, accounts, absent, granted: [], refused: [] };
|
||||
for (const account of accounts) {
|
||||
for (const repo of repos) {
|
||||
const repository = repo.full_name;
|
||||
let u: Unreadable | null;
|
||||
try {
|
||||
u = await this.judge(repo, account);
|
||||
} catch (err) {
|
||||
// One repository whose permission cannot be read is a refusal of its own, never the end of the pass.
|
||||
result.refused.push({ account, repository, why: `its permission could not be read — ${said(err)}` });
|
||||
continue;
|
||||
}
|
||||
if (!u) continue;
|
||||
try {
|
||||
await this.forge.addReadCollaborator(repo.owner, repo.name, account);
|
||||
} catch (err) {
|
||||
result.refused.push({ account, repository, why: said(err) });
|
||||
continue;
|
||||
}
|
||||
// Read back: the forge's own word that the account reads it now, never the grant's answer alone.
|
||||
let after: string;
|
||||
try {
|
||||
after = await this.forge.repositoryPermission(repo.owner, repo.name, account);
|
||||
} catch (err) {
|
||||
result.refused.push({ account, repository, why: `granted, but its permission could not be read back — ${said(err)}` });
|
||||
continue;
|
||||
}
|
||||
if (READS.has(after)) {
|
||||
this.readable.get(account)!.add(repository);
|
||||
result.granted.push({ account, repository });
|
||||
} else {
|
||||
result.refused.push({ account, repository, why: `the grant was answered, but the forge still gives ${account} "${after}"` });
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The start's one pass: grant what is missing and say it. Answers the result; a failure is the caller's to say. */
|
||||
async atStart(): Promise<Granted> {
|
||||
const r = await this.grant();
|
||||
const per = r.accounts.map((a) => {
|
||||
const g = r.granted.filter((x) => x.account === a).map((x) => x.repository);
|
||||
return `${a} ${g.length} granted read now${g.length ? ` (${g.join(", ")})` : ""}`;
|
||||
});
|
||||
this.log(`the mesh's accounts read every repository: ${r.checked} looked at; ${per.join("; ")}` +
|
||||
`${r.absent.length ? `; not on the forge yet, so not granted: ${r.absent.join(", ")}` : ""}` +
|
||||
`${r.refused.length ? `; ${r.refused.length} refused: ` + r.refused.map((x) => `${x.account} on ${x.repository} — ${x.why}`).join("; ") : ""}`);
|
||||
return r;
|
||||
}
|
||||
|
||||
/** A forge event: a repository made (or forked), or the first event of one some account was not seen reading, is
|
||||
* granted read now to every account that cannot read it. */
|
||||
async onEvent(event: { name: string; body: unknown }): Promise<void> {
|
||||
const b = (event.body ?? {}) as { owner?: string; repo?: string };
|
||||
if (!b.owner || !b.repo) return;
|
||||
const full = `${b.owner}/${b.repo}`;
|
||||
if (event.name === "forge.repository.deleted") {
|
||||
for (const seen of this.readable.values()) seen.delete(full);
|
||||
return;
|
||||
}
|
||||
const made = event.name === "forge.repository.created" || event.name === "forge.fork";
|
||||
// Seen readable once by every account, its events ask nothing: so a repository made private after that is not
|
||||
// caught here (the forge says no change of visibility), only at the next start or by gitea_grant_read.
|
||||
if (!made && [...this.readable.values()].every((seen) => seen.has(full))) return;
|
||||
const r = await this.grant([full]);
|
||||
for (const x of r.granted) this.log(`${x.account} was given read on ${full}, on the forge's ${event.name}`);
|
||||
for (const x of r.refused) this.log(`${x.account} could not be given read on ${x.repository} — ${x.why}`);
|
||||
}
|
||||
|
||||
private async judge(repo: ForgeRepository, account: string): Promise<Unreadable | null> {
|
||||
const permission = await this.forge.repositoryPermission(repo.owner, repo.name, account);
|
||||
const seen = this.readable.get(account)!;
|
||||
if (READS.has(permission)) {
|
||||
seen.add(repo.full_name);
|
||||
return null;
|
||||
}
|
||||
seen.delete(repo.full_name);
|
||||
return { account, repository: repo.full_name, permission, private: repo.private, archived: repo.archived, why: whyUnreadable(repo, account) };
|
||||
}
|
||||
}
|
||||
|
||||
type Tool = { name: string; description: string; input: Record<string, unknown>; run: (args: Readonly<Record<string, unknown>>) => Promise<unknown> };
|
||||
|
||||
const strings = (v: unknown): string[] | undefined => (Array.isArray(v) ? v.map(String) : undefined);
|
||||
|
||||
/** The two tools: what each account cannot read and why, and the grant now. */
|
||||
export function readAllTools(readAll: () => ReadAll): Tool[] {
|
||||
return [
|
||||
{
|
||||
name: "gitea_unreadable_repos",
|
||||
description: "Every repository on the forge one of the mesh's own forge accounts cannot read, and why, per account: " +
|
||||
"the tools' (mesh-tools), the build modules' fetch over ssh (nox-builder) and the machines' agents' (nox-agent). " +
|
||||
"For each, the permission the forge gives it there, whether the repository is private or archived, and in words " +
|
||||
"the reason (a private repository of a user with the account no collaborator, an owner not public); a count per " +
|
||||
"account, and the accounts the forge does not hold yet. Empty when every account reads every repository. Read " +
|
||||
"with the site administrator's login, which alone sees every repository.",
|
||||
input: {
|
||||
accounts: { type: "array", items: { type: "string" }, description: "the accounts to look at; empty or absent, every one of the mesh's" },
|
||||
},
|
||||
run: async (args) => readAll().unreadable(strings(args.accounts)),
|
||||
},
|
||||
{
|
||||
name: "gitea_grant_read",
|
||||
description: "Give each of the mesh's own forge accounts (mesh-tools, nox-builder, nox-agent) read on every " +
|
||||
"repository it cannot read now, or on the repositories named, or for the accounts named: each becomes a " +
|
||||
"collaborator that reads only, the least the forge offers. The module does this itself at its start and on the " +
|
||||
"forge's event of a repository made; this is the same act by hand, such as when a build's fetch says its account " +
|
||||
"is not authorized to read a repository. Answers how many were looked at, which were granted to which account " +
|
||||
"(each read back from the forge), which the forge refused, in its own words, and the accounts it does not hold " +
|
||||
"yet. Needs the site administrator's login, used for this grant alone.",
|
||||
input: {
|
||||
repositories: { type: "array", items: { type: "string" }, description: "owner/name of each repository; empty or absent, every repository" },
|
||||
accounts: { type: "array", items: { type: "string" }, description: "the accounts to grant; empty or absent, every one of the mesh's" },
|
||||
},
|
||||
run: async (args) => readAll().grant(strings(args.repositories), strings(args.accounts)),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Who is in a forge team and what the team may do (issue 1563; novox/hq ADR 0466 §3, issue 971).
|
||||
//
|
||||
// The build keys sit on one forge user, nox-builder, in the team `builders`, which reads every repository and pushes
|
||||
// nothing. The tools' account cannot read a team (the forge answers /orgs/novox/teams 403: it lacks read:organization),
|
||||
// so the read goes with the site administrator's login, and only the read, as the branch protection read does (ADR 0375):
|
||||
// GET /orgs/{org}/teams, GET /teams/{id}/members and, for a user's effective right on a repository, the repository as
|
||||
// that user sees it (GiteaAdmin.repositoryPermission, which counts access through a team). Nothing is written.
|
||||
|
||||
/** A team as the forge's Team object answers it (GET /orgs/{org}/teams): the fields read here. */
|
||||
export interface ForgeTeam {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
/** The team's overall permission: none, read, write, admin, owner. */
|
||||
permission?: string;
|
||||
includes_all_repositories?: boolean;
|
||||
can_create_org_repo?: boolean;
|
||||
/** Per unit, the right the team has: units_map {"repo.code": "read", ...}. */
|
||||
units_map?: Record<string, string>;
|
||||
units?: string[];
|
||||
}
|
||||
|
||||
/** What the module asks of the forge for this, with the site administrator's login (GiteaAdmin, client.ts). */
|
||||
export interface TeamForge {
|
||||
teams(org: string): Promise<ForgeTeam[]>;
|
||||
teamMembers(teamId: number): Promise<string[]>;
|
||||
accountExists(user: string): Promise<boolean>;
|
||||
everyRepository(): Promise<{ full_name: string; owner: string; name: string }[]>;
|
||||
repositoryPermission(owner: string, repo: string, user: string): Promise<string>;
|
||||
}
|
||||
|
||||
export interface TeamRights {
|
||||
team: string;
|
||||
id: number;
|
||||
description: string;
|
||||
members: string[];
|
||||
/** The team's overall permission. */
|
||||
permission: string;
|
||||
includes_all_repositories: boolean;
|
||||
can_create_org_repo: boolean;
|
||||
/** Per unit, read / write / admin / none. */
|
||||
units: Record<string, string>;
|
||||
/** True when no unit of the team is above read: the team pushes nothing, writes no issue, no pull request. */
|
||||
read_only: boolean;
|
||||
}
|
||||
|
||||
const RANK: Record<string, number> = { none: 0, read: 1, write: 2, admin: 3, owner: 4 };
|
||||
|
||||
/** One team as a person reads it. A team the forge gives no units_map is read from its `permission`, which is what the
|
||||
* older forge versions answer. */
|
||||
export function teamRights(t: ForgeTeam, members: string[]): TeamRights {
|
||||
const units = t.units_map && Object.keys(t.units_map).length ? { ...t.units_map }
|
||||
: Object.fromEntries((t.units ?? []).map((u) => [u, t.permission ?? "none"]));
|
||||
const highest = Math.max(0, ...Object.values(units).map((r) => RANK[r] ?? 0));
|
||||
return {
|
||||
team: t.name, id: t.id, description: t.description ?? "", members: [...members].sort(), permission: t.permission ?? "none",
|
||||
includes_all_repositories: t.includes_all_repositories === true, can_create_org_repo: t.can_create_org_repo === true,
|
||||
units, read_only: highest <= RANK.read && (RANK[t.permission ?? "none"] ?? 0) <= RANK.read,
|
||||
};
|
||||
}
|
||||
|
||||
export interface UserRights {
|
||||
user: string;
|
||||
/** The teams of the organisation the user is a member of. */
|
||||
teams: string[];
|
||||
/** How many repositories the forge gives the user each right on, effective (through a team or as a collaborator). */
|
||||
repositories: Record<string, number>;
|
||||
/** The repositories where the user may do more than read: empty for a read-only user. */
|
||||
writes: { repository: string; permission: string }[];
|
||||
/** Every repository and the right on it; only when asked. */
|
||||
every?: { repository: string; permission: string }[];
|
||||
}
|
||||
|
||||
/** The teams of an organisation (or the one named), each with its members and rights; and for one user, the teams it is
|
||||
* in and its effective right on every repository (or on those named), as the forge says it. Only reads. */
|
||||
export async function readTeamRights(forge: TeamForge, org: string, opts: { team?: string; user?: string; repositories?: string[]; list?: boolean } = {}):
|
||||
Promise<{ organisation: string; teams: TeamRights[]; user?: UserRights }> {
|
||||
if (opts.user && !(await forge.accountExists(opts.user))) throw new Error(`no forge user ${opts.user}`);
|
||||
const all = await forge.teams(org);
|
||||
const named = opts.team ? all.filter((t) => t.name === opts.team) : all;
|
||||
if (opts.team && !named.length) throw new Error(`no team "${opts.team}" in ${org}: it holds ${all.map((t) => t.name).join(", ") || "none"}`);
|
||||
// Membership is read for every team when a user is asked, so the user's teams are all found.
|
||||
const wanted = opts.user ? all : named;
|
||||
const rights: TeamRights[] = [];
|
||||
for (const t of wanted) rights.push(teamRights(t, await forge.teamMembers(t.id)));
|
||||
const shown = opts.team ? rights.filter((r) => r.team === opts.team) : rights;
|
||||
const out: { organisation: string; teams: TeamRights[]; user?: UserRights } = { organisation: org, teams: shown };
|
||||
if (!opts.user) return out;
|
||||
const user = opts.user;
|
||||
const mine = rights.filter((r) => r.members.includes(user)).map((r) => r.team);
|
||||
const repos = (await forge.everyRepository()).filter((r) => !opts.repositories?.length || opts.repositories.includes(r.full_name));
|
||||
const counts: Record<string, number> = {};
|
||||
const writes: { repository: string; permission: string }[] = [];
|
||||
const every: { repository: string; permission: string }[] = [];
|
||||
for (const r of repos) {
|
||||
const permission = await forge.repositoryPermission(r.owner, r.name, user);
|
||||
counts[permission] = (counts[permission] ?? 0) + 1;
|
||||
if ((RANK[permission] ?? 0) > RANK.read) writes.push({ repository: r.full_name, permission });
|
||||
every.push({ repository: r.full_name, permission });
|
||||
}
|
||||
out.user = { user, teams: mine, repositories: counts, writes, ...(opts.list ? { every } : {}) };
|
||||
return out;
|
||||
}
|
||||
@@ -109,3 +109,53 @@ test("a bundle whose read of the checker's owed is granted late writes the lates
|
||||
assert.match(got.statuses[0], /"state":"success".*pass at 4/);
|
||||
assert.match(err, /mesh-delivery-checker\.owed could not be bound/, "the refusal is said");
|
||||
});
|
||||
|
||||
// **The bundle's import does not wait on the forge** (novox/hq issue 1726, 2026-10-24 12:37). The webhook receiver's set-up
|
||||
// asks the forge's admin API for its system webhook; awaited at the top of index.ts, a forge slow to answer (busy while
|
||||
// every bundle of the node starts) held the import past node-tools' 10 s handshake, the bundle was killed and never
|
||||
// started again, and gitea_commit_status, gitea_merge_pull_request and gitea_events were served nowhere: the forge
|
||||
// bridge's calls found no responders and no new pull request was announced. Here the forge stand-in takes every
|
||||
// request and never answers: the import resolves at once all the same, with the bundle's tools registered.
|
||||
test("importing the bundle resolves, its tools registered, while the forge never answers the webhook's set-up", async () => {
|
||||
const index = pathToFileURL(join(here, "..", "dist", "index.js")).href;
|
||||
const script = `
|
||||
const http = await import("node:http");
|
||||
const fs = await import("node:fs");
|
||||
const os = await import("node:os");
|
||||
const path = await import("node:path");
|
||||
const forge = http.createServer(() => {});
|
||||
await new Promise((r) => forge.listen(0, "127.0.0.1", r));
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gitea-1726-"));
|
||||
fs.writeFileSync(path.join(dir, "admin.password"), "pw");
|
||||
process.env.MESH_GITEA_URL = "http://127.0.0.1:" + forge.address().port;
|
||||
process.env.MESH_GITEA_STATE_DIR = dir;
|
||||
process.env.MESH_GITEA_ADMIN_PASSWORD_FILE = path.join(dir, "admin.password");
|
||||
process.env.MESH_GITEA_HOOK_PORT = "0";
|
||||
process.env.MESH_GITEA_HOOK_HOST = "127.0.0.1";
|
||||
const { useBroker } = await import("@novox/mesh-sdk/messaging");
|
||||
useBroker(() => ({
|
||||
subscribe: async () => () => {}, publish: async () => {}, request: async () => { throw new Error("no responders"); },
|
||||
handle: async () => () => {}, close: async () => {},
|
||||
}));
|
||||
await import(${JSON.stringify(index)});
|
||||
const { collectTools } = await import("@novox/mesh-sdk/tools");
|
||||
console.log(JSON.stringify({ imported: true, tools: collectTools().flatMap((r) => r.tools.map((t) => t.name)) }));
|
||||
process.exit(0);`;
|
||||
const env = { ...process.env, MESH_GITEA_TOKEN: "x", MESH_GITEA_ADMIN_USER: "admin", MESH_GITEA_HOOK_URL: "http://127.0.0.1:1" };
|
||||
for (const k of ["MESH_SERVED_MODULE", "MESH_GITEA_ADMIN_PASSWORD", "MESH_GITEA_CONFIG_FILE"]) delete env[k];
|
||||
const child = spawn(process.execPath, ["--input-type=module", "-e", script], { cwd: join(here, ".."), env, stdio: ["ignore", "pipe", "pipe"] });
|
||||
let out = "";
|
||||
let err = "";
|
||||
child.stdout.on("data", (d) => (out += d));
|
||||
child.stderr.on("data", (d) => (err += d));
|
||||
const ended = new Promise<void>((r) => child.on("exit", () => r()));
|
||||
const timer = setTimeout(() => child.kill("SIGKILL"), 3000);
|
||||
await ended;
|
||||
clearTimeout(timer);
|
||||
const line = out.split("\n").find((l) => l.startsWith("{"));
|
||||
assert.ok(line, `the import did not resolve within 3 s while the forge did not answer; stdout: ${out.slice(0, 400)} stderr: ${err.slice(0, 600)}`);
|
||||
const got = JSON.parse(line!) as { tools: string[] };
|
||||
for (const t of ["gitea_events", "gitea_commit_status", "gitea_merge_pull_request"]) {
|
||||
assert.ok(got.tools.includes(t), `${t} is registered: ${got.tools}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -133,3 +133,45 @@ test("no tool writes mesh/check: gitea_check_status is gone and gitea_commit_sta
|
||||
const index = readFileSync(new URL("../index.ts", import.meta.url), "utf8");
|
||||
assert.ok(!/gitea_check_status|checkStatusTool/.test(index), "index.ts serves no gitea_check_status");
|
||||
});
|
||||
|
||||
test("the watch's current values are taken without waiting on the forge, so a slow forge never times the watch out (issue 1726)", async () => {
|
||||
// The tool runtime hands a watch's current values one at a time and waits for each to be taken; it gives the whole set
|
||||
// 10 s (mesh-tools StateTimeout) and otherwise ends the watch. On 2026-10-23 the owed held ~200 keys, each written to the
|
||||
// forge before the next was handed over: the set took longer than 10 s, the watch ended at every attempt, and no
|
||||
// verdict reached the forge's mesh/check again.
|
||||
const { CheckStatuses, watchOwed } = await import("../check.ts");
|
||||
const sent: string[] = [];
|
||||
let release: () => void = () => {};
|
||||
const slow = new Promise<void>((r) => { release = r; });
|
||||
const f = { setCommitStatus: async (_o: string, _r: string, s: string, st: any) => { await slow; sent.push(`${s.slice(0, 4)} ${st.state}`); } };
|
||||
const checks = new CheckStatuses(f, () => {}, noSleep);
|
||||
const keys = Array.from({ length: 200 }, (_, i) => key(i.toString(16).padStart(4, "0") + sha.slice(4), "novox/mesh-sdk", i + 1));
|
||||
let handedOver = 0;
|
||||
let attempts = 0;
|
||||
const watch = async (handler: (c: any) => Promise<void>) => {
|
||||
if (++attempts > 1) return; // a second attempt is not what this test waits for: the first must hold
|
||||
// The runtime's bound on the current values: the forge has answered none of them yet.
|
||||
const timedOut = new Promise<"timeout">((r) => setTimeout(() => r("timeout"), 200));
|
||||
const delivered = (async () => { for (const [i, k] of keys.entries()) { await handler(put(k, owed(), i + 1, true)); handedOver++; } return "delivered" as const; })();
|
||||
if (await Promise.race([delivered, timedOut]) === "timeout") throw new Error("the current values of mesh-delivery-checker.owed did not arrive in 10s");
|
||||
};
|
||||
const said: string[] = [];
|
||||
await watchOwed(watch, checks, noSleep, (l) => said.push(l));
|
||||
assert.equal(handedOver, 200, "every current value was taken before the forge answered");
|
||||
assert.ok(!said.some((l) => /could not be bound/.test(l)), said.join(" | "));
|
||||
assert.equal(sent.length, 0, "nothing is written before the forge answers");
|
||||
release();
|
||||
await checks.settled();
|
||||
assert.equal(sent.length, 200, "every owed status is written once the forge answers");
|
||||
});
|
||||
|
||||
test("the writes a watch's start hands over go to the forge a few at a time, never all at once (issue 1726)", async () => {
|
||||
const { CheckStatuses, WRITES_AT_ONCE } = await import("../check.ts");
|
||||
let running = 0, most = 0, done = 0;
|
||||
const f = { setCommitStatus: async () => { running++; most = Math.max(most, running); await new Promise((r) => setTimeout(r, 1)); running--; done++; } };
|
||||
const checks = new CheckStatuses(f, () => {}, noSleep);
|
||||
for (let i = 0; i < 50; i++) await checks.take(put(key(i.toString(16).padStart(4, "0") + sha.slice(4), "novox/mesh-sdk", i + 1), owed(), i + 1, true));
|
||||
await checks.settled();
|
||||
assert.equal(done, 50);
|
||||
assert.equal(most, WRITES_AT_ONCE);
|
||||
});
|
||||
|
||||
@@ -209,7 +209,7 @@ test("gitea_commit_trees takes paths as {directory: [paths]} and refuses another
|
||||
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("paths" in tool.input, "the tool names the argument");
|
||||
assert.ok("paths" in tool.input, "the tool names the arguments");
|
||||
const got: any = await tool.run({ owner: "novox", repo: "hq", sha: COMMIT, paths: { "modules/a": ["main.go"] } } as any);
|
||||
assert.deepEqual(got.paths, { "modules/a": { "main.go": h("6") } });
|
||||
const before = asked.length;
|
||||
|
||||
@@ -34,6 +34,12 @@ const state = {
|
||||
// refuses it the rules' list, as gitea's reqAdmin does, and it reads a branch's protection from the branch.
|
||||
admin: false,
|
||||
lastCommit: "2026-01-01T00:00:00Z",
|
||||
// The repository's default branch and pull request 7's base, as the forge keeps them (issue 1422 note 7).
|
||||
defaultBranch: "main",
|
||||
base: "main",
|
||||
prState: "open",
|
||||
// Branches the forge answers 404 for; "scratch" exists but no protection rule guards it.
|
||||
absent: ["nope"],
|
||||
// The pull request's head; the head the merge commit joined (the same, unless a test says otherwise) — the forge's
|
||||
// record of the pull request keeps saying `head` either way, as it would in the race issue 381 is about; and the
|
||||
// merge commit's own sha.
|
||||
@@ -66,7 +72,10 @@ beforeEach(() => {
|
||||
state.statuses = judgedAt(TRUNK);
|
||||
state.trunk = TRUNK;
|
||||
state.emitted = [];
|
||||
state.rules = [protectedMain()];
|
||||
state.rules = [protectedMain(), { rule_name: "release/*", enable_push: false }];
|
||||
state.defaultBranch = "main";
|
||||
state.base = "main";
|
||||
state.prState = "open";
|
||||
state.admin = false;
|
||||
state.head = REVIEWED;
|
||||
state.landed = "";
|
||||
@@ -98,12 +107,22 @@ const server = createServer(async (req, res) => {
|
||||
state.asked.push(`${method} ${path}${url.search}`);
|
||||
if (method !== "GET") state.sent.push({ method, path, body });
|
||||
const repo = "/repos/novox/mesh-catalog";
|
||||
if (method === "GET" && path === repo) return json(res, 200, { full_name: "novox/mesh-catalog", default_branch: "main" });
|
||||
if (method === "GET" && path === repo) return json(res, 200, { full_name: "novox/mesh-catalog", default_branch: state.defaultBranch });
|
||||
if (method === "PATCH" && path === repo) {
|
||||
if (body?.default_branch) state.defaultBranch = body.default_branch;
|
||||
return json(res, 200, { full_name: "novox/mesh-catalog", default_branch: state.defaultBranch });
|
||||
}
|
||||
if (method === "PATCH" && path === `${repo}/pulls/7`) {
|
||||
if (body?.base) state.base = body.base;
|
||||
return json(res, 201, { number: 7, title: "x", state: state.prState, merged: false, head: { ref: "feat/x", sha: state.head },
|
||||
base: { ref: state.base }, html_url: "http://fake/7" });
|
||||
}
|
||||
if (path.startsWith(`${repo}/branch_protections`) && !state.admin) {
|
||||
return json(res, 403, { message: "user should be an owner or a collaborator with admin write of a repository" });
|
||||
}
|
||||
if (method === "GET" && path === `${repo}/branch_protections`) return json(res, 200, state.rules);
|
||||
const branch = method === "GET" && path.startsWith(`${repo}/branches/`) ? decodeURIComponent(path.slice(`${repo}/branches/`.length)) : null;
|
||||
if (branch !== null && state.absent.includes(branch)) return json(res, 404, { message: "branch does not exist" });
|
||||
if (branch !== null) {
|
||||
// As the forge says it to any reader of the code: the rule that covers the branch, as the forge matches it.
|
||||
const rule = (state.rules as any[]).find((r) => ruleCovers(String(r.rule_name ?? r.branch_name), branch));
|
||||
@@ -125,8 +144,8 @@ const server = createServer(async (req, res) => {
|
||||
return json(res, 200, [{ commit: { committer: { date: state.lastCommit } } }]);
|
||||
}
|
||||
if (method === "GET" && path === `${repo}/pulls/7`) {
|
||||
return json(res, 200, { number: 7, title: "x", state: state.merged ? "closed" : "open", merged: state.merged,
|
||||
head: { ref: "feat/x", sha: state.head }, base: { ref: "main" }, html_url: "http://fake/7",
|
||||
return json(res, 200, { number: 7, title: "x", state: state.merged ? "closed" : state.prState, merged: state.merged,
|
||||
head: { ref: "feat/x", sha: state.head }, base: { ref: state.base }, html_url: "http://fake/7",
|
||||
merge_commit_sha: state.merged ? state.mergeCommit : null });
|
||||
}
|
||||
if (method === "GET" && path === `${repo}/git/commits/${state.mergeCommit}`) {
|
||||
@@ -525,6 +544,18 @@ test("gitea_merge_pull_request judges mesh/check on a main requiring no status,
|
||||
|
||||
state.statuses = line(TRUNK);
|
||||
assert.equal(((await merge()) as Record<string, unknown>).merged, true);
|
||||
|
||||
// check-gates-merge false (the operator, 2026-10-25): a head the Approve names merges whatever its mesh/check says;
|
||||
// without approved_heads it is refused as before.
|
||||
const ungated = mergeTool(client, { gates, stateDir, emit: async () => {}, checkGates: () => false });
|
||||
const mergeUngated = (args: Record<string, unknown> = { approved_heads }) =>
|
||||
ungated.run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED, ...args });
|
||||
for (const statuses of [[], line(TRUNK, "pending", "checking"), line(TRUNK, "failure", "fail"), line(MOVED)]) {
|
||||
state.merged = false;
|
||||
state.statuses = statuses;
|
||||
await assert.rejects(mergeUngated({}), /Approve/);
|
||||
assert.equal(((await mergeUngated()) as Record<string, unknown>).merged, true, JSON.stringify(statuses));
|
||||
}
|
||||
});
|
||||
|
||||
test("a rule named for a branch is not made weaker than the glob rule that guards it now", async () => {
|
||||
@@ -628,6 +659,7 @@ test("no tool's caller text becomes another path or another user: `..` and `?sud
|
||||
await tool("gitea_delete_branch").run({ ...repo, branch: "feat/x" });
|
||||
await tool("gitea_get_file").run({ ...repo, path: "modules/gitea/module.json" }).catch(() => undefined);
|
||||
assert.deepEqual(state.asked, ["DELETE /repos/novox/mesh-catalog/branches/feat/x",
|
||||
"GET /repos/novox/mesh-catalog/contents/modules/gitea", // the file's size first (issue 1577)
|
||||
"GET /repos/novox/mesh-catalog/contents/modules/gitea/module.json"]);
|
||||
});
|
||||
|
||||
@@ -796,3 +828,47 @@ test("gitea_merge_pull_request judges a main requiring mesh/repo-check alone on
|
||||
state.statuses = [judgedAt(TRUNK)[1]];
|
||||
assert.equal(((await merge()) as Record<string, unknown>).merged, true);
|
||||
});
|
||||
|
||||
// Issue 1422 note 7: the two settings gitea_api refuses (ADR 0266) have named tools, held to the same guard as
|
||||
// gitea_branch_protection_set: the tools' account, a stated reason, a branch that exists and that a rule guards.
|
||||
test("gitea_default_branch_set answers the default before and after, and refuses a branch that is missing, unguarded or unexplained", async () => {
|
||||
const set = (args: Record<string, unknown>) =>
|
||||
tool("gitea_default_branch_set").run({ owner: "novox", repo: "mesh-catalog", why: "the trunk was recorded as unknown", ...args });
|
||||
await assert.rejects(set({ branch: "nope" }), /no branch nope/);
|
||||
await assert.rejects(set({ branch: "scratch" }), /no protection rule/);
|
||||
await assert.rejects(set({ branch: "release/1", why: " " }), /why/);
|
||||
await assert.rejects(set({ branch: ".." }), /dot segment|not a branch/);
|
||||
assert.deepEqual(writes(), []);
|
||||
assert.deepEqual(await set({ branch: "release/1" }), { branch: "release/1", before: "main", after: "release/1", changed: true,
|
||||
why: "the trunk was recorded as unknown" });
|
||||
assert.deepEqual(writes(), ["PATCH /repos/novox/mesh-catalog"]);
|
||||
assert.deepEqual(state.sent[0].body, { default_branch: "release/1" });
|
||||
assert.equal(state.defaultBranch, "release/1");
|
||||
state.sent = [];
|
||||
assert.equal(((await set({ branch: "release/1" })) as any).changed, false, "already the default: nothing is sent");
|
||||
assert.deepEqual(writes(), []);
|
||||
});
|
||||
|
||||
test("gitea_pull_base_set answers the base before and after, and refuses a base that is missing, a closed pull request or no reason", async () => {
|
||||
const set = (args: Record<string, unknown>) =>
|
||||
tool("gitea_pull_base_set").run({ owner: "novox", repo: "mesh-catalog", number: 7, why: "it was opened against the wrong trunk", ...args });
|
||||
await assert.rejects(set({ base: "nope" }), /no branch nope/);
|
||||
await assert.rejects(set({ base: "release/1", why: "" }), /why/);
|
||||
await assert.rejects(set({ base: "release/1", number: 0 }), /pull request's number/);
|
||||
state.prState = "closed";
|
||||
await assert.rejects(set({ base: "release/1" }), /not open/);
|
||||
state.prState = "open";
|
||||
assert.deepEqual(writes(), []);
|
||||
assert.deepEqual(await set({ base: "release/1" }), { number: 7, before: "main", after: "release/1", changed: true,
|
||||
why: "it was opened against the wrong trunk" });
|
||||
assert.deepEqual(writes(), ["PATCH /repos/novox/mesh-catalog/pulls/7"]);
|
||||
assert.deepEqual(state.sent[0].body, { base: "release/1" });
|
||||
assert.equal(state.base, "release/1");
|
||||
});
|
||||
|
||||
test("the two new tools are named for what mesh_search is asked, and gitea_api still refuses both settings", () => {
|
||||
assert.match(tool("gitea_default_branch_set").description, /default branch/i);
|
||||
assert.match(tool("gitea_pull_base_set").description, /pull request.s base/i);
|
||||
assert.ok("refused" in judgeApiCall("PATCH", "/repos/a/b", { default_branch: "x" }));
|
||||
assert.ok("refused" in judgeApiCall("PATCH", "/repos/a/b/pulls/7", { base: "x" }));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { createServer } from "node:http";
|
||||
|
||||
// **The forge's 405 "Please try again later" on a merge is asked again briefly** (novox/hq issue 1275, 2026-10-26 13:40):
|
||||
// mesh-delivery#73's merge was answered so a few seconds after its base moved, and counted as refused at once. The forge
|
||||
// says it while it works out whether the pull request merges; the merge is asked again after a short wait, and every other
|
||||
// refusal is answered at once. A pull request's own mergeable is carried, so a group can be read whole before it merges.
|
||||
|
||||
/** A forge whose merge of novox/hq#7 answers each of `answers` in turn (the last for ever), and whose pull request says
|
||||
* `mergeable`. */
|
||||
async function serve(answers: { status: number; body: string }[], mergeable?: boolean) {
|
||||
const { GiteaClient } = await import("../client.ts");
|
||||
let merges = 0;
|
||||
const server = createServer((req, res) => {
|
||||
const url = new URL(req.url ?? "", "http://x");
|
||||
if (url.pathname === "/api/v1/repos/novox/hq/pulls/7/merge" && req.method === "POST") {
|
||||
const a = answers[Math.min(merges++, answers.length - 1)];
|
||||
res.statusCode = a.status;
|
||||
return res.end(a.body);
|
||||
}
|
||||
if (url.pathname === "/api/v1/repos/novox/hq/pulls/7") {
|
||||
return res.end(JSON.stringify({ number: 7, state: "open", merged: false, base: { ref: "main" },
|
||||
head: { ref: "b", sha: "a".repeat(40) }, html_url: "h/7", ...(mergeable === undefined ? {} : { mergeable }) }));
|
||||
}
|
||||
res.statusCode = 404;
|
||||
res.end("{}");
|
||||
});
|
||||
await new Promise<void>((r) => server.listen(0, r));
|
||||
const client = new GiteaClient(`http://127.0.0.1:${(server.address() as any).port}`, "t");
|
||||
GiteaClient.mergeRetryAfter = [1, 1, 1];
|
||||
return { client, server, merges: () => merges };
|
||||
}
|
||||
|
||||
const later = { status: 405, body: '{"message":"Please try again later"}' };
|
||||
const ok = { status: 200, body: "" };
|
||||
|
||||
test("a merge answered 'try again later' while the forge works out its mergeability, then merged", async (t) => {
|
||||
const cases: { name: string; answers: { status: number; body: string }[]; merged: boolean; asked: number }[] = [
|
||||
{ name: "merged on the third ask", answers: [later, later, ok], merged: true, asked: 3 },
|
||||
{ name: "still 'later' after the last wait: refused, with the forge's words", answers: [later], merged: false, asked: 4 },
|
||||
{ name: "a refusal of another kind is answered at once", answers: [{ status: 409, body: '{"message":"head moved"}' }], merged: false, asked: 1 },
|
||||
];
|
||||
for (const c of cases) {
|
||||
await t.test(c.name, async () => {
|
||||
const { client, server, merges } = await serve(c.answers);
|
||||
try {
|
||||
const done = client.mergePullRequest("novox", "hq", 7, "merge", false, "a".repeat(40));
|
||||
if (c.merged) await done;
|
||||
else await assert.rejects(done, (err: Error) => /40[59]/.test(err.message));
|
||||
assert.equal(merges(), c.asked);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("a pull request carries the forge's mergeable, and none when the forge did not say", async () => {
|
||||
for (const mergeable of [true, false, undefined]) {
|
||||
const { client, server } = await serve([ok], mergeable);
|
||||
try {
|
||||
assert.equal((await client.getPullRequest("novox", "hq", 7)).mergeable, mergeable);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -45,7 +45,7 @@ test("a new head beyond the newest 20 open pull requests is announced, exactly o
|
||||
const { headsToAnnounce } = await import("../pulls.ts");
|
||||
// 25 open, every head announced before; #3 — the 23rd most recently updated — got a new head.
|
||||
const announced: Record<string, string> = {};
|
||||
for (let n = 1; n <= 25; n++) announced[`novox/hq#${n}`] = sha(n, "a");
|
||||
for (let n = 1; n <= 25; n++) announced[`novox/hq#${n}@main`] = sha(n, "a");
|
||||
const { client, server, asked } = await serve(25, { 3: sha(3, "b") });
|
||||
try {
|
||||
// The fault: the newest 20 alone do not hold it.
|
||||
@@ -58,7 +58,7 @@ test("a new head beyond the newest 20 open pull requests is announced, exactly o
|
||||
const open = await client.listOpenPullsSince("novox", "hq", "");
|
||||
for (const p of headsToAnnounce("novox/hq", open, announced)) {
|
||||
said.push(`#${p.number}@${p.head_sha}`);
|
||||
announced[`novox/hq#${p.number}`] = String(p.head_sha);
|
||||
announced[`novox/hq#${p.number}@${p.base}`] = String(p.head_sha);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(said, [`#3@${sha(3, "b")}`], "the new head past the newest 20, announced once over two passes");
|
||||
@@ -90,3 +90,48 @@ test("a pass pages until it reaches back past its floor, and no further", async
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
// **One forge request never holds the others, and a file too big for an answer is not downloaded** (novox/hq issue 1577): a
|
||||
// request for a 4.9 MB file never ended and every read through the module timed out behind it.
|
||||
test("a request the forge never answers is cut at the limit and says which request", async () => {
|
||||
const { GiteaClient } = await import("../client.ts");
|
||||
const server = createServer((req) => { if (!req.url?.includes("fast")) return; });
|
||||
await new Promise<void>((r) => server.listen(0, r));
|
||||
const client = new GiteaClient(`http://127.0.0.1:${(server.address() as any).port}`, "t");
|
||||
client.requestTimeoutMs = 150;
|
||||
try {
|
||||
const started = Date.now();
|
||||
await assert.rejects(client.api("/repos/novox/hq/pulls"), /Gitea API GET \/repos\/novox\/hq\/pulls: no answer in 0\.15 s/);
|
||||
assert.ok(Date.now() - started < 3000);
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((r) => server.close(() => r()));
|
||||
}
|
||||
});
|
||||
|
||||
test("a file over 512 KiB is refused from its directory listing, and its body is never asked for", async () => {
|
||||
const { GiteaClient } = await import("../client.ts");
|
||||
const asked: string[] = [];
|
||||
const server = createServer((req, res) => {
|
||||
asked.push(req.url ?? "");
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
if (req.url?.startsWith("/api/v1/repos/novox/hq/contents/bin?")) {
|
||||
return res.end(JSON.stringify([{ path: "bin/big", type: "file", size: 5_000_000 }, { path: "bin/small", type: "file", size: 5 }]));
|
||||
}
|
||||
if (req.url?.startsWith("/api/v1/repos/novox/hq/contents/bin/small")) {
|
||||
return res.end(JSON.stringify({ type: "file", encoding: "base64", sha: "s", size: 5, content: Buffer.from("hello").toString("base64") }));
|
||||
}
|
||||
res.statusCode = 404;
|
||||
res.end("{}");
|
||||
});
|
||||
await new Promise<void>((r) => server.listen(0, r));
|
||||
const client = new GiteaClient(`http://127.0.0.1:${(server.address() as any).port}`, "t");
|
||||
try {
|
||||
await assert.rejects(client.getFile("novox", "hq", "bin/big", "main"),
|
||||
/the file is 5000000 bytes, over the 512 KiB a tool answer can carry; read it from a clone/);
|
||||
assert.ok(!asked.some((u) => u.includes("/contents/bin/big")), asked.join("\n"));
|
||||
assert.equal((await client.getFile("novox", "hq", "bin/small", "main")).content, "hello");
|
||||
} finally {
|
||||
await new Promise<void>((r) => server.close(() => r()));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
// gitea_branch_protection_set_full (the operator, 2026-10-28: "just make a generic tool so you can setup any
|
||||
// configuration"): any field of a branch protection rule, only the fields given, as the forge's administrator; it
|
||||
// answers the rule before and after, and whether the controller's trunk rule (judgedRule, mesh-control
|
||||
// build_source.go) now holds. Against a fake forge that keeps the rules and answers them to the administrator alone.
|
||||
|
||||
import { test, after, beforeEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer } from "node:http";
|
||||
|
||||
import { GiteaAdmin, GiteaClient } from "../dist/client.js";
|
||||
import { ConfiguredToken } from "../dist/token.js";
|
||||
import { getGiteaTools } from "../dist/tools/index.js";
|
||||
import { trunkRule } from "../dist/protectfull.js";
|
||||
|
||||
const adminBasic = "Basic " + Buffer.from("mesh-admin:secret").toString("base64");
|
||||
let rules: any[] = [];
|
||||
let repoState: any = {};
|
||||
const branches = ["master", "main"];
|
||||
let sent: { method: string; path: string; body: any }[] = [];
|
||||
beforeEach(() => {
|
||||
// de-spiegel's main as found: no push, but the operator alone merges and an administrator merges past it.
|
||||
rules = [{ rule_name: "main", enable_push: false, enable_merge_whitelist: true, merge_whitelist_usernames: ["jschoubben"],
|
||||
enable_status_check: false, status_check_contexts: [], block_admin_merge_override: false, required_approvals: 0,
|
||||
enable_push_whitelist: false, push_whitelist_deploy_keys: false, enable_force_push: false, enable_force_push_allowlist: false,
|
||||
force_push_allowlist_deploy_keys: false, merge_whitelist_teams: [] }];
|
||||
source = {};
|
||||
repoState = { full_name: "novox/de-spiegel", default_branch: "master", description: "", has_wiki: true };
|
||||
sent = [];
|
||||
});
|
||||
|
||||
// What gitea 1.27.3's EditBranchProtection does with a body (routers/api/v1/repo/branch.go): the push whitelist flags are
|
||||
// read only beside enable_push true and cleared by enable_push false, the force-push allowlist flags likewise with
|
||||
// enable_force_push; teams are passed over on a repository no organisation owns (de-spiegel here, for the test).
|
||||
function giteaEdit(r: any, body: any) {
|
||||
const nested = ["enable_push", "enable_push_whitelist", "push_whitelist_deploy_keys", "enable_force_push",
|
||||
"enable_force_push_allowlist", "force_push_allowlist_deploy_keys", "merge_whitelist_teams", "push_whitelist_teams"];
|
||||
for (const [k, v] of Object.entries(body)) if (!nested.includes(k) && k !== "rule_name") r[k] = v;
|
||||
if (body.enable_push === false) Object.assign(r, { enable_push: false, enable_push_whitelist: false, push_whitelist_deploy_keys: false });
|
||||
if (body.enable_push === true) {
|
||||
r.enable_push = true;
|
||||
if (body.enable_push_whitelist !== undefined) r.enable_push_whitelist = body.enable_push_whitelist;
|
||||
if (body.push_whitelist_deploy_keys !== undefined) r.push_whitelist_deploy_keys = body.push_whitelist_deploy_keys;
|
||||
}
|
||||
if (body.enable_force_push === false) Object.assign(r, { enable_force_push: false, enable_force_push_allowlist: false, force_push_allowlist_deploy_keys: false });
|
||||
if (body.enable_force_push === true) {
|
||||
r.enable_force_push = true;
|
||||
if (body.enable_force_push_allowlist !== undefined) r.enable_force_push_allowlist = body.enable_force_push_allowlist;
|
||||
if (body.force_push_allowlist_deploy_keys !== undefined) r.force_push_allowlist_deploy_keys = body.force_push_allowlist_deploy_keys;
|
||||
}
|
||||
}
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const path = (req.url ?? "").replace(/^\/api\/v1/, "");
|
||||
const send = (status: number, body: unknown) => { res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify(body)); };
|
||||
if (req.headers.authorization !== adminBasic) return send(403, { message: "not an administrator of the repository" });
|
||||
let raw = "";
|
||||
req.on("data", (c) => (raw += c));
|
||||
req.on("end", () => {
|
||||
const body = raw ? JSON.parse(raw) : undefined;
|
||||
if (req.method !== "GET") sent.push({ method: req.method!, path, body });
|
||||
if (path === "/repos/novox/de-spiegel") {
|
||||
if (req.method === "GET") return send(200, repoState);
|
||||
if (req.method === "PATCH") {
|
||||
// gitea's updateBasicProperties: a default branch that does not exist is passed over, the call still 200.
|
||||
// enable_prune is a mirror's, kept beside the repository and never in its answer; mirror_interval is answered in Go's
|
||||
// duration form.
|
||||
const { default_branch, enable_prune, mirror_interval, ...rest } = body;
|
||||
Object.assign(repoState, rest);
|
||||
if (mirror_interval !== undefined) repoState.mirror_interval = mirror_interval === "8h" ? "8h0m0s" : mirror_interval;
|
||||
if (default_branch !== undefined && branches.includes(default_branch)) repoState.default_branch = default_branch;
|
||||
return send(200, repoState);
|
||||
}
|
||||
}
|
||||
const m = path.match(/^\/repos\/novox\/de-spiegel\/branch_protections(?:\/(.+))?$/);
|
||||
if (!m) return send(404, { message: "not found" });
|
||||
if (req.method === "GET" && !m[1]) return send(200, rules);
|
||||
if (req.method === "PATCH" && m[1]) {
|
||||
const r = rules.find((x) => x.rule_name === decodeURIComponent(m[1]));
|
||||
if (!r) return send(404, { message: "no rule" });
|
||||
giteaEdit(r, body);
|
||||
return send(200, r);
|
||||
}
|
||||
if (req.method === "POST" && !m[1]) { const r = { rule_name: body.rule_name }; giteaEdit(r, body); rules.push(r); return send(201, r); }
|
||||
send(405, { message: "no" });
|
||||
});
|
||||
});
|
||||
await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
|
||||
after(() => new Promise<void>((r) => server.close(() => r())));
|
||||
const { port } = server.address() as { port: number };
|
||||
const url = `http://127.0.0.1:${port}`;
|
||||
// The gate's source as the tools read it (mesh-delivery-gate.source): one key per trunk, each naming the repositories
|
||||
// installed from it. A test sets it; empty is a source that names no trunk of de-spiegel.
|
||||
let source: Record<string, { heads: Record<string, string> }> = {};
|
||||
const readSource = async () => source;
|
||||
const tools = getGiteaTools(new GiteaClient(url, new ConfiguredToken("tools-token")), () => new GiteaAdmin(url, "mesh-admin", "secret"), readSource);
|
||||
const setRepo = (args: Record<string, unknown>) =>
|
||||
tools.find((t) => t.name === "gitea_repo_settings_set_full")!.run({ owner: "novox", repo: "de-spiegel", why: "L2", ...args });
|
||||
const setFull = (args: Record<string, unknown>) =>
|
||||
tools.find((t) => t.name === "gitea_branch_protection_set_full")!.run({ owner: "novox", repo: "de-spiegel", branch: "main", why: "L2", ...args });
|
||||
|
||||
test("it sets the merge whitelist and the administrator's override, only the fields given, and says the trunk rule now holds", async () => {
|
||||
const out: any = await setFull({ merge_whitelist_usernames: ["mesh-tools", "jschoubben"], block_admin_merge_override: true });
|
||||
assert.deepEqual(sent, [{ method: "PATCH", path: "/repos/novox/de-spiegel/branch_protections/main",
|
||||
body: { merge_whitelist_usernames: ["mesh-tools", "jschoubben"], block_admin_merge_override: true } }]);
|
||||
assert.deepEqual(out.before.merge_whitelist, ["jschoubben"]);
|
||||
assert.equal(out.before.admin_may_override, true);
|
||||
assert.deepEqual(out.after.merge_whitelist, ["mesh-tools", "jschoubben"]);
|
||||
assert.equal(out.after.admin_may_override, false);
|
||||
assert.equal(out.why, "L2");
|
||||
assert.equal(out.trunk_rule.holds, true, out.trunk_rule.why);
|
||||
});
|
||||
|
||||
test("it loosens as told too, and says the trunk rule no longer holds and why", async () => {
|
||||
const out: any = await setFull({ enable_push: true, status_check_contexts: ["mesh/check"], enable_status_check: true, required_approvals: 1 });
|
||||
assert.equal(out.after.push, true);
|
||||
assert.deepEqual(out.after.required_statuses, ["mesh/check"]);
|
||||
assert.equal(out.after.required_approvals, 1);
|
||||
assert.equal(out.trunk_rule.holds, false);
|
||||
assert.match(out.trunk_rule.why, /push to main directly/);
|
||||
});
|
||||
|
||||
test("a branch with no rule gets one with the fields given", async () => {
|
||||
const out: any = await setFull({ branch: "release/*", enable_push: false, protected_file_patterns: "*.lock" });
|
||||
assert.equal(out.created, true);
|
||||
assert.deepEqual(sent[0], { method: "POST", path: "/repos/novox/de-spiegel/branch_protections",
|
||||
body: { rule_name: "release/*", enable_push: false, protected_file_patterns: "*.lock" } });
|
||||
});
|
||||
|
||||
test("an unknown field, a field of the wrong kind, no field, no reason or a login that is none: refused, nothing sent", async () => {
|
||||
await assert.rejects(setFull({ enable_pushes: true }), /no field of a branch protection rule/);
|
||||
await assert.rejects(setFull({ enable_push: "yes" }), /true or false/);
|
||||
await assert.rejects(setFull({ required_approvals: -1 }), /whole number/);
|
||||
await assert.rejects(setFull({ merge_whitelist_usernames: ["../x"] }), /no forge login/);
|
||||
await assert.rejects(setFull({}), /at least one field/);
|
||||
await assert.rejects(setFull({ why: "", enable_push: false }), /say why/);
|
||||
assert.deepEqual(sent, []);
|
||||
});
|
||||
|
||||
// judgedRule's cases (mesh-control cmd/mesh-controller/build_source.go), each in its own words.
|
||||
test("the trunk rule is judged as the controller judges it", () => {
|
||||
const ok = { rule_name: "main", enable_push: false, enable_merge_whitelist: true, merge_whitelist_usernames: ["mesh-tools"], block_admin_merge_override: true };
|
||||
const cases: [any[], string, boolean, RegExp][] = [
|
||||
[[], "main", false, /no protection rule covers main/],
|
||||
[[{ ...ok, enable_push: true }], "main", false, /push to main directly/],
|
||||
[[{ ...ok, enable_merge_whitelist: false }], "main", false, /no merge whitelist/],
|
||||
[[{ ...ok, merge_whitelist_usernames: ["jschoubben"] }], "main", false, /and not mesh-tools/],
|
||||
[[{ ...ok, merge_whitelist_usernames: ["mesh-tools", "agent-g14"] }], "main", false, /agent's account/],
|
||||
[[{ ...ok, block_admin_merge_override: false }], "main", false, /administrator/],
|
||||
[[ok], "main", true, /takes no push/],
|
||||
[[{ ...ok, rule_name: "ma*" }], "main", true, /ma\*/],
|
||||
[[{ ...ok, rule_name: "ma*", enable_push: true }, { ...ok }], "main", true, /rule main/],
|
||||
];
|
||||
for (const [rs, branch, holds, why] of cases) {
|
||||
const got = trunkRule(rs, branch);
|
||||
assert.equal(got.holds, holds, `${JSON.stringify(rs)}: ${got.why}`);
|
||||
assert.match(got.why, why);
|
||||
}
|
||||
});
|
||||
|
||||
// Review round 1 of feat/protect-as-trunk: gitea passes over a dependent field without its parent set true, and answers 200.
|
||||
test("a field the forge reads only beside its parent is refused without it; with it, it is applied", async () => {
|
||||
await assert.rejects(setFull({ enable_push_whitelist: true }), /only when the same call sets enable_push: true/);
|
||||
await assert.rejects(setFull({ force_push_allowlist_deploy_keys: true, enable_force_push: false }), /enable_force_push: true/);
|
||||
assert.deepEqual(sent, []);
|
||||
const out: any = await setFull({ enable_push: true, enable_push_whitelist: true, push_whitelist_usernames: ["jschoubben"] });
|
||||
assert.equal(out.after.fields.enable_push_whitelist, true);
|
||||
await setFull({ enable_push: false, enable_push_whitelist: false }); // a false beside a false parent is what the forge does
|
||||
});
|
||||
|
||||
test("a field the forge answered and did not apply fails the call, naming it; what it changed beside the call is said", async () => {
|
||||
await assert.rejects(setFull({ merge_whitelist_teams: ["owners"] }), /did not apply merge_whitelist_teams \(sent \["owners"\], the forge holds \[\]\)/);
|
||||
rules[0].enable_push = true; rules[0].enable_push_whitelist = true;
|
||||
const out: any = await setFull({ enable_push: false });
|
||||
assert.deepEqual(out.also_changed, ["enable_push_whitelist"]);
|
||||
});
|
||||
|
||||
test("gitea_repo_settings_set_full sets the default branch, and fails loudly on a branch the forge passed over", async () => {
|
||||
const out: any = await setRepo({ default_branch: "main" });
|
||||
assert.deepEqual(sent.at(-1), { method: "PATCH", path: "/repos/novox/de-spiegel", body: { default_branch: "main" } });
|
||||
assert.equal(out.before.default_branch, "master");
|
||||
assert.equal(out.after.default_branch, "main");
|
||||
await assert.rejects(setRepo({ default_branch: "trunk" }), /did not apply default_branch/);
|
||||
await assert.rejects(setRepo({ name: "x" }), /no field of a repository's settings/);
|
||||
await assert.rejects(setRepo({ why: "", has_wiki: false }), /say why/);
|
||||
});
|
||||
|
||||
test("every field gitea 1.27.3 edits on a rule is taken: 32, the bypass allowlist among them", async () => {
|
||||
const { PROTECTION_FIELDS } = await import("../dist/protectfull.js");
|
||||
assert.equal(Object.keys(PROTECTION_FIELDS).length, 32);
|
||||
for (const f of ["enable_bypass_allowlist", "bypass_allowlist_usernames", "bypass_allowlist_teams"]) assert.ok(f in PROTECTION_FIELDS, f);
|
||||
});
|
||||
|
||||
// Review round 2: the settings tool's read-back meets the forge's own answer.
|
||||
test("A: enable_prune, which the forge never answers, is said as not read back, never failed", async () => {
|
||||
const pruned: any = await setRepo({ enable_prune: true });
|
||||
assert.deepEqual(pruned.not_read_back, ["enable_prune"]);
|
||||
});
|
||||
|
||||
test("B: mirror_interval is compared as a duration: 8h and the forge's 8h0m0s are one", async () => {
|
||||
const mirror: any = await setRepo({ mirror_interval: "8h" });
|
||||
assert.equal(mirror.after.mirror_interval, "8h0m0s");
|
||||
await assert.rejects(setRepo({ mirror_interval: "often" }), /duration/);
|
||||
});
|
||||
|
||||
test("C: allow_merge_update, default_update_style and has_code are taken; projects_mode needs has_projects; what is left out is named", async () => {
|
||||
const more: any = await setRepo({ allow_merge_update: true, default_update_style: "rebase", has_code: true });
|
||||
assert.deepEqual(more.set, { allow_merge_update: true, default_update_style: "rebase", has_code: true });
|
||||
await assert.rejects(setRepo({ projects_mode: "repo" }), /only when the same call sets has_projects: true/);
|
||||
await setRepo({ has_projects: true, projects_mode: "repo" });
|
||||
const desc = tools.find((t) => t.name === "gitea_repo_settings_set_full")!.description;
|
||||
for (const w of ["name", "mirror_username", "mirror_password", "mirror_token", "tracker", "wiki"]) assert.match(desc, new RegExp(w));
|
||||
});
|
||||
|
||||
// The operator, 2026-10-28 16:4x: a call waits for their Approve only if it removes branch protection or allows a
|
||||
// force-push to main. No tool of this module can ask yet (no TypeScript asker; ADR 0286's mark is unbuilt in the
|
||||
// runner), so until one can, such a call is refused, with nothing sent, and says it is the operator's; everything else
|
||||
// runs at once.
|
||||
test("a force-push onto a rule covering main is refused, nothing sent; on another branch, and tightening, it runs", async () => {
|
||||
for (const args of [{ enable_force_push: true }, { enable_force_push: true, enable_force_push_allowlist: true },
|
||||
{ force_push_allowlist_usernames: ["jschoubben"] }, { force_push_allowlist_teams: ["owners"] },
|
||||
{ branch: "ma*", enable_force_push: true }, { branch: "**", force_push_allowlist_usernames: ["x"] }]) {
|
||||
await assert.rejects(setFull(args), /force-push to a trunk is the operator's/, JSON.stringify(args));
|
||||
}
|
||||
assert.deepEqual(sent, []);
|
||||
await setFull({ branch: "release/*", enable_force_push: true });
|
||||
const tight: any = await setFull({ enable_force_push: false, force_push_allowlist_usernames: [] });
|
||||
assert.equal(tight.trunk_rule.holds, false, "trunk_rule is in the answer");
|
||||
});
|
||||
|
||||
// The operator, 2026-10-28 16:5x: the gates and trunk_rule apply to every trunk, the branches the mesh installs from, as
|
||||
// the gate's source names them; "main" only when it names none, and said.
|
||||
test("a trunk the gate's source names, beta, is gated and judged as main is; main is then no trunk of the repository", async () => {
|
||||
source = { main: { heads: { "novox/okioki": "a1" } }, beta: { heads: { "novox/de-spiegel": "b2" } } };
|
||||
await assert.rejects(setFull({ branch: "beta", enable_force_push: true }), /force-push to a trunk is the operator's/);
|
||||
await assert.rejects(setFull({ branch: "b*", force_push_allowlist_usernames: ["x"] }), /covers beta/);
|
||||
assert.deepEqual(sent, []);
|
||||
const main: any = await setFull({ enable_force_push: true });
|
||||
assert.deepEqual(main.trunks, { names: ["beta"], from: "mesh-delivery-gate.source" });
|
||||
assert.equal(main.trunk_rule.is_trunk, false);
|
||||
const beta: any = await setFull({ branch: "beta", enable_push: false, enable_merge_whitelist: true, merge_whitelist_usernames: ["mesh-tools"], block_admin_merge_override: true });
|
||||
assert.equal(beta.trunk_rule.is_trunk, true);
|
||||
assert.equal(beta.trunk_rule.holds, true, beta.trunk_rule.why);
|
||||
});
|
||||
|
||||
test("a source that names no trunk of the repository falls back to main, and says so", async () => {
|
||||
const out: any = await setFull({ block_admin_merge_override: true });
|
||||
assert.equal(out.trunks.names[0], "main");
|
||||
assert.match(out.trunks.from, /names no trunk of novox\/de-spiegel: main assumed/);
|
||||
assert.equal(out.trunk_rule.is_trunk, true);
|
||||
});
|
||||
@@ -8,16 +8,27 @@ import { createServer } from "node:http";
|
||||
test("each open pull request's new head is announced once, and nothing closed or merged", async () => {
|
||||
const { headsToAnnounce } = await import("../pulls.ts");
|
||||
const pulls = [
|
||||
{ number: 1, title: "a", state: "open", merged: false, head_sha: "aaa", html_url: "" },
|
||||
{ number: 1, title: "a", state: "open", merged: false, head_sha: "aaa", base: "main", html_url: "" },
|
||||
{ number: 2, title: "b", state: "open", merged: false, head_sha: "bbb", html_url: "" },
|
||||
{ number: 3, title: "c", state: "closed", merged: true, head_sha: "ccc", html_url: "" },
|
||||
{ number: 4, title: "d", state: "open", merged: false, html_url: "" },
|
||||
];
|
||||
const announced = { "novox/mesh-catalog#1": "aaa", "novox/mesh-catalog#2": "old" };
|
||||
const announced = { "novox/mesh-catalog#1@main": "aaa", "novox/mesh-catalog#2@": "old" };
|
||||
assert.deepEqual(headsToAnnounce("novox/mesh-catalog", pulls, announced).map((p) => p.number), [2],
|
||||
"only a head not announced, of a pull request that is open");
|
||||
});
|
||||
|
||||
test("a pull request retargeted keeps its head and is announced again, into its new base (de-spiegel #6)", async () => {
|
||||
const { headsToAnnounce, headKey } = await import("../pulls.ts");
|
||||
const was = { number: 6, title: "a", state: "open", merged: false, head_sha: "aaa", base: "old-trunk", html_url: "" };
|
||||
const announced: Record<string, string> = { [headKey("novox/de-spiegel", 6, "old-trunk")]: "aaa" };
|
||||
assert.deepEqual(headsToAnnounce("novox/de-spiegel", [was], announced), [], "the same head into the same base is announced once");
|
||||
const retargeted = { ...was, base: "main" };
|
||||
assert.deepEqual(headsToAnnounce("novox/de-spiegel", [retargeted], announced).map((p) => p.number), [6],
|
||||
"the same head into a new base is judged afresh");
|
||||
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" };
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Issue 1563: no tool read a forge team's members and rights, so the build keys' read-only user (nox-builder, team
|
||||
// builders, ADR 0466 §3) could not be checked live. The tools' token answers /orgs/novox/teams 403; the read goes with
|
||||
// the administrator's login, GET only.
|
||||
//
|
||||
// The answers below keep the shape of the forge's own API (gitea's swagger, "Team": id, name, description,
|
||||
// organization, includes_all_repositories, permission, units, units_map, can_create_org_repo; "User": id, login, ...;
|
||||
// repos/search: {ok, data:[Repository]}; Repository.permissions {admin, push, pull}). The live answer is read once the
|
||||
// tool is delivered (issue 1563 AC2).
|
||||
|
||||
import { test, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer } from "node:http";
|
||||
|
||||
import { GiteaAdmin, GiteaClient } from "../dist/client.js";
|
||||
import { ConfiguredToken } from "../dist/token.js";
|
||||
import { getGiteaTools } from "../dist/tools/index.js";
|
||||
|
||||
const adminBasic = "Basic " + Buffer.from("mesh-admin:secret").toString("base64");
|
||||
const org = { id: 2, username: "novox", name: "novox" };
|
||||
const team = (id: number, name: string, over: Record<string, unknown>) => ({
|
||||
id, name, description: "", organization: org, includes_all_repositories: true, permission: "read", can_create_org_repo: false,
|
||||
units: ["repo.code"], units_map: { "repo.code": "read" }, ...over,
|
||||
});
|
||||
const teams = [
|
||||
team(5, "builders", { description: "the build modules' fetch" }),
|
||||
team(3, "mesh-tools", { permission: "write", units: ["repo.code", "repo.issues", "repo.pulls"],
|
||||
units_map: { "repo.code": "write", "repo.issues": "write", "repo.pulls": "write" } }),
|
||||
];
|
||||
const members: Record<string, string[]> = { "5": ["nox-builder"], "3": ["mesh-tools"] };
|
||||
const user = (login: string) => ({ id: 23, login, username: login, email: `${login}@noreply.localhost` });
|
||||
const repo = (owner: string, name: string) => ({ full_name: `${owner}/${name}`, name, owner: { login: owner }, private: true, archived: false });
|
||||
const repos = [repo("novox", "mesh-catalog"), repo("jschoubben", "car-hunter")];
|
||||
const seen: string[] = [];
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const url = new URL(req.url ?? "", "http://x");
|
||||
const path = url.pathname.replace(/^\/api\/v1/, "");
|
||||
const as = req.headers.authorization === adminBasic ? "admin" : "tools";
|
||||
seen.push(`${req.method} ${path} as ${as}`);
|
||||
const send = (status: number, body: unknown) => { res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify(body)); };
|
||||
if (as !== "admin" && path.startsWith("/orgs/")) return send(403, { message: "token does not have at least one of required scope(s): [read:organization]" });
|
||||
if (path === "/orgs/novox/teams") return send(200, teams);
|
||||
const m = path.match(/^\/teams\/(\d+)\/members$/);
|
||||
if (m) return send(200, (members[m[1]] ?? []).map(user));
|
||||
const u = path.match(/^\/users\/([^/]+)$/);
|
||||
if (u) return send(u[1] === "nobody" ? 404 : 200, u[1] === "nobody" ? { message: "not found" } : user(u[1]));
|
||||
if (path === "/repos/search") return send(200, { ok: true, data: repos });
|
||||
const r = path.match(/^\/repos\/([^/]+)\/([^/]+)$/);
|
||||
if (r) {
|
||||
const sudo = url.searchParams.get("sudo");
|
||||
const found = repos.find((x) => x.full_name === `${r[1]}/${r[2]}`);
|
||||
// nox-builder reads the organisation's repositories through its team and the other one not at all; mesh-tools writes.
|
||||
if (!found || (sudo === "nox-builder" && r[1] !== "novox")) return send(404, { message: "not found" });
|
||||
const push = sudo === "mesh-tools";
|
||||
return send(200, { ...found, permissions: { admin: false, push, pull: true } });
|
||||
}
|
||||
send(404, { message: "not found" });
|
||||
});
|
||||
await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
|
||||
after(() => new Promise<void>((r) => server.close(() => r())));
|
||||
const { port } = server.address() as { port: number };
|
||||
const url = `http://127.0.0.1:${port}`;
|
||||
const tools = getGiteaTools(new GiteaClient(url, new ConfiguredToken("tools-token")), () => new GiteaAdmin(url, "mesh-admin", "secret"));
|
||||
const run = (args: Record<string, unknown>) => tools.find((t) => t.name === "gitea_team_rights")!.run(args) as Promise<any>;
|
||||
|
||||
test("gitea_team_rights answers every team of the organisation with its members and units, read as the administrator", async () => {
|
||||
const out = await run({});
|
||||
assert.equal(out.organisation, "novox");
|
||||
assert.deepEqual(out.teams.map((t: any) => t.team), ["builders", "mesh-tools"]);
|
||||
const builders = out.teams[0];
|
||||
assert.deepEqual(builders.members, ["nox-builder"]);
|
||||
assert.deepEqual(builders.units, { "repo.code": "read" });
|
||||
assert.equal(builders.includes_all_repositories, true);
|
||||
assert.equal(builders.read_only, true);
|
||||
assert.equal(out.teams[1].read_only, false);
|
||||
assert.ok(seen.every((s) => s.startsWith("GET ") && s.endsWith("as admin")), `only reads, as the administrator: ${seen.join("; ")}`);
|
||||
});
|
||||
|
||||
test("gitea_team_rights answers one team by name, and refuses an unknown one naming the teams there are", async () => {
|
||||
const out = await run({ team: "builders" });
|
||||
assert.deepEqual(out.teams.map((t: any) => t.team), ["builders"]);
|
||||
await assert.rejects(run({ team: "nobody" }), /no team "nobody" in novox: it holds builders, mesh-tools/);
|
||||
});
|
||||
|
||||
test("gitea_team_rights answers a user's teams and effective right on each repository: nox-builder writes nowhere", async () => {
|
||||
const out = await run({ user: "nox-builder" });
|
||||
assert.deepEqual(out.user.teams, ["builders"]);
|
||||
assert.deepEqual(out.user.repositories, { read: 1, none: 1 });
|
||||
assert.deepEqual(out.user.writes, []);
|
||||
const tools2 = await run({ user: "mesh-tools", list: true });
|
||||
assert.deepEqual(tools2.user.writes, [{ repository: "novox/mesh-catalog", permission: "write" }, { repository: "jschoubben/car-hunter", permission: "write" }]);
|
||||
assert.equal(tools2.user.every.length, 2);
|
||||
});
|
||||
|
||||
test("the tools' own token is refused the teams, which is why the administrator's login reads them", async () => {
|
||||
const res = await fetch(`${url}/api/v1/orgs/novox/teams`, { headers: { Authorization: "token tools-token" } });
|
||||
assert.equal(res.status, 403);
|
||||
});
|
||||
|
||||
test("a user the forge does not know is refused, not answered as writing nowhere", async () => {
|
||||
await assert.rejects(run({ user: "nobody" }), /no forge user nobody/);
|
||||
});
|
||||
@@ -538,7 +538,8 @@ test("the tools register once there is a way to a token, and the first call mint
|
||||
"gitea_get_file",
|
||||
"gitea_list_branches",
|
||||
"gitea_delete_branch",
|
||||
"gitea_branch_protection_get", "gitea_branch_protection_set",
|
||||
"gitea_branch_protection_get", "gitea_team_rights", "gitea_branch_protection_set", "gitea_branch_protection_set_full", "gitea_repo_settings_set_full",
|
||||
"gitea_default_branch_set", "gitea_pull_base_set",
|
||||
"gitea_note_append", "gitea_delivery_view", "gitea_commit_statuses", "gitea_open_pulls", "gitea_commit_trees", "gitea_contains",
|
||||
"gitea_list_labels", "gitea_create_label",
|
||||
"gitea_api",
|
||||
|
||||
@@ -16,7 +16,7 @@ import { join } from "node:path";
|
||||
|
||||
import { MintedToken } from "../dist/token.js";
|
||||
import { GiteaClient } from "../dist/client.js";
|
||||
import { TOOLS_ACCOUNT, TOOLS_TEAM_UNITS } 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";
|
||||
@@ -26,6 +26,19 @@ interface Account { admin: boolean; password: string; active: boolean }
|
||||
interface Token { id: number; name: string; sha1: string; user: string; scopes: string[] }
|
||||
interface Team { id: number; name: string; units: any; members: Set<string> }
|
||||
|
||||
interface ForgeRepo { owner: string; name: string; full: string; private: boolean; archived?: boolean; ownerVisibility: string; collaborators: Map<string, string> }
|
||||
const forgeRepos: ForgeRepo[] = [];
|
||||
const userRules = new Map<string, any>();
|
||||
const repoFaults = { refuse: new Set<string>(), permission: new Set<string>() };
|
||||
function addRepo(full: string, isPrivate: boolean, ownerVisibility = "public"): ForgeRepo {
|
||||
const [owner, name] = full.split("/");
|
||||
const r = { owner, name, full, private: isPrivate, ownerVisibility, collaborators: new Map<string, string>() };
|
||||
forgeRepos.push(r);
|
||||
return r;
|
||||
}
|
||||
const repoJSON = (r: ForgeRepo) => ({ full_name: r.full, name: r.name, owner: { login: r.owner, visibility: r.ownerVisibility },
|
||||
private: r.private, archived: r.archived === true });
|
||||
|
||||
function fakeForge() {
|
||||
const users = new Map<string, Account>([[ADMIN, { admin: true, password: PASSWORD, active: true }]]);
|
||||
const tokens: Token[] = [];
|
||||
@@ -61,11 +74,18 @@ function fakeForge() {
|
||||
return null;
|
||||
};
|
||||
const isAdmin = (u: string | null) => u !== null && users.get(u)?.admin === true;
|
||||
const permissionOf = (u: string | null, r: ForgeRepo): string => {
|
||||
if (isAdmin(u)) return "admin";
|
||||
if (u === r.owner) return "owner";
|
||||
if (r.owner === "novox" && teams.some((t) => t.members.has(u ?? "") && t.units.units_map?.["repo.code"] === "write")) return "write";
|
||||
if (u && r.collaborators.has(u)) return r.collaborators.get(u)!;
|
||||
return !r.private && r.ownerVisibility === "public" ? "read" : "none";
|
||||
};
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url ?? "/", "http://fake");
|
||||
const p = url.pathname.replace(/^\/api\/v1/, "");
|
||||
const me = who(req);
|
||||
const me = url.searchParams.has("sudo") && isAdmin(who(req)) ? url.searchParams.get("sudo") : who(req);
|
||||
calls.push(`${req.method} ${p} as ${me}`);
|
||||
if (me === null) return json(res, 401, { message: "unauthorized" });
|
||||
|
||||
@@ -149,6 +169,23 @@ function fakeForge() {
|
||||
if (m[2] === "feat/unguarded") return json(res, 200, { name: m[2], protected: false });
|
||||
return json(res, 404, { message: "branch does not exist" });
|
||||
}
|
||||
// A repository of a user: its rules are its administrator's (a site administrator, or a collaborator with admin).
|
||||
m = p.match(/^\/repos\/(?!novox\/)([^/]+)\/([^/]+)\/branch_protections(?:\/(.*))?$/);
|
||||
if (m) {
|
||||
const key = `${m[1]}/${m[2]}`;
|
||||
const repo = forgeRepos.find((r) => r.full === key);
|
||||
if (!repo || !["admin", "owner"].includes(permissionOf(me, repo))) return json(res, 403, { message: "user should be an owner or a collaborator with admin write of a repository" });
|
||||
if (req.method === "GET" && m[3] === undefined) return json(res, 200, userRules.has(key) ? [userRules.get(key)] : []);
|
||||
// As gitea does: a whitelist login that cannot write the repository is dropped, and the write still answered.
|
||||
const keep = (b: any) => (Array.isArray(b.merge_whitelist_usernames)
|
||||
? { ...b, merge_whitelist_usernames: b.merge_whitelist_usernames.filter((u: string) => ["write", "admin", "owner"].includes(permissionOf(u, repo))) } : b);
|
||||
if (req.method === "PATCH" && m[3] === "main") {
|
||||
if (!userRules.has(key)) return json(res, 404, { message: "branch protection does not exist" });
|
||||
Object.assign(userRules.get(key), keep(await read(req)));
|
||||
return json(res, 200, userRules.get(key));
|
||||
}
|
||||
if (req.method === "POST") { const b = keep(await read(req)); userRules.set(key, b); return json(res, 201, b); }
|
||||
}
|
||||
// gitea's reqAdmin on the whole group: reading the rules is administration too.
|
||||
m = p.match(/^\/repos\/novox\/([^/]+)\/branch_protections(?:\/(.*))?$/);
|
||||
if (m) {
|
||||
@@ -176,7 +213,37 @@ function fakeForge() {
|
||||
if (!writes) return json(res, 403, { message: "write on code required" });
|
||||
return json(res, 201, { context: (await read(req)).context, creator: { login: me } });
|
||||
}
|
||||
if (req.method === "GET" && p === "/repos/search") return json(res, 200, { data: [] });
|
||||
// Every repository of every owner, and who reads it (readall.ts): the site administrator reads all; the tools' account
|
||||
// a repository of novox through its team, a public one of a public owner, and one it is a collaborator of.
|
||||
if (req.method === "GET" && p === "/repos/search") {
|
||||
const page = Number(url.searchParams.get("page") ?? "1");
|
||||
const seen = forgeRepos.filter((r) => permissionOf(me, r) !== "none");
|
||||
return json(res, 200, { data: page === 1 ? seen.map(repoJSON) : [] });
|
||||
}
|
||||
m = p.match(/^\/repos\/([^/]+)\/([^/]+)(?:\/collaborators\/([^/]+)(\/permission)?)?$/);
|
||||
if (m) {
|
||||
const repo = forgeRepos.find((r) => r.owner === decodeURIComponent(m![1]) && r.name === decodeURIComponent(m![2]));
|
||||
if (repo && req.method === "GET" && !m[3] && url.searchParams.has("sudo") && repoFaults.permission.has(repo.full)) return json(res, 500, { message: "the forge is not well" });
|
||||
if (!repo || permissionOf(me, repo) === "none") return json(res, 404, { message: "repository does not exist" });
|
||||
if (req.method === "GET" && !m[3]) {
|
||||
const mine = permissionOf(me, repo);
|
||||
return json(res, 200, { ...repoJSON(repo), permissions: { pull: true, push: ["write", "admin", "owner"].includes(mine), admin: ["admin", "owner"].includes(mine) } });
|
||||
}
|
||||
const user = decodeURIComponent(m[3] ?? "");
|
||||
if (req.method === "GET" && m[4]) {
|
||||
if (repoFaults.permission.has(repo.full)) return json(res, 500, { message: "the forge is not well" });
|
||||
if (!isAdmin(me) && me !== user) return json(res, 403, { message: "only the user or an administrator" });
|
||||
// As gitea answers: the collaborator's own permission, not what a team gives the account.
|
||||
return json(res, 200, { permission: repo.collaborators.get(user) ?? "read" });
|
||||
}
|
||||
if (req.method === "PUT" && !m[4]) {
|
||||
// gitea's reqAdmin: the repository's administration, which on a repository of another user the site administrator alone holds.
|
||||
if (!isAdmin(me) && me !== repo.owner) return json(res, 403, { message: "user should be an owner or a collaborator with admin write of a repository" });
|
||||
if (repoFaults.refuse.has(repo.full)) return json(res, 422, { message: "the forge would not have it" });
|
||||
repo.collaborators.set(user, (await read(req))?.permission ?? "write");
|
||||
return json(res, 204, null);
|
||||
}
|
||||
}
|
||||
return json(res, 404, { message: "no such route in the fake" });
|
||||
});
|
||||
return new Promise<{ url: string; users: typeof users; tokens: typeof tokens; teams: typeof teams; calls: string[]; mainRules: typeof mainRules;
|
||||
@@ -402,3 +469,226 @@ test("a mint with keep-main-rules false (the default) writes no branch protectio
|
||||
const writes = forge.calls.slice(from).filter((c: string) => /branch_protections/.test(c) && !/^GET /.test(c) && !/^(GET|HEAD) /.test(c));
|
||||
assert.deepEqual(writes, []);
|
||||
});
|
||||
|
||||
// The mesh's accounts read every repository (readall.ts; the operator, 2026-10-26): jschoubben/car-hunter and
|
||||
// jschoubben/snake, private repositories of a user, answered the tools 404 while that user's public ones were seen; and
|
||||
// once the tools' account read them, the build modules' fetch as nox-builder still could not.
|
||||
|
||||
test("the start's pass gives each of the mesh's accounts read, as a collaborator that reads only, on each repository it could not read, and on no other; an account the forge does not hold is said, not refused", async () => {
|
||||
const env = await environment();
|
||||
await fresh(env).listRepos(); // the account and its team exist, as after any mint
|
||||
forge.users.set(BUILDER_ACCOUNT, { admin: false, password: "unused", active: true }); // made by the forge-account provider
|
||||
forge.users.delete(AGENT_ACCOUNT); // no machine's key asked for it yet
|
||||
forgeRepos.splice(0);
|
||||
addRepo("novox/hq", true);
|
||||
addRepo("jschoubben/txt-game", false);
|
||||
addRepo("jschoubben/car-hunter", true);
|
||||
addRepo("jschoubben/snake", true);
|
||||
addRepo("shy/open", false, "limited");
|
||||
addRepo("elsewhere/secret", true);
|
||||
const { GiteaAdmin } = await import("../dist/client.js");
|
||||
const { ReadAll, readAllTools } = await import("../dist/readall.js");
|
||||
const lines: string[] = [];
|
||||
const readAll = new ReadAll(GiteaAdmin.fromEnv(env), meshAccounts(), (l: string) => lines.push(l));
|
||||
assert.deepEqual(readAll.accounts, [TOOLS_ACCOUNT, BUILDER_ACCOUNT, AGENT_ACCOUNT]);
|
||||
const [unreadableTool, grantTool] = readAllTools(() => readAll);
|
||||
|
||||
// What the tool says before, per account: the four the tools' account cannot read, each with why; the builders'
|
||||
// account, in no write team here, also not novox/hq; nox-agent not on the forge.
|
||||
const before = await unreadableTool.run({}) as any;
|
||||
assert.equal(before.checked, 6);
|
||||
const of = (account: string) => before.unreadable.filter((u: any) => u.account === account).map((u: any) => u.repository).sort();
|
||||
assert.deepEqual(of(TOOLS_ACCOUNT), ["elsewhere/secret", "jschoubben/car-hunter", "jschoubben/snake", "shy/open"]);
|
||||
assert.deepEqual(of(BUILDER_ACCOUNT), ["elsewhere/secret", "jschoubben/car-hunter", "jschoubben/snake", "novox/hq", "shy/open"]);
|
||||
assert.deepEqual(before.accounts, [{ account: TOOLS_ACCOUNT, unreadable: 4 }, { account: BUILDER_ACCOUNT, unreadable: 5 }]);
|
||||
assert.deepEqual(before.absent, [AGENT_ACCOUNT]);
|
||||
assert.match(before.unreadable.find((u: any) => u.account === BUILDER_ACCOUNT && u.repository === "jschoubben/car-hunter").why,
|
||||
/a private repository of jschoubben, on which nox-builder is no collaborator/);
|
||||
const builderOnly = await unreadableTool.run({ accounts: [BUILDER_ACCOUNT] }) as any;
|
||||
assert.deepEqual(builderOnly.accounts, [{ account: BUILDER_ACCOUNT, unreadable: 5 }]);
|
||||
await assert.rejects(unreadableTool.run({ accounts: ["someone"] }), /not one of the mesh's accounts/);
|
||||
assert.match(before.unreadable.find((u: any) => u.repository === "jschoubben/car-hunter").why,
|
||||
/a private repository of jschoubben, on which mesh-tools is no collaborator/);
|
||||
assert.match(before.unreadable.find((u: any) => u.repository === "shy/open").why, /visibility is limited/);
|
||||
assert.deepEqual((await fresh(env).listAllRepos()).map((r) => r.full_name).sort(), ["jschoubben/txt-game", "novox/hq"]);
|
||||
|
||||
// The account's own token cannot grant itself: the forge asks the repository's administration (the reason for the admin login).
|
||||
const own = (await readFile(join(env.MESH_GITEA_STATE_DIR, "token"), "utf8")).trim();
|
||||
const self = await fetch(`${forge.url}/api/v1/repos/jschoubben/car-hunter/collaborators/${TOOLS_ACCOUNT}`,
|
||||
{ method: "PUT", headers: { Authorization: `token ${own}`, "Content-Type": "application/json" }, body: JSON.stringify({ permission: "read" }) });
|
||||
assert.equal(self.status, 404, "a repository the account cannot read is not even there for it");
|
||||
|
||||
const from = forge.calls.length;
|
||||
const r = await readAll.atStart();
|
||||
const grantedTo = (account: string) => r.granted.filter((x: any) => x.account === account).map((x: any) => x.repository).sort();
|
||||
assert.deepEqual(grantedTo(TOOLS_ACCOUNT), ["elsewhere/secret", "jschoubben/car-hunter", "jschoubben/snake", "shy/open"]);
|
||||
assert.deepEqual(grantedTo(BUILDER_ACCOUNT), ["elsewhere/secret", "jschoubben/car-hunter", "jschoubben/snake", "novox/hq", "shy/open"]);
|
||||
assert.deepEqual(r.refused, []);
|
||||
assert.deepEqual(r.absent, [AGENT_ACCOUNT]);
|
||||
const puts = forge.calls.slice(from).filter((c) => c.startsWith("PUT "));
|
||||
assert.equal(puts.length, 9, "the repositories each read already were left alone, and the absent account was asked nothing");
|
||||
assert.ok(!puts.some((c) => c.includes(AGENT_ACCOUNT)));
|
||||
assert.ok(puts.every((c) => c.endsWith(` as ${ADMIN}`)), "only the administrator's login grants");
|
||||
for (const repo of forgeRepos.filter((x) => x.collaborators.size)) {
|
||||
for (const [account, permission] of repo.collaborators) assert.equal(permission, "read", `${account} on ${repo.full} reads only`);
|
||||
}
|
||||
assert.equal(forgeRepos.find((x) => x.full === "jschoubben/car-hunter")!.collaborators.get(BUILDER_ACCOUNT), "read");
|
||||
assert.match(lines.join("\n"), /6 looked at; mesh-tools 4 granted read now .*; nox-builder 5 granted read now .*; not on the forge yet, so not granted: nox-agent/);
|
||||
|
||||
// The account made later is granted by hand (or at the next pass), its rows like any other.
|
||||
forge.users.set(AGENT_ACCOUNT, { admin: false, password: "unused", active: true });
|
||||
const agent = await grantTool.run({ accounts: [AGENT_ACCOUNT], repositories: ["jschoubben/car-hunter"] }) as any;
|
||||
assert.deepEqual(agent.granted, [{ account: AGENT_ACCOUNT, repository: "jschoubben/car-hunter" }]);
|
||||
assert.deepEqual(agent.absent, []);
|
||||
forge.users.delete(AGENT_ACCOUNT);
|
||||
|
||||
// After: the account's own token sees every repository, and the tool names none.
|
||||
assert.equal((await fresh(env).listAllRepos()).length, 6);
|
||||
assert.deepEqual(((await unreadableTool.run({})) as any).unreadable, []);
|
||||
// Granting again asks nothing of the forge but reads.
|
||||
const again = forge.calls.length;
|
||||
assert.deepEqual(((await grantTool.run({})) as any).granted, []);
|
||||
assert.ok(forge.calls.slice(again).every((c) => c.startsWith("GET ")));
|
||||
});
|
||||
|
||||
test("on the forge's events: a repository made is granted read at once, a known readable one asks nothing, a refusal is said, not thrown", async () => {
|
||||
const env = await environment();
|
||||
await fresh(env).listRepos();
|
||||
forgeRepos.splice(0);
|
||||
addRepo("jschoubben/txt-game", false);
|
||||
const { GiteaAdmin } = await import("../dist/client.js");
|
||||
const { ReadAll, readAllTools } = await import("../dist/readall.js");
|
||||
const lines: string[] = [];
|
||||
forge.users.set(BUILDER_ACCOUNT, { admin: false, password: "unused", active: true });
|
||||
const readAll = new ReadAll(GiteaAdmin.fromEnv(env), [TOOLS_ACCOUNT, BUILDER_ACCOUNT], (l: string) => lines.push(l));
|
||||
await readAll.atStart();
|
||||
|
||||
const cases: { name: string; event: string; repo: string; make?: boolean; refuse?: boolean; unanswered?: boolean; asks: boolean; reads: string }[] = [
|
||||
{ name: "a repository made", event: "forge.repository.created", repo: "jschoubben/new-private", make: true, asks: true, reads: "read" },
|
||||
{ name: "a fork made", event: "forge.fork", repo: "someone/fork", make: true, asks: true, reads: "read" },
|
||||
{ name: "a push to a repository seen readable", event: "forge.push", repo: "jschoubben/txt-game", asks: false, reads: "read" },
|
||||
{ name: "a push to a private repository never seen", event: "forge.push", repo: "jschoubben/quiet", make: true, asks: true, reads: "read" },
|
||||
{ name: "a repository the forge refuses the grant on", event: "forge.repository.created", repo: "jschoubben/stuck", make: true, refuse: true, asks: true, reads: "none" },
|
||||
{ name: "a repository whose permission the forge will not answer", event: "forge.repository.created", repo: "jschoubben/mute", make: true, unanswered: true, asks: true, reads: "none" },
|
||||
];
|
||||
for (const c of cases) {
|
||||
if (c.make) addRepo(c.repo, true);
|
||||
if (c.refuse) repoFaults.refuse.add(c.repo);
|
||||
if (c.unanswered) repoFaults.permission.add(c.repo);
|
||||
const from = forge.calls.length;
|
||||
const [owner, repo] = c.repo.split("/");
|
||||
await readAll.onEvent({ name: c.event, body: { owner, repo } });
|
||||
assert.equal(forge.calls.length > from, c.asks, `${c.name}: asks the forge`);
|
||||
for (const account of [TOOLS_ACCOUNT, BUILDER_ACCOUNT]) {
|
||||
assert.equal(forgeRepos.find((x) => x.full === c.repo)!.collaborators.get(account) ?? (c.make ? "none" : "read"), c.reads, `${c.name}: ${account}`);
|
||||
}
|
||||
}
|
||||
assert.match(lines.join("\n"), /mesh-tools could not be given read on jschoubben\/stuck — .*422/);
|
||||
assert.match(lines.join("\n"), /nox-builder was given read on someone\/fork, on the forge's forge.fork/);
|
||||
const named = await (readAllTools(() => readAll)[1].run({ repositories: ["jschoubben/stuck"] })) as any;
|
||||
assert.deepEqual(named.refused.map((x: any) => `${x.account} ${x.repository}`), [`${TOOLS_ACCOUNT} jschoubben/stuck`, `${BUILDER_ACCOUNT} jschoubben/stuck`]);
|
||||
await assert.rejects(readAllTools(() => readAll)[1].run({ repositories: ["no-slash"] }), /not a repository/);
|
||||
assert.match(lines.join("\n"), /could not be given read on jschoubben\/mute — its permission could not be read — .*500/);
|
||||
// A whole pass goes past the repository it cannot judge: every other one is still granted, and the listing names it.
|
||||
addRepo("jschoubben/after-mute", true);
|
||||
const pass = await readAll.grant();
|
||||
assert.ok(pass.granted.some((x: any) => x.repository === "jschoubben/after-mute" && x.account === BUILDER_ACCOUNT), "the pass did not stop at the repository it could not judge");
|
||||
assert.ok(pass.refused.some((x: any) => x.repository === "jschoubben/mute" && /permission could not be read/.test(x.why)));
|
||||
const listed = await readAllTools(() => readAll)[0].run({}) as any;
|
||||
assert.equal(listed.unreadable.find((u: any) => u.repository === "jschoubben/mute").permission, "unknown");
|
||||
repoFaults.refuse.clear();
|
||||
repoFaults.permission.clear();
|
||||
});
|
||||
|
||||
// The merging account writes every repository that builds a registered module, and no other (writes.ts; the operator,
|
||||
// 2026-10-26; issue 1945): car-hunter #2 was approved and its commit status refused 403, mesh-tools reading only there.
|
||||
|
||||
test("the merging account is given write on each repository the indexer's modules name, read back and said; the operator's other repositories stay read-only; the tools say both", async () => {
|
||||
const env = await environment();
|
||||
const client = fresh(env);
|
||||
await client.listRepos(); // the account and its team exist, as after any mint
|
||||
forgeRepos.splice(0);
|
||||
addRepo("novox/hq", true);
|
||||
for (const full of ["jschoubben/car-hunter", "jschoubben/snake", "jschoubben/dotfiles"]) addRepo(full, true).collaborators.set(TOOLS_ACCOUNT, "read");
|
||||
const { GiteaAdmin } = await import("../dist/client.js");
|
||||
const { DeliveredWrites, writeTools, WRITABLE_EVENT } = await import("../dist/writes.js");
|
||||
const lines: string[] = [];
|
||||
const said: { type: string; body: any }[] = [];
|
||||
const writes = new DeliveredWrites(GiteaAdmin.fromEnv(env), TOOLS_ACCOUNT, "novox", (l: string) => lines.push(l),
|
||||
async (type: string, body: unknown) => { said.push({ type, body }); });
|
||||
const [unwritableTool, grantTool] = writeTools(() => writes);
|
||||
await assert.rejects(grantTool.run({}), /not known yet/, "nothing is granted before the indexer's modules were read");
|
||||
assert.equal((await client.getRepo("jschoubben", "car-hunter")).writes, false, "the statuses' answer says the account cannot write");
|
||||
|
||||
// 1945/AC1: the watch hands the indexer's modules; a user's repository a module builds from is made writable, once.
|
||||
await writes.take({ key: "car-hunter", op: "put", value: { v: 1, repository: "jschoubben/car-hunter", commit: "c" }, current: true });
|
||||
await writes.take({ key: "gitea", op: "put", value: { v: 1, repository: "novox/hq", commit: "c" }, current: true });
|
||||
await writes.take({ key: "broken", op: "put", value: { v: 1, repository: "no-slash" }, current: true });
|
||||
const from = forge.calls.length;
|
||||
await writes.take({ key: "car-hunter-2", op: "put", value: { v: 1, repository: "jschoubben/car-hunter" } });
|
||||
assert.equal(forge.calls.length, from, "a repository already written asks nothing more");
|
||||
const repo = (full: string) => forgeRepos.find((x) => x.full === full)!;
|
||||
assert.equal(repo("jschoubben/car-hunter").collaborators.get(TOOLS_ACCOUNT), "write", "a user's repository is written, not administered");
|
||||
assert.equal(userRules.has("jschoubben/car-hunter"), false, "a repository with no rule for main is given none");
|
||||
assert.equal(repo("novox/hq").collaborators.size, 0, "the organisation's repository is written through its team, no collaborator");
|
||||
assert.equal((await client.getRepo("jschoubben", "car-hunter")).writes, true);
|
||||
assert.deepEqual(said, [{ type: WRITABLE_EVENT, body: { owner: "jschoubben", repo: "car-hunter", account: TOOLS_ACCOUNT } }]);
|
||||
assert.match(lines.join("\n"), /mesh-tools was given write on jschoubben\/car-hunter, which builds the registered module car-hunter/);
|
||||
assert.match(lines.join("\n"), /broken names no repository/);
|
||||
|
||||
// 1945/AC2: a delivered repository the forge refuses is said, in its words, by the listing and the grant; the operator's
|
||||
// repository no module builds from is left read-only, named with why, and a grant naming it asks the forge nothing.
|
||||
repoFaults.refuse.add("jschoubben/snake");
|
||||
await writes.take({ key: "snake", op: "put", value: { v: 1, repository: "jschoubben/snake" } });
|
||||
assert.match(lines.join("\n"), /could not be given write on jschoubben\/snake, which builds snake — .*422/);
|
||||
const listed = await unwritableTool.run({}) as any;
|
||||
assert.deepEqual(listed.unwritable.map((u: any) => [u.repository, u.permission]), [["jschoubben/snake", "read"]]);
|
||||
assert.match(listed.unwritable[0].why, /builds snake.*gitea_grant_write/);
|
||||
const all = await grantTool.run({}) as any;
|
||||
assert.deepEqual(all.delivered, ["jschoubben/car-hunter", "jschoubben/snake", "novox/hq"]);
|
||||
assert.deepEqual(all.already, ["jschoubben/car-hunter", "novox/hq"]);
|
||||
assert.deepEqual(all.granted, []);
|
||||
assert.match(all.refused.find((x: any) => x.repository === "jschoubben/snake").why, /422.*would not have it/);
|
||||
assert.deepEqual(all.left_read_only.map((x: any) => x.repository), ["jschoubben/dotfiles"]);
|
||||
assert.match(all.left_read_only[0].why, /builds no registered module/);
|
||||
const puts = forge.calls.length;
|
||||
const named = await grantTool.run({ repositories: ["jschoubben/dotfiles"] }) as any;
|
||||
assert.deepEqual([named.granted, named.left_read_only.map((x: any) => x.repository)], [[], ["jschoubben/dotfiles"]]);
|
||||
assert.ok(!forge.calls.slice(puts).some((c) => c.startsWith("PUT ")), "no write is asked on a repository no module builds from");
|
||||
assert.equal(repo("jschoubben/dotfiles").collaborators.get(TOOLS_ACCOUNT), "read");
|
||||
repoFaults.refuse.clear();
|
||||
// The operator's rule is left as set (the operator, 2026-10-26: "Write, rule untouched"): only mesh-tools is added to its
|
||||
// merge whitelist; a status the mesh does not set and required approvals are said as blocking the mesh's merge, unchanged.
|
||||
const operators = { rule_name: "main", enable_push: true, enable_merge_whitelist: true, merge_whitelist_usernames: ["jschoubben"],
|
||||
enable_status_check: true, status_check_contexts: ["mesh/check", "mesh/merge-gate", "ci/external"], required_approvals: 2,
|
||||
block_admin_merge_override: false, require_signed_commits: true, block_on_outdated_branch: true, block_on_rejected_reviews: true };
|
||||
userRules.set("jschoubben/snake", structuredClone(operators));
|
||||
const after = await grantTool.run({ repositories: ["jschoubben/snake"] }) as any;
|
||||
assert.deepEqual(after.granted, ["jschoubben/snake"]);
|
||||
assert.equal(repo("jschoubben/snake").collaborators.get(TOOLS_ACCOUNT), "write");
|
||||
assert.deepEqual(userRules.get("jschoubben/snake"), { ...operators, merge_whitelist_usernames: ["jschoubben", TOOLS_ACCOUNT] }, "only the whitelist changed");
|
||||
assert.deepEqual(after.merge_whitelist, [{ repository: "jschoubben/snake", changed: true, listed: ["jschoubben", TOOLS_ACCOUNT] }]);
|
||||
const blockedWhy = "writable, but main's rule blocks the mesh's merge: it requires the status ci/external, which the mesh does not set; " +
|
||||
"it requires 2 approving reviews on the forge, which the gate does not give; it requires signed commits, which the mesh does not make; " +
|
||||
"it refuses a branch behind main, which the mesh does not bring up to date; " +
|
||||
"it refuses a merge after a review asked for changes, which the gate does not clear";
|
||||
assert.deepEqual(after.merge_blocked, [{ repository: "jschoubben/snake", why: blockedWhy }]);
|
||||
const again = await unwritableTool.run({}) as any;
|
||||
assert.deepEqual([again.unwritable, again.merge_blocked], [[], [{ repository: "jschoubben/snake", why: blockedWhy }]]);
|
||||
const writesBefore = forge.calls.filter((c) => /^PATCH .*branch_protections/.test(c)).length;
|
||||
assert.deepEqual(((await grantTool.run({ repositories: ["jschoubben/snake"] })) as any).merge_whitelist[0].changed, false);
|
||||
assert.equal(forge.calls.filter((c) => /^PATCH .*branch_protections/.test(c)).length, writesBefore, "a listed account is not written again");
|
||||
// The mesh's own statuses never block: a rule requiring only them, with mesh-tools listed, is not reported.
|
||||
userRules.set("jschoubben/snake", { rule_name: "main", enable_merge_whitelist: true, merge_whitelist_usernames: [TOOLS_ACCOUNT],
|
||||
enable_status_check: true, status_check_contexts: ["mesh/check", "mesh/merge-gate", "mesh/repo-check"] });
|
||||
assert.deepEqual(((await unwritableTool.run({})) as any).merge_blocked, []);
|
||||
assert.deepEqual(((await grantTool.run({ repositories: ["jschoubben/snake"] })) as any).merge_blocked, []);
|
||||
// The forge drops a whitelist login that cannot write and still answers 200: the whitelist is read back and that fails loud.
|
||||
userRules.set("jschoubben/dotfiles", { rule_name: "main", enable_merge_whitelist: true, merge_whitelist_usernames: ["jschoubben"] });
|
||||
await assert.rejects(GiteaAdmin.fromEnv(env).ensureMergerListed("jschoubben", "dotfiles", TOOLS_ACCOUNT),
|
||||
/mesh-tools was added to the merge whitelist of main's rule main of jschoubben\/dotfiles, but the forge holds \[jschoubben\]: it drops a login that cannot write/);
|
||||
userRules.delete("jschoubben/dotfiles");
|
||||
// A module gone from the indexer leaves its repository out of the delivered list.
|
||||
await writes.take({ key: "snake", op: "delete" });
|
||||
assert.deepEqual(writes.delivered(), ["jschoubben/car-hunter", "novox/hq"]);
|
||||
});
|
||||
|
||||
@@ -34,6 +34,9 @@ test("both statuses say the head and the trunk commit the verdict judged, first,
|
||||
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", () => {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mock, test } from "node:test";
|
||||
import type { GiteaPull } from "../client.ts";
|
||||
|
||||
// **No poll of the forge** (issue 1422, notes 10 and 11): the forge's events announce heads and merges; a pass runs once at
|
||||
// the start and when the receiver listens.
|
||||
|
||||
const edited = (base: string) => ({ name: "forge.pull-request.edited", body: { owner: "novox", repo: "hq", number: 7, base, merged: false } });
|
||||
|
||||
function deps() {
|
||||
const calls: string[] = [];
|
||||
return { calls, d: {
|
||||
getRepo: async () => ({ r: 1 }),
|
||||
getPull: async () => ({ state: "open", base: "trunk" }) as any,
|
||||
announceHead: async () => { calls.push("head"); },
|
||||
announceMerge: async () => { calls.push("merge"); },
|
||||
announceClosed: async () => { calls.push("closed"); },
|
||||
siblingMoves: async () => { calls.push("siblings"); },
|
||||
} };
|
||||
}
|
||||
|
||||
test("an edited event announces the head into the base it now has, without a poll", async () => {
|
||||
const { actOn } = await import("../watch.ts");
|
||||
const { calls, d } = deps();
|
||||
await actOn(edited("trunk"), d);
|
||||
assert.deepEqual(calls, ["head"]);
|
||||
});
|
||||
|
||||
test("a close without a merge is announced by its event, a merged close is left to the merge event", async () => {
|
||||
const { actOn } = await import("../watch.ts");
|
||||
const { calls, d } = deps();
|
||||
await actOn({ name: "forge.pull-request.closed", body: { owner: "novox", repo: "hq", number: 7, merged: false } }, d);
|
||||
assert.deepEqual(calls, ["closed", "siblings"]);
|
||||
calls.length = 0;
|
||||
await actOn({ name: "forge.pull-request.closed", body: { owner: "novox", repo: "hq", number: 7, merged: true } }, d);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test("no timer polls after the start: one pass ends and nothing is scheduled; a failed one is asked again", async () => {
|
||||
const { startPass } = await import("../watch.ts");
|
||||
mock.timers.enable({ apis: ["setTimeout", "setInterval"] });
|
||||
try {
|
||||
let runs = 0;
|
||||
await startPass(async () => { runs++; }, { retryMs: 60_000, log: () => {} });
|
||||
mock.timers.tick(24 * 3600_000);
|
||||
assert.equal(runs, 1, "a day later the forge was not asked again");
|
||||
let tries = 0;
|
||||
const retried = startPass(async () => { if (++tries < 2) throw new Error("forge down"); }, { retryMs: 60_000, log: () => {} });
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
mock.timers.tick(60_000);
|
||||
await retried;
|
||||
assert.equal(tries, 2);
|
||||
} finally {
|
||||
mock.timers.reset();
|
||||
}
|
||||
});
|
||||
|
||||
test("a start pass announces a head nobody announced, and only remembers one the forge shows a check of", async () => {
|
||||
const { announceNewHeads } = await import("../watch.ts");
|
||||
const pull = (n: number, sha: string): GiteaPull => ({ number: n, state: "open", head_sha: sha, base: "main",
|
||||
updated_at: new Date().toISOString() } as unknown as GiteaPull);
|
||||
const announced: number[] = [], said: string[] = [];
|
||||
const heads: Record<string, string> = {};
|
||||
await announceNewHeads("novox/hq", [pull(1, "a".repeat(40)), pull(2, "b".repeat(40))], heads, false,
|
||||
new Date(Date.now() - 24 * 3600_000).toISOString(), {
|
||||
alreadyChecked: async (p) => p.number === 2,
|
||||
announceHead: async (p) => { announced.push(p.number); return true; },
|
||||
missed: (w) => said.push(w),
|
||||
});
|
||||
assert.deepEqual(announced, [1]);
|
||||
assert.equal(heads["novox/hq#2@main"], "b".repeat(40), "the checked head is remembered, not announced");
|
||||
assert.equal(said.length, 1);
|
||||
});
|
||||
@@ -251,7 +251,8 @@ test("a forge that never delivers is said loudly once, and unhealthy, when the p
|
||||
assert.equal(r.healthy, true);
|
||||
assert.equal(r.verified.count, 1);
|
||||
assert.equal(r.verified.last.event, "push");
|
||||
assert.equal(r.polls.every_seconds, 300);
|
||||
assert.equal(r.polls.every_seconds, null);
|
||||
assert.equal(r.polls.hearing, true);
|
||||
assert.match(said.join("\n"), /heard again/);
|
||||
});
|
||||
|
||||
@@ -314,24 +315,19 @@ test("without a receiver by configuration the polls alone watch, healthy; a rece
|
||||
assert.match(r.why, /403/);
|
||||
});
|
||||
|
||||
test("the polls look every 30 s until events are heard, every five minutes while they are, and 30 s again on a miss", async () => {
|
||||
test("nothing is paced: the report says no timer asks the forge, and a miss is still counted and said", async () => {
|
||||
const { Pace } = await import("../webhook.ts");
|
||||
let now = 1_000_000;
|
||||
const said: string[] = [];
|
||||
const pace = new Pace(() => now, (l) => said.push(l));
|
||||
assert.equal(pace.every(), 30_000, "no delivery yet");
|
||||
pace.heard();
|
||||
assert.equal(pace.every(), 300_000, "a delivery heard");
|
||||
now += 11 * 60_000;
|
||||
assert.equal(pace.every(), 30_000, "ten minutes without a delivery");
|
||||
assert.equal((pace as any).every, undefined, "no pace to ask");
|
||||
assert.equal((pace.report() as any).polls.every_seconds, null);
|
||||
pace.heard();
|
||||
assert.equal((pace.report() as any).polls.hearing, true);
|
||||
now += 1000;
|
||||
pace.miss("novox/hq#7 at aaaaaaaa");
|
||||
assert.equal(pace.every(), 30_000, "the poll found what no event said");
|
||||
assert.equal((pace.report() as any).polls.hearing, false, "the pass found what no event said");
|
||||
assert.match(said.join("\n"), /MISSED novox\/hq#7/);
|
||||
now += 1000;
|
||||
pace.heard();
|
||||
assert.equal(pace.every(), 300_000, "the next delivery");
|
||||
});
|
||||
|
||||
// Issue 1079: a pull request's edit event carries its description, so mesh-delivery reads a Reviewed-by line written after
|
||||
|
||||
@@ -22,6 +22,15 @@
|
||||
import { keepMainRules } from "./mainrules.js";
|
||||
|
||||
export const TOOLS_ACCOUNT = "mesh-tools";
|
||||
/** The build modules' account, whose keys fetch over ssh (internal/forgeaccount BuilderUser, ADR 0466 §3). */
|
||||
export const BUILDER_ACCOUNT = "nox-builder";
|
||||
/** The machines' agents' account, whose keys ssh-client reports (internal/forgeaccount AgentUser, ADR 0410 §1). */
|
||||
export const AGENT_ACCOUNT = "nox-agent";
|
||||
/** Every forge account the mesh's own parts use: each reads every repository on the forge (readall.ts; the operator,
|
||||
* 2026-10-26). internal/forgeaccount's test holds the two Go names to its own constants. */
|
||||
export function meshAccounts(tools: string = TOOLS_ACCOUNT): string[] {
|
||||
return [tools, BUILDER_ACCOUNT, AGENT_ACCOUNT];
|
||||
}
|
||||
/** The organisation whose repositories are the mesh's, and the team through which the tools' account writes there. */
|
||||
export const TOOLS_ORG = "novox";
|
||||
export const TOOLS_TEAM = "mesh-tools";
|
||||
|
||||
+193
-10
@@ -11,6 +11,9 @@
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { emit } from "@novox/mesh-sdk/events";
|
||||
import { GiteaAdmin, GiteaClient, type BranchProtection } from "../client.js";
|
||||
import { readTeamRights } from "../teamrights.js";
|
||||
import { state } from "@novox/mesh-sdk/state";
|
||||
import { PROTECTION_FIELDS, REPO_FIELDS, alsoChanged, forcePushToTrunk, fullProtectionBody, sourceFromBus, trunksOf, type SourceRead, givenFields, trunkRule, unapplied } from "../protectfull.js";
|
||||
import { appendNote, run, viewBody, viewComment } from "../delivery.js";
|
||||
import { containedFrom, isCommit, mergeableUnder, newestByContext } from "../checks.js";
|
||||
import {
|
||||
@@ -76,13 +79,13 @@ function namedPaths(raw: unknown): Record<string, string[]> | undefined {
|
||||
return out;
|
||||
}
|
||||
|
||||
export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefinition[] {
|
||||
return namesChecked(allGiteaTools(gitea, admin));
|
||||
export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin, source: SourceRead = sourceFromBus(state)): ToolDefinition[] {
|
||||
return namesChecked(allGiteaTools(gitea, admin, source));
|
||||
}
|
||||
|
||||
// gitea_merge_pull_request is served from the watcher's bundle (merge.ts, index.ts), beside the merge check's one
|
||||
// writer (novox/hq issue 456, ADR 0323).
|
||||
function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefinition[] {
|
||||
function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined, source: SourceRead): ToolDefinition[] {
|
||||
return [
|
||||
// ---- Repositories ----
|
||||
{
|
||||
@@ -374,7 +377,7 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
|
||||
// ---- Contents and branches ----
|
||||
{
|
||||
name: "gitea_get_file",
|
||||
description: "One file's contents from a repository, decoded, at a branch, tag or commit (default the repository's default branch).",
|
||||
description: "One file's contents from a repository, decoded, at a branch, tag or commit (default the repository's default branch). Refuses a file over 512 KiB, which a tool answer cannot carry: read it from a clone.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
@@ -419,14 +422,35 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
|
||||
},
|
||||
run: async (args) => {
|
||||
const owner = String(args.owner), repo = String(args.repo);
|
||||
// The one read made as the forge's administrator (novox/hq ADR 0375, issue 772): the rules' list is the
|
||||
// repository administrator's, which the tools' account is not. No other tool uses this account.
|
||||
// Read as the forge's administrator (novox/hq ADR 0375, issue 772): the rules' list is the repository
|
||||
// administrator's, which the tools' account is not. gitea_team_rights reads, and gitea_branch_protection_set_full
|
||||
// and gitea_repo_settings_set_full write, with the same account.
|
||||
const rules = await (admin ? admin() : gitea).branchProtections(owner, repo);
|
||||
if (!args.branch) return { rules: rules.map(summarised) };
|
||||
const rule = rules.find((p) => (p.rule_name ?? p.branch_name) === String(args.branch)) ?? null;
|
||||
return { branch: String(args.branch), rule: rule ? summarised(rule) : null };
|
||||
},
|
||||
},
|
||||
// ---- Forge teams (issue 1563) ----
|
||||
{
|
||||
name: "gitea_team_rights",
|
||||
description: "Read-only: the forge's teams of an organisation, who is in each (members) and what each may do — its permission, its units read, write or admin per unit (code, issues, pull requests), whether it covers all repositories, and whether it is read-only. Give a user to see which teams it is in and its effective right (read, write, admin) on every repository, with those it can write listed: the check that a build key's forge user (nox-builder, team builders) reads and pushes nothing. Forge team members and rights; GET only, no secret answered.",
|
||||
input: {
|
||||
org: { type: "string", description: "the organisation; novox when not given" },
|
||||
team: { type: "string", description: "one team by name; every team of the organisation when not given" },
|
||||
user: { type: "string", description: "a forge user: its teams and effective right on each repository" },
|
||||
repositories: { type: "array", items: { type: "string" }, description: "owner/name of the repositories to look at for the user; every repository when not given" },
|
||||
list: { type: "boolean", description: "with a user, list the right on every repository, not only those above read" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const strings = Array.isArray(args.repositories) ? args.repositories.map(String) : undefined;
|
||||
// The second read made as the forge's administrator (ADR 0375's precedent, issue 1563): the tools' token lacks
|
||||
// read:organization, so a team is not theirs to read. GET only.
|
||||
return readTeamRights(admin ? admin() : (() => { throw new Error("the forge's administrator login is not set: a team is read with it"); })(),
|
||||
String(args.org || "novox"), { team: args.team ? String(args.team) : undefined, user: args.user ? String(args.user) : undefined,
|
||||
repositories: strings, list: args.list === true });
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gitea_branch_protection_set",
|
||||
description: "Protect a branch. Never weaker (novox/hq ADR 0266): a change that lets a person push directly or lets an administrator merge past the rule is refused, and on main so is a change that drops the merge whitelist (only mesh-tools merges there) or widens it (novox/hq ADR 0398 §2, issue 948) — that is the operator's, at the forge. The forge requires no status to merge (ADR 0534): the rule written requires none, and one the rule required is taken off; the checks are judged by mesh-delivery and the merge tool. Edits the branch's rule (everything else kept unless given) or creates one, which refuses direct pushes unless push=true. Answers the rule before and after.",
|
||||
@@ -456,6 +480,154 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "gitea_branch_protection_set_full",
|
||||
description: "Set any field of a branch protection rule — protect a branch as a trunk, set its merge whitelist (users and teams), " +
|
||||
"push and push whitelist, force push and its allowlist, the bypass allowlist, required status checks, required approvals and " +
|
||||
"the approvals whitelist, block on rejected reviews, block on an outdated branch, dismiss stale approvals, signed commits, " +
|
||||
"protected and unprotected file patterns, and the administrator's merge override (block_admin_merge_override): the 32 " +
|
||||
"fields gitea 1.27.3's API edits, by their own names. Only the fields given change; a branch with no rule gets one. " +
|
||||
"enable_push_whitelist and push_whitelist_deploy_keys need enable_push: true in the same call, the force-push allowlist " +
|
||||
"flags enable_force_push: true (the forge ignores them otherwise). The rule is read back after the write: a field the " +
|
||||
"forge did not apply fails the call, naming it; also_changed names what the forge changed beside the call. Generic: it " +
|
||||
"tightens or loosens as told (the operator, 2026-10-28), so a reason (why) is required and logged; but a call that lets a force-push onto a rule covering a trunk is refused, nothing sent, until the module can ask the operator's Approve for it. A repository's trunks, the branches the mesh installs from, are read from mesh-delivery-gate's source at the call (main when it names none, said in trunks.from). As the forge's " +
|
||||
"administrator. Answers the rule before and after, the repository's trunks, and trunk_rule: whether the branch is a trunk (is_trunk) and whether the controller now registers a module from " +
|
||||
"this branch (judgedRule, mesh-control build_source.go: no push, a merge whitelist naming mesh-tools and no " +
|
||||
"agent-<node>, no administrator override) and why not.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
branch: { type: "string", description: "the branch (the rule's name, or a glob) whose rule is set" },
|
||||
why: { type: "string", description: "why, in a sentence: logged and answered" },
|
||||
...Object.fromEntries(Object.entries(PROTECTION_FIELDS).map(([name, f]) => [name, f.kind === "logins" || f.kind === "strings"
|
||||
? { type: "array", items: { type: "string" }, description: f.says }
|
||||
: { type: f.kind === "number" ? "number" : f.kind, description: f.says }])),
|
||||
},
|
||||
run: async (args) => {
|
||||
const owner = String(args.owner), repo = String(args.repo), branch = String(args.branch ?? "").trim();
|
||||
const why = String(args.why ?? "").trim();
|
||||
if (!branch) throw new Error("name the branch whose rule is set");
|
||||
if (!why) throw new Error("say why (why): a branch's protection is changed for a reason, which is logged");
|
||||
pathSegments("branch", branch);
|
||||
const given = givenFields(args);
|
||||
if (!given.length) throw new Error(`name at least one field to set: ${Object.keys(PROTECTION_FIELDS).join(", ")}`);
|
||||
const body = fullProtectionBody(args, given);
|
||||
const trunks = await trunksOf(owner, repo, source);
|
||||
const operators = forcePushToTrunk(branch, body, trunks.names);
|
||||
if (operators) throw new Refused(operators);
|
||||
const forge = admin ? admin() : gitea;
|
||||
const rules = await forge.branchProtections(owner, repo);
|
||||
const before = rules.find((p) => (p.rule_name ?? p.branch_name) === branch) ?? null;
|
||||
console.log(`[gitea] gitea_branch_protection_set_full ${owner}/${repo} ${branch}: ${JSON.stringify(body)} — why: ${why}`);
|
||||
const rule = await forge.writeBranchProtection(owner, repo, branch, body, !before);
|
||||
const after = await forge.branchProtections(owner, repo);
|
||||
const read = after.find((p) => (p.rule_name ?? p.branch_name) === branch) ?? rule;
|
||||
const missed = unapplied(body, read);
|
||||
if (missed.length) {
|
||||
throw new Error(`the forge answered the write of ${owner}/${repo} ${branch}'s rule but did not apply ${missed.join("; ")}. ` +
|
||||
`The rule it holds now: ${JSON.stringify(summarised(read))}`);
|
||||
}
|
||||
return {
|
||||
branch, created: !before, why, set: body, also_changed: alsoChanged(before, read, body),
|
||||
before: before ? { ...summarised(before), fields: before } : null,
|
||||
after: { ...summarised(read), fields: read },
|
||||
trunks, trunk_rule: trunkRule(after, branch, trunks.names),
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "gitea_repo_settings_set_full",
|
||||
description: "Set a repository's settings as the forge's administrator — its default branch (e.g. master to main), " +
|
||||
"description, website, private, template, archived, the issues, wiki, pull requests, projects, releases, packages and " +
|
||||
"actions units, the merge styles it allows and its default merge style, delete the branch after merge, and the mirror " +
|
||||
"settings: gitea 1.27.3's EditRepoOption by its own names. Left out: name (a rename moves every module's source " +
|
||||
"address), the internal tracker, external tracker and external wiki objects, and mirror_username, mirror_password " +
|
||||
"and mirror_token, which are credentials. projects_mode needs has_projects: true in the same call. Only the fields " +
|
||||
"given change. The repository is read back: a field the forge did not apply (a default branch that does not exist) " +
|
||||
"fails the call, naming it; mirror_interval is compared as a duration, and enable_prune, which the forge never " +
|
||||
"answers, is listed in not_read_back. A reason (why) is required and logged. Answers the settings before and after.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
why: { type: "string", description: "why, in a sentence: logged and answered" },
|
||||
...Object.fromEntries(Object.entries(REPO_FIELDS).map(([name, f]) => [name, { type: f.kind, description: f.says }])),
|
||||
},
|
||||
run: async (args) => {
|
||||
const owner = String(args.owner), repo = String(args.repo), why = String(args.why ?? "").trim();
|
||||
if (!why) throw new Error("say why (why): a repository's settings are changed for a reason, which is logged");
|
||||
if (!admin) throw new Error("the forge's administrator login is not set: a repository's settings are written with it");
|
||||
const given = givenFields(args);
|
||||
if (!given.length) throw new Error(`name at least one setting: ${Object.keys(REPO_FIELDS).join(", ")}`);
|
||||
const body = fullProtectionBody(args, given, REPO_FIELDS, "a repository's settings");
|
||||
const forge = admin();
|
||||
const before = await forge.repoSettings(owner, repo);
|
||||
console.log(`[gitea] gitea_repo_settings_set_full ${owner}/${repo}: ${JSON.stringify(body)} — why: ${why}`);
|
||||
await forge.editRepoSettings(owner, repo, body);
|
||||
const after = await forge.repoSettings(owner, repo);
|
||||
const missed = unapplied(body, after, REPO_FIELDS);
|
||||
if (missed.length) throw new Error(`the forge answered the write of ${owner}/${repo}'s settings but did not apply ${missed.join("; ")}`);
|
||||
const pick = (r: Record<string, unknown>) => Object.fromEntries(Object.keys(REPO_FIELDS).filter((k) => k in r).map((k) => [k, r[k]]));
|
||||
return { repository: `${owner}/${repo}`, why, set: body, before: pick(before), after: pick(after),
|
||||
not_read_back: Object.keys(body).filter((k) => REPO_FIELDS[k]?.unread) };
|
||||
},
|
||||
},
|
||||
|
||||
// ---- Two settings gitea_api refuses (issue 1422 note 7; guard.ts), each its own named tool ----
|
||||
{
|
||||
name: "gitea_default_branch_set",
|
||||
description: "Set a repository's default branch — the branch the forge shows, clones and opens pull requests against, and the one gitea_api never writes a file to. Refused when the branch does not exist, when no protection rule guards it (a default nothing protects would open it to direct writes), or when no reason (why) is given; acts as the tools' account, so the forge refuses it where that account is no administrator of the repository. Answers the default branch before and after.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
branch: { type: "string", description: "the branch to make the default; it must exist and be guarded by a protection rule" },
|
||||
why: { type: "string", description: "why, in a sentence: kept in the call's record" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const owner = String(args.owner), repo = String(args.repo), branch = String(args.branch ?? "").trim();
|
||||
const why = String(args.why ?? "").trim();
|
||||
if (!branch) throw new Error("name the branch to make the default");
|
||||
if (!why) throw new Error("say why (why): a default branch is changed for a reason, which the call's record keeps");
|
||||
pathSegments("branch", branch);
|
||||
const state = await gitea.branchState(owner, repo, branch);
|
||||
if (!state) throw new Refused(`${owner}/${repo} has no branch ${branch}, so it cannot be the default branch. Nothing was sent`);
|
||||
if (!state.protected) {
|
||||
throw new Refused(`no protection rule guards ${branch} of ${owner}/${repo}: a default branch nothing protects takes direct writes ` +
|
||||
`(gitea_branch_protection_set guards it first). Nothing was sent`);
|
||||
}
|
||||
const before = String((await gitea.getRepo(owner, repo)).default_branch ?? "");
|
||||
if (before === branch) return { branch, before, after: before, changed: false, why };
|
||||
const after = String((await gitea.setDefaultBranch(owner, repo, branch)).default_branch ?? "");
|
||||
return { branch, before, after, changed: before !== after, why };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gitea_pull_base_set",
|
||||
description: "Change an open pull request's base — the branch it merges into — keeping its head. The checks were judged against the base it had, so the pull request is announced again into the new one. Refused when the base branch does not exist, when the pull request is not open, or when no reason (why) is given; acts as the tools' account. Answers the pull request's base before and after.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
number: { type: "number", description: "the pull request's number" },
|
||||
base: { type: "string", description: "the branch it should merge into; it must exist" },
|
||||
why: { type: "string", description: "why, in a sentence: kept in the call's record" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const owner = String(args.owner), repo = String(args.repo), number = Number(args.number);
|
||||
const base = String(args.base ?? "").trim(), why = String(args.why ?? "").trim();
|
||||
if (!Number.isInteger(number) || number < 1) throw new Error(`${args.number} is not a pull request's number`);
|
||||
if (!base) throw new Error("name the base branch");
|
||||
if (!why) throw new Error("say why (why): a pull request's base is changed for a reason, which the call's record keeps");
|
||||
pathSegments("branch", base);
|
||||
if (!(await gitea.branchState(owner, repo, base))) throw new Refused(`${owner}/${repo} has no branch ${base}, so a pull request cannot merge into it. Nothing was sent`);
|
||||
const had = await gitea.getPullRequest(owner, repo, number);
|
||||
if (had.state !== "open" || had.merged) throw new Refused(`${owner}/${repo}#${number} is not open, so its base is not changed. Nothing was sent`);
|
||||
const before = String(had.base ?? "");
|
||||
if (before === base) return { number, before, after: before, changed: false, why };
|
||||
const after = String((await gitea.setPullBase(owner, repo, number, base)).base ?? "");
|
||||
return { number, before, after, changed: before !== after, why };
|
||||
},
|
||||
},
|
||||
|
||||
// ---- A delivery's note, view and statuses (novox/hq ADR 0239), asked by mesh-delivery ----
|
||||
{
|
||||
name: "gitea_note_append",
|
||||
@@ -492,7 +664,7 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
|
||||
},
|
||||
{
|
||||
name: "gitea_commit_statuses",
|
||||
description: "Read a commit's statuses — or a pull request's head's — as the forge keeps them: the newest of each context (mesh/check, mesh/delivery, …) with its state, description, link, when it was set and by whom; and whether the branch it merges into would let it merge, every status that branch's protection requires being a success. Reads only.",
|
||||
description: "Read a commit's statuses — or a pull request's head's — as the forge keeps them: the newest of each context (mesh/check, mesh/delivery, …) with its state, description, link, when it was set and by whom; and whether the branch it merges into would let it merge, every status that branch's protection requires being a success; for a pull request, also the forge's own mergeable (false while it is in conflict with its base or the forge is still working that out); and merge.writes, whether the mesh's merging account may write the repository at all (false: its statuses and its merge are refused until gitea_grant_write). Reads only.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
@@ -508,11 +680,12 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
|
||||
if (!owner || !repo) throw new Error("name the repository: owner and repo");
|
||||
if (!Number.isInteger(number) || number < 0) throw new Error(`${args.number} is not a pull request's number`);
|
||||
let pull: { number: number; title: string; state: string; merged: boolean; base?: string; head?: string;
|
||||
head_sha?: string; merge_commit_sha?: string; html_url: string } | undefined;
|
||||
head_sha?: string; merge_commit_sha?: string; merged_at?: string; html_url: string; mergeable?: boolean } | undefined;
|
||||
if (number > 0) {
|
||||
const p = await gitea.getPullRequest(owner, repo, number);
|
||||
pull = { number: p.number, title: p.title, state: p.state, merged: p.merged, base: p.base, head: p.head,
|
||||
head_sha: p.head_sha, merge_commit_sha: p.merge_commit_sha, html_url: p.html_url };
|
||||
head_sha: p.head_sha, merge_commit_sha: p.merge_commit_sha, merged_at: p.merged_at, html_url: p.html_url,
|
||||
...(p.mergeable === undefined ? {} : { mergeable: p.mergeable }) };
|
||||
if (!ref) ref = p.head_sha ?? "";
|
||||
if (!base) base = p.base ?? "";
|
||||
if (!ref) throw new Error(`${owner}/${repo}#${number} names no head commit`);
|
||||
@@ -527,8 +700,18 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
|
||||
} catch (err) {
|
||||
unreadable = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
// Whether this account may write the repository at all (issue 1945): a commit status and a merge need it, and a
|
||||
// repository of a user it only reads refuses both 403. Not said when the forge would not answer.
|
||||
let writes: boolean | undefined;
|
||||
try {
|
||||
writes = (await gitea.getRepo(owner, repo)).writes;
|
||||
} catch {
|
||||
writes = undefined;
|
||||
}
|
||||
const merge = mergeableUnder(base, rule, statuses, unreadable);
|
||||
return { commit: combined.sha || ref, pull, combined: combined.state, statuses,
|
||||
merge: mergeableUnder(base, rule, statuses, unreadable) };
|
||||
merge: writes === undefined ? merge : { ...merge, writes,
|
||||
...(writes ? {} : { cannot_write: `the forge account cannot write ${owner}/${repo}: gitea_grant_write` }) } };
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// What the forge's events ask of the announcer, and the one pass at a start (issue 1422, notes 10 and 11). Nothing polls the
|
||||
// forge: a head or merge is announced when its event is heard, and a pass over every repository runs once when the module
|
||||
// starts and again when the receiver is listening, so what happened while it was down is announced too.
|
||||
//
|
||||
// Apart from index.ts, which has side effects at import, so these are tested with the announcers injected.
|
||||
|
||||
import type { GiteaPull, GiteaRepo } from "./client.js";
|
||||
import type { Announced } from "./pulls.js";
|
||||
import { headKey, headsToAnnounce } from "./pulls.js";
|
||||
import type { ForgeEvent } from "./webhook.js";
|
||||
|
||||
export interface ActDeps<R, P> {
|
||||
getRepo(owner: string, repo: string): Promise<R>;
|
||||
getPull(owner: string, repo: string, number: number): Promise<P & { state?: string; merged?: boolean }>;
|
||||
announceHead(repo: R, pull: P): Promise<unknown>;
|
||||
announceMerge(repo: R, pull: P): Promise<unknown>;
|
||||
announceClosed(repo: R, pull: P): Promise<unknown>;
|
||||
siblingMoves(full: string, number: number): Promise<unknown>;
|
||||
}
|
||||
|
||||
const HEAD_EVENTS = ["forge.pull-request.opened", "forge.pull-request.synchronized", "forge.pull-request.reopened"];
|
||||
|
||||
/**
|
||||
* What the forge's event asks of the announcer at once: a new head is announced, a merge, a close without a merge, and a
|
||||
* pull request retargeted (an edit: the head is announced into the base it now has — the base is in the head's key, so an
|
||||
* edit that changed nothing about the base announces nothing).
|
||||
*/
|
||||
export async function actOn<R, P>(event: ForgeEvent, d: ActDeps<R, P>): Promise<void> {
|
||||
const b = event.body as { owner?: string; repo?: string; number?: number; merged?: boolean };
|
||||
if (!b.owner || !b.repo || !b.number) return;
|
||||
const edited = event.name === "forge.pull-request.edited";
|
||||
const closed = event.name === "forge.pull-request.closed";
|
||||
const head = HEAD_EVENTS.includes(event.name) || edited;
|
||||
const merged = event.name === "forge.pull-request.merged";
|
||||
if (!head && !merged && !closed) return;
|
||||
if (closed && b.merged) return; // a merge is weighed with its own event (ADR 0382 §5)
|
||||
const [repo, pull] = await Promise.all([d.getRepo(b.owner, b.repo), d.getPull(b.owner, b.repo, b.number)]);
|
||||
if (closed) {
|
||||
await d.announceClosed(repo, pull);
|
||||
await d.siblingMoves(`${b.owner}/${b.repo}`, b.number);
|
||||
return;
|
||||
}
|
||||
if (head && pull.state === "open") await d.announceHead(repo, pull);
|
||||
if (merged) await d.announceMerge(repo, pull);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one pass at a start, and its retry: run now; if it fails, say so and run again after `retryMs`, until one pass has
|
||||
* ended. A retry of a failed pass, never a poll: after a pass has ended nothing is scheduled.
|
||||
*/
|
||||
export function startPass(run: () => Promise<void>, opts: { retryMs: number; log?: (l: string) => void }): Promise<void> {
|
||||
const log = opts.log ?? ((l) => console.error(`[gitea] ${l}`));
|
||||
return run().catch((err) => {
|
||||
log(`the pass over every repository failed, asked again in ${opts.retryMs / 1000} s — ${err instanceof Error ? err.message : err}`);
|
||||
return new Promise<void>((resolve) => setTimeout(() => resolve(startPass(run, opts)), opts.retryMs));
|
||||
});
|
||||
}
|
||||
|
||||
export interface HeadDeps {
|
||||
/** Whether the forge shows a merge check of the head: only an announced head has one. */
|
||||
alreadyChecked(pull: GiteaPull): Promise<boolean>;
|
||||
/** Announce it; true when it was this call that announced it (not an event before it). */
|
||||
announceHead(pull: GiteaPull): Promise<boolean>;
|
||||
/** Told of a head this pass announced that no event had. */
|
||||
missed(what: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The heads of one repository's open pull requests a pass announces. A head is announced when it is new and fresh (moved in
|
||||
* the last day); at the first pass after a start (`primed` false) one the forge shows a merge check of was announced
|
||||
* already, and is only remembered.
|
||||
*/
|
||||
export async function announceNewHeads(full: string, open: GiteaPull[], heads: Announced, primed: boolean, dayAgo: string, d: HeadDeps): Promise<void> {
|
||||
for (const pull of headsToAnnounce(full, open, heads)) {
|
||||
const key = headKey(full, pull.number, pull.base);
|
||||
const fresh = primed || (!!pull.updated_at && pull.updated_at > dayAgo);
|
||||
if (fresh && !primed && await d.alreadyChecked(pull)) {
|
||||
heads[key] = String(pull.head_sha);
|
||||
continue;
|
||||
}
|
||||
if (fresh) {
|
||||
if (await d.announceHead(pull)) d.missed(`${key} at ${String(pull.head_sha).slice(0, 8)}`);
|
||||
} else heads[key] = String(pull.head_sha);
|
||||
}
|
||||
}
|
||||
|
||||
export type { GiteaRepo };
|
||||
+19
-22
@@ -3,7 +3,7 @@
|
||||
// with a secret it mints and keeps beside its token; the receiver refuses whatever is unsigned or does not verify, and
|
||||
// publishes each event, trimmed, on the module's events as forge.<kind>.<action> (forge.pull-request.opened, forge.push, …),
|
||||
// on the shared EVENTS path its other events take; its own stream (ADR 0363) is a follow-up once issue 725 ships.
|
||||
// The polls stay as the safety net, slowed; the announced-heads and announced-merges records keep anything from
|
||||
// No poll is the safety net (issue 1422); the announced-heads and announced-merges records keep anything from
|
||||
// being announced twice, whichever path saw it first.
|
||||
//
|
||||
// The secret is never logged: lines say it was minted or reused and where it is kept, never what it is.
|
||||
@@ -144,7 +144,7 @@ export interface ReceiverOptions {
|
||||
publish: (event: ForgeEvent) => Promise<void>;
|
||||
/** Acts on it at once, beside publishing it (the announcer). */
|
||||
act?: (event: ForgeEvent) => Promise<void>;
|
||||
/** Told of every verified delivery — its kind (X-Gitea-Event) and its delivery id — so the polls know events are
|
||||
/** Told of every verified delivery — its kind (X-Gitea-Event) and its delivery id — so the module knows events are
|
||||
* heard, and the module can say when it last heard one. */
|
||||
heard?: (kind: string, delivery: string) => void;
|
||||
/** Told of every refused delivery, with the reason and where it came from. */
|
||||
@@ -242,7 +242,7 @@ export function receiver(opts: ReceiverOptions): Server & { idle(): Promise<void
|
||||
await opts.publish(event);
|
||||
if (opts.act) await opts.act(event);
|
||||
} catch (err) {
|
||||
log(`a forge event ${event.name} could not be published or acted on; the polls catch it — ${err instanceof Error ? err.message : err}`);
|
||||
log(`a forge event ${event.name} could not be published or acted on; the pass over every repository is asked again shortly — ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -276,14 +276,13 @@ export interface HookState {
|
||||
export const SILENT_AFTER = 10 * 60_000;
|
||||
|
||||
/**
|
||||
* **How fast the polls look, and whether the forge's events are heard** (issue 729): every five minutes only while the
|
||||
* forge's events are heard — a verified delivery within the last ten minutes, and none missed since. Until the first
|
||||
* delivery, after ten minutes without one, or once the poll found something no event said, every thirty seconds.
|
||||
* **Whether the forge's events are heard** (issues 729, 1422). Nothing polls the forge, so there is no pace: a delivery
|
||||
* heard within the last ten minutes, and none missed since, is `hearing`.
|
||||
*
|
||||
* It also keeps what `gitea_events` says: where the receiver listens, the hook as ensured, the verified and refused
|
||||
* deliveries, and the polls' finds no event said. **A forge whose events stop is said** (issue 729): once the receiver
|
||||
* deliveries, and the finds of the pass over every repository (at the start and when the receiver listens) that no event said. **A forge whose events stop is said** (issue 729): once the receiver
|
||||
* listens and the hook is ensured, when nothing verified for ten minutes — since the start, or since the last delivery —
|
||||
* and the poll has found at least two heads or merges no event said in that quiet (one could be the poll beating an
|
||||
* and a pass has found at least two heads or merges no event said in that quiet (one could be a pass beating an
|
||||
* event by a second), it is logged loudly once per silent spell, and the module's events are said unhealthy until a
|
||||
* delivery verifies. Deliveries refused since the last verified one are said unhealthy too.
|
||||
*/
|
||||
@@ -318,7 +317,7 @@ export class Pace {
|
||||
this.notHeard = null;
|
||||
}
|
||||
/** No events are listened for, and why: by configuration (`fails` false), or because the receiver could not listen
|
||||
* or the hook could not be ensured (`fails` true, said unhealthy). The polls alone watch. */
|
||||
* or the hook could not be ensured (`fails` true, said unhealthy). Only the pass at the start watches. */
|
||||
unheard(why: string, fails = true): void {
|
||||
this.notHeard = why;
|
||||
this.notHeardFails = fails;
|
||||
@@ -337,10 +336,10 @@ export class Pace {
|
||||
this.refusedSpell++;
|
||||
this.lastRefused = { at: this.now(), reason, from };
|
||||
}
|
||||
/** The poll found a head or merge no forge event announced. */
|
||||
/** The pass over every repository found a head or merge no forge event announced. */
|
||||
miss(what: string): void {
|
||||
const t = this.now();
|
||||
if (this.hearing()) this.log(`THE FORGE'S EVENTS MISSED ${what}: the poll found it, so the polls look every 30 s again until the next event`);
|
||||
if (this.hearing()) this.log(`THE FORGE'S EVENTS MISSED ${what}: the pass over every repository found it`);
|
||||
this.missed = t;
|
||||
this.missCount++;
|
||||
this.lastMiss = { at: t, what };
|
||||
@@ -355,8 +354,8 @@ export class Pace {
|
||||
this.log(`THE FORGE'S EVENTS ARE NOT HEARD: ${this.last ? `no delivery verified since the last at ` +
|
||||
`${new Date(this.last).toISOString()}` : "no delivery ever verified since the start"}, ${minutes} min, from the ` +
|
||||
`system webhook at ${this.hook?.url}${refusedSince ? `; ${refusedSince} refused since, the last because ` +
|
||||
`${this.lastRefused?.reason}` : ""}; the poll found ${what} — the polls alone watch the forge, every 30 s ` +
|
||||
`(novox/hq issue 729)`);
|
||||
`${this.lastRefused?.reason}` : ""}; the pass found ${what}; nothing else watches the forge ` +
|
||||
`(novox/hq issue 729, 1422)`);
|
||||
}
|
||||
}
|
||||
/** Whether a delivery was refused after the last verified one (or with none verified). */
|
||||
@@ -367,9 +366,6 @@ export class Pace {
|
||||
const t = this.now();
|
||||
return this.last > 0 && t - this.last < 10 * 60_000 && this.last > this.missed;
|
||||
}
|
||||
every(): number {
|
||||
return this.hearing() ? 5 * 60_000 : 30_000;
|
||||
}
|
||||
private expecting(): boolean {
|
||||
return this.where !== null && this.hook !== null;
|
||||
}
|
||||
@@ -383,14 +379,14 @@ export class Pace {
|
||||
if (this.silentSince) {
|
||||
state = "unhealthy";
|
||||
why = `${this.last ? `no delivery verified since the last at ${iso(this.last)}` : `the forge has never delivered an ` +
|
||||
`event since the start at ${iso(this.started)}`}, while the poll found ${this.missCount} head(s) or merge(s) no ` +
|
||||
`event since the start at ${iso(this.started)}`}, while the pass found ${this.missCount} head(s) or merge(s) no ` +
|
||||
`event said, the last ${this.lastMiss?.what} at ${iso(this.lastMiss?.at ?? t)}`;
|
||||
} else if (refusedSince) {
|
||||
state = "unhealthy";
|
||||
why = `${refusedSince} deliveries refused ${this.last ? `since the last verified one at ${iso(this.last)}` :
|
||||
"and none verified"}, the last at ${iso(this.lastRefused?.at ?? t)} because ${this.lastRefused?.reason}`;
|
||||
} else if (!this.expecting()) {
|
||||
why = this.notHeard ? `no events are listened for, so the polls alone watch the forge: ${this.notHeard}` :
|
||||
why = this.notHeard ? `no events are listened for, so only the pass at the start watches the forge: ${this.notHeard}` :
|
||||
"the receiver is not listening yet";
|
||||
if (this.notHeard && this.notHeardFails) state = "unhealthy";
|
||||
} else if (this.hearing()) {
|
||||
@@ -398,7 +394,7 @@ export class Pace {
|
||||
} else if (this.last === 0) {
|
||||
why = `no delivery heard yet; the forge is said silent if the poll finds what no event said ${SILENT_AFTER / 60_000} min after the start`;
|
||||
} else {
|
||||
why = `the last delivery was at ${iso(this.last)}; the polls look every 30 s until the next`;
|
||||
why = `the last delivery was at ${iso(this.last)}; nothing polls the forge, so what happens meanwhile is announced at the next start or when the receiver listens again`;
|
||||
}
|
||||
return {
|
||||
healthy: state !== "unhealthy", state, why,
|
||||
@@ -407,7 +403,8 @@ export class Pace {
|
||||
hook: this.hook,
|
||||
verified: { count: this.verifiedCount, last: this.lastVerified && { ...this.lastVerified, at: iso(this.lastVerified.at) } },
|
||||
refused: { count: this.refusedCount, since_verified: refusedSince, last: this.lastRefused && { ...this.lastRefused, at: iso(this.lastRefused.at) } },
|
||||
polls: { every_seconds: this.every() / 1000, hearing: this.hearing(), found_unsaid: this.missCount,
|
||||
polls: { every_seconds: null, note: "no timer asks the forge; one pass at the start and when the receiver listens (issue 1422)",
|
||||
hearing: this.hearing(), found_unsaid: this.missCount,
|
||||
last_found: this.lastMiss && { ...this.lastMiss, at: iso(this.lastMiss.at) } },
|
||||
silent: this.silentSince ? { since: iso(this.silentSince) } : null,
|
||||
};
|
||||
@@ -502,8 +499,8 @@ export function eventsTool(pace: Pace): { name: string; description: string; inp
|
||||
name: "gitea_events",
|
||||
description: "Whether the forge's events reach the mesh (novox/hq issue 729): where the receiver listens, the " +
|
||||
"system webhook as ensured at the start (its id, url, events, kind, and what it replaced), how many deliveries " +
|
||||
"verified and the last one's time and event, how many were refused and the last reason, how often the polls look " +
|
||||
"and whether events are heard, and how many heads or merges the poll found that no event said. Unhealthy when the " +
|
||||
"verified and the last one's time and event, how many were refused and the last reason, whether " +
|
||||
"events are heard (nothing polls; one pass runs at the start), and how many heads or merges that pass found that no event said. Unhealthy when the " +
|
||||
"forge has delivered nothing that verified for ten minutes (since the start or since the last delivery) while the " +
|
||||
"poll found what no event said, when deliveries were refused since the last verified one, or when the receiver " +
|
||||
"could not listen or the webhook could not be made.",
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
// The account that sets commit statuses and merges writes every repository that builds a registered module, and no other
|
||||
// (the operator, 2026-10-26: "yes the mesh should get write access", on the repositories the mesh delivers; novox/hq issue
|
||||
// 1945). car-hunter #2 (a repository of a user, not of the organisation novox) was approved, and mesh-delivery's first
|
||||
// act, its commit status, was refused 403: the tools' account was only a collaborator that reads there (readall.ts).
|
||||
//
|
||||
// **Which account.** The tools' account, `mesh-tools`: the gitea module's own tools set every commit status and merge
|
||||
// (gitea_commit_status, gitea_merge_pull_request) with its token. The build modules' `nox-builder` and the machines'
|
||||
// `nox-agent` only fetch, and keep read (readall.ts).
|
||||
//
|
||||
// **Which repositories: the indexer's word, read over the bus.** mesh-delivery-indexer keeps every registered module in its
|
||||
// state `modules` (mesh-sdk go/delivery ModulesBucket), each value naming the repository it builds from. This module reads
|
||||
// that state (its manifest's `reads`) with a watch: the watch hands the latest value of every key when it starts, and then
|
||||
// every change, so a repository is granted at the module's start and when a module of it is first registered. Nothing
|
||||
// polls. A repository of the organisation needs nothing: the organisation's team writes it (tools-account.ts). Every other
|
||||
// repository of a user (the operator's dotfiles, say) is left as it is: read only.
|
||||
//
|
||||
// **Every grant is read back** from the forge (its permission for the account), and said on the bus as
|
||||
// `repo.writable` {owner, repo, account}: mesh-delivery-gate, which does not ask the operator's Approve of a pull request
|
||||
// whose merge the forge would refuse, reads that repository again on it.
|
||||
//
|
||||
// **A repository of a user is written, and main's rule is left as the operator set it** (the operator, 2026-10-26, asked
|
||||
// "Write, rule untouched"): the account is a write collaborator there, and only added to main's merge whitelist when that
|
||||
// rule keeps one (client.ts ensureMergerListed). What else in main's rule stops the mesh's merge (a status the mesh does not
|
||||
// set, approvals the gate does not give) is changed by nobody but said: both tools list such a repository as writable, but
|
||||
// blocked, with why (merge_blocked).
|
||||
//
|
||||
// Not taken away: a repository whose last module goes from the indexer keeps its collaborator until a hand removes it;
|
||||
// gitea_unwritable_repos and gitea_grant_write say the delivered list as it is now.
|
||||
|
||||
import type { ForgeRepository } from "./client.js";
|
||||
import type { BranchProtection } from "./protection.js";
|
||||
import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT } from "./pulls.js";
|
||||
|
||||
/** The indexer's state this module reads: one key per registered module, its value naming the repository. */
|
||||
export const INDEXER_MODULES = "mesh-delivery-indexer.modules";
|
||||
/** The event said once an account writes a repository it could not write before. */
|
||||
export const WRITABLE_EVENT = "repo.writable";
|
||||
|
||||
/** What the module asks of the forge for this, with the site administrator's login (GiteaAdmin, client.ts). */
|
||||
export interface WriteForge {
|
||||
everyRepository(): Promise<ForgeRepository[]>;
|
||||
repository(owner: string, repo: string): Promise<ForgeRepository | null>;
|
||||
repositoryPermission(owner: string, repo: string, user: string): Promise<string>;
|
||||
addWriteCollaborator(owner: string, repo: string, user: string): Promise<void>;
|
||||
mainRule(owner: string, repo: string): Promise<BranchProtection | null>;
|
||||
ensureMergerListed(owner: string, repo: string, account: string): Promise<{ changed: boolean; rule: BranchProtection | null }>;
|
||||
}
|
||||
|
||||
/** The statuses every pull request the mesh delivers carries (mesh-delivery's checks); a rule requiring another blocks. */
|
||||
const MESH_CONTEXTS = new Set([CHECK_STATUS, CHECK_CONTEXT, REPO_CHECK_CONTEXT]);
|
||||
|
||||
/** What in main's rule stops the mesh's merge, in words; empty when nothing does. Changed by nobody here: the operator's. */
|
||||
export function mergeBlocks(rule: BranchProtection | null, account: string): string[] {
|
||||
if (!rule) return [];
|
||||
const blocks: string[] = [];
|
||||
if (rule.enable_merge_whitelist === true && !(rule.merge_whitelist_usernames ?? []).includes(account)) {
|
||||
blocks.push(`its merge whitelist [${(rule.merge_whitelist_usernames ?? []).join(", ")}] leaves out ${account} (gitea_grant_write adds it)`);
|
||||
}
|
||||
if (rule.enable_status_check === true) {
|
||||
for (const c of rule.status_check_contexts ?? []) {
|
||||
if (!MESH_CONTEXTS.has(c)) blocks.push(`it requires the status ${c}, which the mesh does not set`);
|
||||
}
|
||||
}
|
||||
const approvals = Number(rule.required_approvals ?? 0);
|
||||
if (approvals > 0) blocks.push(`it requires ${approvals} approving review${approvals === 1 ? "" : "s"} on the forge, which the gate does not give`);
|
||||
if (rule.require_signed_commits === true) blocks.push("it requires signed commits, which the mesh does not make");
|
||||
if (rule.block_on_outdated_branch === true) blocks.push("it refuses a branch behind main, which the mesh does not bring up to date");
|
||||
if (rule.block_on_rejected_reviews === true) blocks.push("it refuses a merge after a review asked for changes, which the gate does not clear");
|
||||
return blocks;
|
||||
}
|
||||
|
||||
const blockedWhy = (blocks: string[]): string => `writable, but main's rule blocks the mesh's merge: ${blocks.join("; ")}`;
|
||||
|
||||
/** One change of the indexer's modules state, as the SDK's state watch hands it. */
|
||||
export interface ModulesChange {
|
||||
key: string;
|
||||
op: "put" | "delete";
|
||||
value?: unknown;
|
||||
revision?: number;
|
||||
current?: boolean;
|
||||
}
|
||||
|
||||
export interface Written {
|
||||
account: string;
|
||||
/** The repositories that build a registered module, as the indexer says now. */
|
||||
delivered: string[];
|
||||
/** Made writable now, each read back from the forge. */
|
||||
granted: string[];
|
||||
/** Written already (a collaborator of a user's repository), or written through the organisation's team. */
|
||||
already: string[];
|
||||
/** Each repository of a user: whether the account was added to main's merge whitelist now, and the whitelist read back
|
||||
* (null: main's rule keeps no whitelist, or there is no rule, and whoever writes may merge). */
|
||||
merge_whitelist: { repository: string; changed: boolean; listed: string[] | null }[];
|
||||
/** Writable, but main's rule (the operator's, left as set) stops the mesh's merge, and why. */
|
||||
merge_blocked: { repository: string; why: string }[];
|
||||
/** The forge refused, in its own words; or the repository is the organisation's, whose team should write it. */
|
||||
refused: { repository: string; why: string }[];
|
||||
/** Repositories the account cannot write that are left so, and why: they build no registered module. */
|
||||
left_read_only: { repository: string; permission: string; why: string }[];
|
||||
}
|
||||
|
||||
const WRITES = new Set(["write", "admin", "owner"]);
|
||||
const said = (err: unknown): string => (err instanceof Error ? err.message : String(err));
|
||||
|
||||
function split(full: string): [string, string] {
|
||||
const [owner, name, ...rest] = String(full ?? "").split("/");
|
||||
if (!owner || !name || rest.length) throw new Error(`"${full}" is not a repository: say owner/name`);
|
||||
return [owner, name];
|
||||
}
|
||||
|
||||
export class DeliveredWrites {
|
||||
/** Module to the repository it builds from, as the indexer's state says. */
|
||||
readonly modules = new Map<string, string>();
|
||||
/** Whether the watch has handed the indexer's state at all: until then, which repositories are delivered is not known. */
|
||||
heard = false;
|
||||
/** Repositories seen written by the account in this process: a module registered again asks nothing more. */
|
||||
readonly writable = new Set<string>();
|
||||
|
||||
constructor(private readonly forge: WriteForge, readonly account: string, readonly org: string,
|
||||
private readonly log: (line: string) => void = (l) => console.log(`[gitea] ${l}`),
|
||||
private readonly emit: (type: string, body: unknown) => Promise<void> = async () => {}) {}
|
||||
|
||||
/** The repositories that build a registered module, sorted. */
|
||||
delivered(): string[] {
|
||||
return [...new Set(this.modules.values())].sort();
|
||||
}
|
||||
|
||||
/** One change of the indexer's modules: a repository newly delivered is made writable now. Never throws. */
|
||||
async take(change: ModulesChange): Promise<void> {
|
||||
this.heard = true;
|
||||
if (change.op === "delete") {
|
||||
this.modules.delete(change.key);
|
||||
return;
|
||||
}
|
||||
const repository = String((change.value as { repository?: unknown } | undefined)?.repository ?? "").trim();
|
||||
try {
|
||||
split(repository);
|
||||
} catch {
|
||||
this.log(`${INDEXER_MODULES} ${change.key} names no repository (${JSON.stringify(repository)}): no write is granted for it`);
|
||||
return;
|
||||
}
|
||||
this.modules.set(change.key, repository);
|
||||
if (this.writable.has(repository)) return;
|
||||
try {
|
||||
const r = await this.grant([repository]);
|
||||
for (const g of r.granted) this.log(`${this.account} was given write on ${g}, which builds the registered module ${change.key}`);
|
||||
for (const x of r.refused) this.log(`${this.account} could not be given write on ${x.repository}, which builds ${change.key} — ${x.why}`);
|
||||
} catch (err) {
|
||||
this.log(`whether ${this.account} writes ${repository} (module ${change.key}) could not be settled — ${said(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Grant write on every delivered repository the account cannot write (or on those named, each of which must be
|
||||
* delivered); with none named, also say which repositories it cannot write are left so, and why. */
|
||||
async grant(only?: string[]): Promise<Written> {
|
||||
if (!this.heard) {
|
||||
throw new Error(`which repositories build a registered module is not known yet: ${INDEXER_MODULES} has not been read ` +
|
||||
"(the watch is not granted or not answered); nothing is granted");
|
||||
}
|
||||
const delivered = this.delivered();
|
||||
const result: Written = { account: this.account, delivered, granted: [], already: [], merge_whitelist: [], merge_blocked: [], refused: [], left_read_only: [] };
|
||||
const named = only && only.length ? only.map((x) => String(x).trim()) : null;
|
||||
const targets: string[] = [];
|
||||
for (const full of named ?? delivered) {
|
||||
split(full);
|
||||
if (delivered.includes(full)) targets.push(full);
|
||||
else result.left_read_only.push({ repository: full, permission: "unchanged",
|
||||
why: "builds no registered module (the indexer names none from it), so the account is given no write there" });
|
||||
}
|
||||
for (const full of targets) {
|
||||
const [owner, name] = split(full);
|
||||
let permission: string;
|
||||
try {
|
||||
if (!(await this.forge.repository(owner, name))) {
|
||||
result.refused.push({ repository: full, why: "the forge holds no repository by that name" });
|
||||
continue;
|
||||
}
|
||||
permission = await this.forge.repositoryPermission(owner, name, this.account);
|
||||
} catch (err) {
|
||||
result.refused.push({ repository: full, why: `its permission could not be read — ${said(err)}` });
|
||||
continue;
|
||||
}
|
||||
if (owner === this.org) {
|
||||
if (WRITES.has(permission)) {
|
||||
this.writable.add(full);
|
||||
result.already.push(full);
|
||||
continue;
|
||||
}
|
||||
// The organisation's repositories are written through its team (tools-account.ts), never a collaborator.
|
||||
result.refused.push({ repository: full, why: `a repository of ${this.org}, which ${this.account} writes through the ` +
|
||||
`team ${this.org}/${this.account}; the forge gives it "${permission}", so the team is not as it should be (it is ` +
|
||||
"set again at the next mint of the tools' token)" });
|
||||
continue;
|
||||
}
|
||||
// A repository of a user: the account writes it, and is put on main's merge whitelist when the rule keeps one.
|
||||
let changed = false;
|
||||
if (!WRITES.has(permission)) {
|
||||
try {
|
||||
await this.forge.addWriteCollaborator(owner, name, this.account);
|
||||
} catch (err) {
|
||||
result.refused.push({ repository: full, why: said(err) });
|
||||
continue;
|
||||
}
|
||||
let after: string;
|
||||
try {
|
||||
after = await this.forge.repositoryPermission(owner, name, this.account);
|
||||
} catch (err) {
|
||||
result.refused.push({ repository: full, why: `granted, but its permission could not be read back — ${said(err)}` });
|
||||
continue;
|
||||
}
|
||||
if (!WRITES.has(after)) {
|
||||
result.refused.push({ repository: full, why: `the grant was answered, but the forge still gives ${this.account} "${after}"` });
|
||||
continue;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
try {
|
||||
const { changed: listedNow, rule } = await this.forge.ensureMergerListed(owner, name, this.account);
|
||||
result.merge_whitelist.push({ repository: full, changed: listedNow,
|
||||
listed: rule?.enable_merge_whitelist === true ? (rule.merge_whitelist_usernames ?? []) : null });
|
||||
const blocks = mergeBlocks(rule, this.account);
|
||||
if (blocks.length > 0) result.merge_blocked.push({ repository: full, why: blockedWhy(blocks) });
|
||||
} catch (err) {
|
||||
result.refused.push({ repository: full, why: `${this.account} writes it, but could not be put on main's merge whitelist — ${said(err)}` });
|
||||
continue;
|
||||
}
|
||||
this.writable.add(full);
|
||||
if (!changed) {
|
||||
result.already.push(full);
|
||||
continue;
|
||||
}
|
||||
result.granted.push(full);
|
||||
await this.emit(WRITABLE_EVENT, { owner, repo: name, account: this.account }).catch((err) =>
|
||||
this.log(`${this.account} writes ${full} now, and that could not be said on the bus — ${said(err)}`));
|
||||
}
|
||||
if (!named) {
|
||||
for (const repo of await this.forge.everyRepository()) {
|
||||
if (repo.owner === this.org || delivered.includes(repo.full_name)) continue;
|
||||
let permission: string;
|
||||
try {
|
||||
permission = await this.forge.repositoryPermission(repo.owner, repo.name, this.account);
|
||||
} catch (err) {
|
||||
permission = `unknown — ${said(err)}`;
|
||||
}
|
||||
if (WRITES.has(permission)) continue;
|
||||
result.left_read_only.push({ repository: repo.full_name, permission,
|
||||
why: "builds no registered module (the indexer names none from it), so the account is given no write there" });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Every delivered repository the account cannot write, as the forge says now, and why. */
|
||||
async unwritable(): Promise<{ account: string; delivered: number; unwritable: { repository: string; permission: string; why: string }[];
|
||||
merge_blocked: { repository: string; why: string }[] }> {
|
||||
if (!this.heard) throw new Error(`which repositories build a registered module is not known yet: ${INDEXER_MODULES} has not been read`);
|
||||
const delivered = this.delivered();
|
||||
const out: { repository: string; permission: string; why: string }[] = [];
|
||||
const blocked: { repository: string; why: string }[] = [];
|
||||
for (const full of delivered) {
|
||||
const [owner, name] = split(full);
|
||||
let permission: string;
|
||||
try {
|
||||
permission = await this.forge.repositoryPermission(owner, name, this.account);
|
||||
} catch (err) {
|
||||
out.push({ repository: full, permission: "unknown", why: `the forge did not answer ${this.account}'s permission on it — ${said(err)}` });
|
||||
continue;
|
||||
}
|
||||
if (WRITES.has(permission)) {
|
||||
if (owner === this.org) continue; // the organisation's main rules are the mesh's own (mainrules.ts)
|
||||
try {
|
||||
const blocks = mergeBlocks(await this.forge.mainRule(owner, name), this.account);
|
||||
if (blocks.length > 0) blocked.push({ repository: full, why: blockedWhy(blocks) });
|
||||
} catch (err) {
|
||||
blocked.push({ repository: full, why: `writable, but main's rule could not be read — ${said(err)}` });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const modules = [...this.modules].filter(([, r]) => r === full).map(([m]) => m).sort();
|
||||
out.push({ repository: full, permission, why: `builds ${modules.join(", ")}, and the forge gives ${this.account} ` +
|
||||
`"${permission}" on it: its commit status and its merge are refused until gitea_grant_write grants it` });
|
||||
}
|
||||
return { account: this.account, delivered: delivered.length, unwritable: out, merge_blocked: blocked };
|
||||
}
|
||||
}
|
||||
|
||||
type Tool = { name: string; description: string; input: Record<string, unknown>; run: (args: Readonly<Record<string, unknown>>) => Promise<unknown> };
|
||||
|
||||
/** The two tools: which delivered repositories the merging account cannot write, and the grant now. */
|
||||
export function writeTools(writes: () => DeliveredWrites): Tool[] {
|
||||
return [
|
||||
{
|
||||
name: "gitea_unwritable_repos",
|
||||
description: "Every repository that builds a registered module (the indexer's modules, read over the bus) on which " +
|
||||
"the mesh's merging forge account (mesh-tools, which sets commit statuses and merges) cannot write, with the " +
|
||||
"permission the forge gives it there and why: such a pull request can be checked but never merged, and the " +
|
||||
"delivery gate does not ask its Approve. Empty when it writes every one. Its merge_blocked lists each repository of " +
|
||||
"a user it writes whose main's rule (the operator's, left as set) still stops the mesh's merge: a merge whitelist " +
|
||||
"without it, a required status the mesh does not set, required approving reviews. Read with the site administrator's login.",
|
||||
input: {},
|
||||
run: async () => writes().unwritable(),
|
||||
},
|
||||
{
|
||||
name: "gitea_grant_write",
|
||||
description: "Give the mesh's merging forge account (mesh-tools) write, as a collaborator, on every repository of a " +
|
||||
"user that builds a registered module, or on the repositories named (each must build one). Main's rule there is left " +
|
||||
"as the operator set it; only when it keeps a merge whitelist is mesh-tools added to it, read back (merge_whitelist). " +
|
||||
"What else in that rule stops the mesh's merge (a required status the mesh does not set, required approving reviews) " +
|
||||
"is changed by nobody and said in merge_blocked. " +
|
||||
"The module does this itself at its start and when a module of a new repository is registered; this is the same " +
|
||||
"act by hand, such as when a merge or a commit status was refused 403. A repository of the organisation novox " +
|
||||
"is written through its team and needs nothing. Answers what was granted (each read back from the forge), what " +
|
||||
"was written already, what the forge refused in its own words, and which other repositories it left read-only " +
|
||||
"and why (they build no registered module). Needs the site administrator's login, used for this grant alone.",
|
||||
input: {
|
||||
repositories: { type: "array", items: { type: "string" }, description: "owner/name of each repository; empty or absent, every delivered one" },
|
||||
},
|
||||
run: async (args) => writes().grant(Array.isArray(args.repositories) ? args.repositories.map(String) : undefined),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
# 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.
|
||||
# the copies of this package in every module that carries one.
|
||||
modules/xorg/internal/desktop/*.go
|
||||
modules/lemurs/internal/desktop/*.go
|
||||
modules/i3/internal/desktop/*.go
|
||||
modules/xterm/internal/desktop/*.go
|
||||
modules/adwaita/internal/desktop/*.go
|
||||
@@ -53,6 +53,22 @@ built for the fallback kernel too) after its boot-entries service, so the entry
|
||||
| file | `/etc/pacman.d/hooks/90-mesh-kernel-boot-entries.hook` | runs the script after a kernel image is installed, upgraded or removed |
|
||||
| packages | `linux-lts`, `linux-lts-headers` | declared after the service and the hook |
|
||||
|
||||
## Kernel modules at boot, and the running kernel's modules kept
|
||||
|
||||
A machine that runs for weeks meets a kernel upgrade that removes `/usr/lib/modules/<the running kernel>`: from then on
|
||||
no module that was not loaded yet can be loaded until the next boot. On 2026-10-28 a build node, up 24 days on
|
||||
`linux-lts` 6.18.54 with only 6.18.58's modules on disk, had `/dev/net/tun` and no tun driver, and every image build
|
||||
there failed in buildah's network setup ("Failed to open() /dev/net/tun: No such device").
|
||||
|
||||
| | what | notes |
|
||||
|---|---|---|
|
||||
| file | `/etc/modules-load.d/mesh-kernel.conf` | the kernel modules loaded at every boot by systemd-modules-load: `tun` (pasta, which buildah uses for a RUN line's network, and every user-space tunnel open `/dev/net/tun`). Read at boot only: nothing is loaded by an apply |
|
||||
| package | `kernel-modules-hook` | keeps the running kernel's modules across an upgrade (saved before the transaction, put back after), so a module first needed later still loads |
|
||||
| service | `linux-modules-cleanup.service` | the package's oneshot, enabled at boot and stopped otherwise: it moves the modules of a kernel that is neither running nor owned by a package to `/usr/lib/modules/.old`, which the package's tmpfiles rule empties after four weeks. An apply never starts it |
|
||||
|
||||
A machine whose running kernel's modules are already gone loads nothing new until it is started again; the image build
|
||||
module says so in its health and takes no image build there meanwhile.
|
||||
|
||||
## Tools
|
||||
|
||||
| tool | | answers |
|
||||
@@ -63,7 +79,7 @@ built for the fallback kernel too) after its boot-entries service, so the entry
|
||||
## Tests
|
||||
|
||||
`go test ./...`: the tool over a stand-in tree holding the control node's own files of 2026-10-14; the
|
||||
manifest's shape (the default, the order, the unit and its sandbox, reload, restart and health); the boot-entries script run with `sh` over stand-in roots (a systemd-boot entries directory, a GRUB menu, a hook that copies kernels to one fixed name): the entry from the booted command line, a menu generated again only when the kernels changed, no loader, no room, the taken-over hook; `kernel_boot` over the same roots; and the huge-pages script as the mesh
|
||||
manifest's shape (the default, the order, the unit and its sandbox, reload, restart and health); the kernel modules loaded at boot (`tun`) and the package and boot service that keep the running kernel's modules; the boot-entries script run with `sh` over stand-in roots (a systemd-boot entries directory, a GRUB menu, a hook that copies kernels to one fixed name): the entry from the booted command line, a menu generated again only when the kernels changed, no loader, no room, the taken-over hook; `kernel_boot` over the same roots; and the huge-pages script as the mesh
|
||||
writes it, run with `sh` against a stand-in directory: it sets and reads back, keeps what it found once,
|
||||
rotates the journal once and not for `always` nor while the machine starts, goes straight to a new value on a
|
||||
reload without rewriting what it found, gives back what it found and then keeps nothing, refuses a value the kernel
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestTheDefaultIsMadviseAndTheUnitFollowsTheSetting(t *testing.T) {
|
||||
for _, r := range m.Resources {
|
||||
ids = append(ids, r["id"].(string))
|
||||
}
|
||||
if strings.Join(ids, " ") != "state huge-pages unit apply boot-entries boot-unit boot-hook boot-apply linux-lts linux-lts-headers" {
|
||||
if strings.Join(ids, " ") != "state huge-pages unit apply boot-entries boot-unit boot-hook boot-apply linux-lts linux-lts-headers modules-load modules-keep modules-cleanup" {
|
||||
t.Fatalf("order: %v", ids)
|
||||
}
|
||||
script := m.resource(t, "huge-pages")
|
||||
@@ -57,6 +57,31 @@ func TestTheDefaultIsMadviseAndTheUnitFollowsTheSetting(t *testing.T) {
|
||||
if h := apply["health"].(map[string]any); h["kind"] != "unit" {
|
||||
t.Fatalf("health: %v", h)
|
||||
}
|
||||
// A build node, 2026-10-28: up 24 days on linux-lts 6.18.54 after an upgrade to 6.18.58 removed the running kernel's modules,
|
||||
// so tun could not be loaded when a build first asked for it. tun is loaded at every boot, before an upgrade can take
|
||||
// its file away, and the running kernel's modules are kept across an upgrade (kernel-modules-hook) and cleaned up at
|
||||
// the next boot.
|
||||
load := m.resource(t, "modules-load")
|
||||
if load["path"] != "/etc/modules-load.d/mesh-kernel.conf" || load["mode"] != "0644" {
|
||||
t.Fatalf("modules-load: %v", load)
|
||||
}
|
||||
var names []string
|
||||
for _, l := range strings.Split(load.text(t), "\n") {
|
||||
if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "#") {
|
||||
names = append(names, l)
|
||||
}
|
||||
}
|
||||
if strings.Join(names, " ") != "tun" {
|
||||
t.Fatalf("the modules loaded at boot: %v", names)
|
||||
}
|
||||
if keep := m.resource(t, "modules-keep"); keep["type"] != "package" || keep["package"] != "kernel-modules-hook" {
|
||||
t.Fatalf("modules-keep: %v", keep)
|
||||
}
|
||||
// A oneshot at boot: enabled, and stopped once it ran, never started by an apply (it moves the modules of kernels no
|
||||
// package owns away).
|
||||
if c := m.resource(t, "modules-cleanup"); c["unit"] != "linux-modules-cleanup.service" || c["state"] != "stopped" || c["boot"] != "enabled" {
|
||||
t.Fatalf("modules-cleanup: %v", c)
|
||||
}
|
||||
}
|
||||
|
||||
// bed is the script as the mesh writes it for one value of the setting, a stand-in transparent_hugepage
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Written by the mesh's kernel module (systemd-modules-load reads it at boot).
|
||||
# tun: buildah's network for a RUN line (pasta) and every other user-space tunnel open /dev/net/tun.
|
||||
# Loaded at boot, so an upgrade that removes the running kernel's modules cannot keep it out later.
|
||||
tun
|
||||
@@ -100,6 +100,25 @@
|
||||
"id": "linux-lts-headers",
|
||||
"type": "package",
|
||||
"package": "linux-lts-headers"
|
||||
},
|
||||
{
|
||||
"id": "modules-load",
|
||||
"type": "file",
|
||||
"path": "/etc/modules-load.d/mesh-kernel.conf",
|
||||
"mode": "0644",
|
||||
"template": "files/modules-load.conf"
|
||||
},
|
||||
{
|
||||
"id": "modules-keep",
|
||||
"type": "package",
|
||||
"package": "kernel-modules-hook"
|
||||
},
|
||||
{
|
||||
"id": "modules-cleanup",
|
||||
"type": "service",
|
||||
"unit": "linux-modules-cleanup.service",
|
||||
"state": "stopped",
|
||||
"boot": "enabled"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# 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.
|
||||
# the copies of this package in every module that carries one.
|
||||
modules/xorg/internal/desktop/*.go
|
||||
modules/lemurs/internal/desktop/*.go
|
||||
modules/i3/internal/desktop/*.go
|
||||
modules/xterm/internal/desktop/*.go
|
||||
modules/adwaita/internal/desktop/*.go
|
||||
@@ -102,7 +102,11 @@ integration level, the postgres major and the nats-server version; and, when the
|
||||
workspace (`beside`), each one's repository and commit, which the answer echoes as `beside` (the checker keeps a verdict only
|
||||
for the siblings it was judged with). The text's form is `DigestVersion` 3. The Go version is not in it: the toolchain enters a
|
||||
fingerprint once, as the version the build nodes agree on (ADR 0477 §3), and the standard library's packages are that
|
||||
version's. A package with no tests at the level has no digest and is named in `without_tests`. A directory that vendors
|
||||
version's. A package with no tests at the level has no digest and is named in `without_tests`. A catalogue module's packages also hash the module's non-Go files (the nearest ancestor directory holding `module.json`: its manifest, README, `files/` and the rest, so `../../module.json` is covered; digest form 5). A package whose directory holds a file named `reads-outside` (any content; say what the tests read) is never kept (issue 1935): its tests read
|
||||
files outside it, which a digest from `go list` cannot name, so its digest is a fresh random one on every call, the ask finds no kept
|
||||
verdict for it and runs it, and the answer names it in `reads_outside` and the module's log says why. The checker still writes a
|
||||
verdict under that digest, which nothing ever reads again. The marker is the plain way to say it until each verdict keeps the files
|
||||
its tests opened (Go's test log). A directory that vendors
|
||||
is listed and tested from its vendor directory (`-mod=vendor`).
|
||||
|
||||
## A change to this module is judged by its candidate
|
||||
|
||||
@@ -14,6 +14,7 @@ var Kept = []announce.State{
|
||||
{Name: "toolchains", Readable: true},
|
||||
{Name: "keys", Readable: true},
|
||||
{Name: "steps", Readable: true},
|
||||
{Name: "pauses", Readable: true},
|
||||
}
|
||||
|
||||
// keepStates announces Kept as this module's (stdio.Keeps): main calls it before it serves.
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
@@ -43,6 +44,7 @@ func (m *Module) askSteps(steps []StepTool) buildstep.AskSteps {
|
||||
return delivery.ToolchainUsed{Version: installed, Recipe: m.Recipe.Version()}, nil
|
||||
},
|
||||
Kept: m.KeptLookup,
|
||||
Has: m.Has,
|
||||
Release: func(ws string) error { return m.spaces().Release(ws) },
|
||||
Logf: m.Say,
|
||||
}
|
||||
@@ -80,19 +82,31 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
|
||||
source = buildstep.SeatAsks{Language: delivery.AskGo}
|
||||
}
|
||||
capacity := m.Capacity
|
||||
// The capacity's own change wakes the puller (issue 1887): the runtime's watch of the config value as mesh-config
|
||||
// resolved it; the settings file is read only when that watch cannot start. A test's Capacity is its own.
|
||||
var watch func(string, func(stdio.ProvisionChange) error) error
|
||||
// And the CPU bounds it (issue 1806's follow-up): the configured capacity is an upper bound, the node's build slice
|
||||
// gives floor(its quota's cores / the module's cores-per-step) at once, read live; a test judges by its own Capacity.
|
||||
// And the node's build hours (a node may build only in a window): outside them it takes
|
||||
// nothing new.
|
||||
var cores func() float64
|
||||
var hours func() (*buildstep.BuildWindow, string)
|
||||
if capacity == nil {
|
||||
capacity = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, m.Say)
|
||||
hours = buildstep.NodeBuildHours(m.Settings, m.Node, m.Say)
|
||||
capacity = buildstep.NodeCapacity(m.Settings, m.Node, m.Say)
|
||||
watch = buildstep.WatchConfigServed
|
||||
cores = buildstep.NodeCoresPerStep(m.Settings, buildstep.DefaultCoresPerStep[delivery.AskGo], m.Say)
|
||||
}
|
||||
ask := m.askSteps(steps)
|
||||
var load func(string) ([]byte, error)
|
||||
if m.ReadSteps.Get != nil {
|
||||
if m.ReadPauses.Get != nil {
|
||||
load = func(key string) ([]byte, error) {
|
||||
raw, err := m.ReadSteps.Get(key)
|
||||
raw, err := m.ReadPauses.Get(key)
|
||||
return raw, err
|
||||
}
|
||||
}
|
||||
return &buildstep.Puller{Source: source, Language: delivery.AskGo, Node: m.Node, Put: m.Steps, Load: load,
|
||||
Capacity: capacity, Logf: m.Say,
|
||||
return &buildstep.Puller{Source: source, Language: delivery.AskGo, Node: m.Node, Put: m.Steps, PausePut: m.Pauses, Load: load,
|
||||
Capacity: capacity, WatchConfig: watch, CoresPerStep: cores, BuildHours: hours, Logf: m.Say, Has: m.Has,
|
||||
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
|
||||
if err := m.Check(ctx); err != nil {
|
||||
return nil, err
|
||||
@@ -100,6 +114,9 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
|
||||
if err := m.room(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.Compose != nil {
|
||||
return m.compose(ctx, ask.Fetch, a)
|
||||
}
|
||||
return buildstep.RunAsk(ctx, ask, a)
|
||||
}}
|
||||
}
|
||||
@@ -108,3 +125,18 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
|
||||
func (m *Module) SeatTools(p *buildstep.Puller) []stdio.Tool {
|
||||
return buildstep.SeatTools(Seat, "Go", p)
|
||||
}
|
||||
|
||||
// StepEndTool ends one step left running (issue 1890); the module's steps state is the one it reads and writes.
|
||||
const StepEndTool = "build_go_step_end"
|
||||
|
||||
// endLeftRunning is the module's start sweep (issue 1890): it ends the steps of its node that a previous process left
|
||||
// running (buildstep.EndStaleAtStart; every step names its owner). main runs it once at the start, and the module's test
|
||||
// runs it through the conformance kit's left-running case.
|
||||
func (m *Module) endLeftRunning(ctx context.Context, scan buildstep.Scan, booted time.Time, logf func(string, ...any)) {
|
||||
buildstep.EndStaleAtStart(ctx, scan, m.Node, booted, logf)
|
||||
}
|
||||
|
||||
// stepScan is the module's steps state as buildstep reads it.
|
||||
func (m *Module) stepScan() buildstep.Scan {
|
||||
return buildstep.ScanOf(stdio.State(delivery.StepsState))
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -14,16 +13,25 @@ import (
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/testbus"
|
||||
)
|
||||
|
||||
// checkCommit is the commit the check ask fetches, apart from the delivery ask's, so the fetch order names which ran first.
|
||||
const checkCommit = "89abcdef0123456789abcdef0123456789abcdef"
|
||||
|
||||
// 1887/AC-order, 1887/AC-pull against the real bus: a check ask published before a delivery ask runs after it, and at
|
||||
// capacity 1 the second waits in the stream until the first ends.
|
||||
//
|
||||
// **A bus of its own.** The integration level runs every directory's tests at once on one bus, and this test names the
|
||||
// build seat's stream and its fixed workers (SEAT_NODE_BUILD_GO and its workers) and deletes the stream at its end, so
|
||||
// anything else on that bus, a puller or a leftover consumer, could hold or take its delivery ask and let the check
|
||||
// ask run first. It starts from testbus (mesh-sdk, novox/hq ADR 0495 clause 5): a bus for this test alone, gone after it.
|
||||
//
|
||||
// **The order is read from the record.** At capacity 1 the puller starts the check the instant the delivery ask's step
|
||||
// ends, well before any poll, so "the check key holds a state" is true on correct behaviour (issue 1887). The test
|
||||
// waits for both asks to end and compares the step records' own Started and Ended times, and the fetch order by commit.
|
||||
func TestTheModuleTakesADeliveryAskBeforeAnEarlierCheck(t *testing.T) {
|
||||
url := os.Getenv("MESH_TEST_NATS")
|
||||
if url == "" {
|
||||
t.Skip("MESH_TEST_NATS names no bus: the integration level starts one")
|
||||
}
|
||||
nc, err := nats.Connect(url)
|
||||
nc, err := nats.Connect(testbus.URL(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -40,6 +48,7 @@ func TestTheModuleTakesADeliveryAskBeforeAnEarlierCheck(t *testing.T) {
|
||||
t.Cleanup(func() { _ = js.DeleteStream(context.Background(), "SEAT_NODE_BUILD_GO") })
|
||||
check := deliveryAsk()
|
||||
check.Kind = delivery.AskCheck
|
||||
check.Build.Fetch["commit"] = checkCommit
|
||||
first := deliveryAsk()
|
||||
for _, a := range []delivery.StepAsk{check, first} {
|
||||
raw, _ := delivery.EncodeStepAsk(a)
|
||||
@@ -68,30 +77,36 @@ func TestTheModuleTakesADeliveryAskBeforeAnEarlierCheck(t *testing.T) {
|
||||
p := m.Puller(steps, &buildstep.JetStreamAsks{JS: js, Language: delivery.AskGo})
|
||||
p.PullWait = 200 * time.Millisecond
|
||||
go p.Pull(ctx)
|
||||
for _, a := range []delivery.StepAsk{first, check} {
|
||||
key, _ := delivery.AskStepKey("ace", a.ID)
|
||||
for deadline := time.Now().Add(10 * time.Second); ; time.Sleep(10 * time.Millisecond) {
|
||||
states.mu.Lock()
|
||||
v := states.steps[key]
|
||||
states.mu.Unlock()
|
||||
if len(v) > 0 && v[len(v)-1].State != delivery.StepRunning {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("the ask %s (%s) never ended", a.ID, a.Kind)
|
||||
}
|
||||
}
|
||||
var ran map[string][]delivery.Step
|
||||
for deadline := time.Now().Add(10 * time.Second); ; time.Sleep(10 * time.Millisecond) {
|
||||
ran = map[string][]delivery.Step{}
|
||||
ended := 0
|
||||
states.mu.Lock()
|
||||
checkKey, _ := delivery.AskStepKey("ace", check.ID)
|
||||
if a.ID == first.ID && len(states.steps[checkKey]) > 0 {
|
||||
states.mu.Unlock()
|
||||
t.Fatal("the check ask started before the delivery ask ended at capacity 1")
|
||||
for _, a := range []delivery.StepAsk{first, check} {
|
||||
key, _ := delivery.AskStepKey("ace", a.ID)
|
||||
v := append([]delivery.Step(nil), states.steps[key]...)
|
||||
ran[a.ID] = v
|
||||
if len(v) > 0 && v[len(v)-1].State != delivery.StepRunning {
|
||||
ended++
|
||||
}
|
||||
}
|
||||
states.mu.Unlock()
|
||||
if ended == 2 {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("the asks never both ended: %v", ran)
|
||||
}
|
||||
}
|
||||
// The check's step may start the moment the delivery's ends, so the order is read from the records' own times.
|
||||
deliveryEnded := ran[first.ID][len(ran[first.ID])-1].Ended
|
||||
checkStarted := ran[check.ID][0].Started
|
||||
if checkStarted.Before(deliveryEnded) {
|
||||
t.Fatalf("the check ask started at %v, before the delivery ask ended at %v at capacity 1", checkStarted, deliveryEnded)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(order) != 2 {
|
||||
t.Fatalf("fetched %v", order)
|
||||
if len(order) != 2 || order[0] != askCommit || order[1] != checkCommit {
|
||||
t.Fatalf("fetched %v, want the delivery ask's commit %s first, then the check's %s", order, askCommit, checkCommit)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -125,12 +127,39 @@ func TestManifestClaimsTheGoBuildSeatAndReadsItsCapacity(t *testing.T) {
|
||||
raw, _ := os.ReadFile(moduleDir + "/module.json")
|
||||
var doc struct {
|
||||
Config map[string]struct {
|
||||
Default string `json:"default"`
|
||||
Default json.RawMessage `json:"default"`
|
||||
Pattern, Description string
|
||||
} `json:"config"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &doc)
|
||||
if doc.Config[buildstep.CapacitySetting].Default != buildstep.DefaultCapacities {
|
||||
t.Errorf("the capacity's default = %q, want %q", doc.Config[buildstep.CapacitySetting].Default, buildstep.DefaultCapacities)
|
||||
// Issue 1806's follow-up: the cores a step needs is a setting of its own, in the settings file, with the SDK's default.
|
||||
if cores := string(doc.Config[buildstep.CoresPerStepSetting].Default); cores != fmt.Sprint(buildstep.DefaultCoresPerStep[delivery.AskGo]) {
|
||||
t.Errorf("the cores-per-step default = %s, want %g", cores, buildstep.DefaultCoresPerStep[delivery.AskGo])
|
||||
}
|
||||
if got := buildstep.NodeCoresPerStep(moduleDir+"/files/settings.json", 0, nil)(); got != buildstep.DefaultCoresPerStep[delivery.AskGo] {
|
||||
t.Errorf("the settings template holds cores-per-step %g", got)
|
||||
}
|
||||
// And the build hours (the operator, 2026-10-26): a setting of their own, empty by default (any hour), in the template.
|
||||
if hours := string(doc.Config[buildstep.BuildHoursSetting].Default); hours != `""` {
|
||||
t.Errorf("the build-hours default = %s, want empty", hours)
|
||||
}
|
||||
if w, fault := buildstep.NodeBuildHours(moduleDir+"/files/settings.json", "g14", t.Errorf)(); w != nil || fault != "" {
|
||||
t.Errorf("the settings template holds build hours %v (%s)", w, fault)
|
||||
}
|
||||
// ADR 0567 §15: build-hours names no node (one window, or empty for any hour) and capacity defaults to 0.
|
||||
window := regexp.MustCompile("^(?:" + doc.Config[buildstep.BuildHoursSetting].Pattern + ")$")
|
||||
for value, ok := range map[string]bool{"": true, "18:00-06:00": true, "g14=18:00-06:00": false, "18:00": false} {
|
||||
if window.MatchString(value) != ok {
|
||||
t.Errorf("build-hours pattern on %q: accepted %v, want %v", value, !ok, ok)
|
||||
}
|
||||
}
|
||||
if strings.Contains(doc.Config[buildstep.BuildHoursSetting].Description, "=") {
|
||||
t.Errorf("the build-hours description names a node pair")
|
||||
}
|
||||
var capacity float64
|
||||
_ = json.Unmarshal(doc.Config[buildstep.CapacitySetting].Default, &capacity)
|
||||
if capacity != 0 {
|
||||
t.Errorf("the capacity's default = %g, want 0", capacity)
|
||||
}
|
||||
settings := resource(t, m, "settings")
|
||||
if settings["path"] != "${dir:state}/settings.json" {
|
||||
@@ -174,7 +203,7 @@ func TestACheckAsksDigestIsTheToolsAnswerInTheCall(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if l := out.(delivery.CheckAnswer).Levels; len(l) != 1 || !strings.Contains(string(l[0].Digest), `"x/a":"d1"`) {
|
||||
if l := out.(delivery.CheckAnswer).Levels; len(l) != 1 || l[0].Digest.Map()["x/a"] != "d1" {
|
||||
t.Fatalf("levels = %+v, want the digest's packages", l)
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user