From 79777d0f789e14b7a996a179a3328982d415f920 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 23 Oct 2026 12:10:10 +0200 Subject: [PATCH] =?UTF-8?q?gitea:=20keep=20the=20operator's=20own=20login?= =?UTF-8?q?=20on=20main's=20merge=20whitelist=20beside=20mesh-tools=20(ADR?= =?UTF-8?q?=200534=20=C2=A72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The operator merges by hand at the forge when the mesh's own path is stuck, without changing a rule. The logins come from the trusted config value main-mergers, merged into the runtime config both programs read. --- modules/gitea/cmd/repositories/check_test.go | 49 +++++++++++++ modules/gitea/cmd/repositories/fake_test.go | 3 +- modules/gitea/cmd/repositories/main.go | 8 ++- .../gitea/cmd/repositories/manifest_test.go | 50 ++++++++++++++ .../gitea/cmd/repositories/repositories.go | 68 ++++++++++++++++--- modules/gitea/files/config.json | 2 +- modules/gitea/guard.ts | 14 ++-- modules/gitea/module.json | 17 +++-- modules/gitea/protection.ts | 30 ++++++-- modules/gitea/test/protection.test.ts | 15 ++++ 10 files changed, 227 insertions(+), 29 deletions(-) diff --git a/modules/gitea/cmd/repositories/check_test.go b/modules/gitea/cmd/repositories/check_test.go index 6663ad6f..84bf77d8 100644 --- a/modules/gitea/cmd/repositories/check_test.go +++ b/modules/gitea/cmd/repositories/check_test.go @@ -1,6 +1,8 @@ package main import ( + "os" + "path/filepath" "strings" "testing" ) @@ -212,3 +214,50 @@ func TestOrganisationListRefusedIsSaid(t *testing.T) { t.Errorf("the refused list is not in the outcome: %+v", o) } } + +// 0534-2 (the operator, 2026-10-23 ~12:05): the operator's own forge login stays on main's merge whitelist beside the +// tools' account, so the operator can always merge by hand at the forge; a rule naming the tools' account alone is put +// right, and a second pass changes nothing. +func TestTheOperatorsLoginStaysBesideTheTools(t *testing.T) { + f := newFake() + repo := f.add("novox/mesh-delivery", map[string]map[string]any{mainRule: narrowedRule()}, "main") + s, _ := syncer(t, f) + s.Mergers = []string{"jschoubben", tools, "jschoubben"} + o := run(t, s, Value{"mesh-delivery": {}, "fresh": {}}) + for name, rule := range map[string]map[string]any{"mesh-delivery": repo.Rules[mainRule], "fresh": f.repos["novox/fresh"].Rules[mainRule]} { + if got := strings.Join(strs(rule["merge_whitelist_usernames"]), ","); got != tools+",jschoubben" { + t.Errorf("%s's merge whitelist is %s, want %s and jschoubben", name, got, tools) + } + if !requiresNone(rule) || rule["enable_push"] != false { + t.Errorf("%s's rule: %v", name, rule) + } + } + if r := result(o, "mesh-delivery"); r.Did != DidPutRight { + t.Errorf("outcome %+v, want put right", r) + } + before := len(f.writes()) + run(t, s, Value{"mesh-delivery": {}, "fresh": {}}) + if w := f.writes(); len(w) != before { + t.Errorf("a second pass wrote %v", w[before:]) + } +} + +// 0534-2: the mergers are read from the module's runtime config file; a missing key is none; a name that is no forge login +// is refused. +func TestReadMergers(t *testing.T) { + p := filepath.Join(t.TempDir(), "r.json") + _ = os.WriteFile(p, []byte(`{"url":"","main-mergers":["jschoubben"]}`), 0o600) + if m, err := ReadMergers(p); err != nil || strings.Join(m, ",") != "jschoubben" { + t.Fatalf("%v %v", m, err) + } + _ = os.WriteFile(p, []byte(`{"url":""}`), 0o600) + if m, err := ReadMergers(p); err != nil || len(m) != 0 { + t.Fatalf("%v %v", m, err) + } + for _, bad := range []string{`["a b"]`, `["../x"]`, `[""]`, `"jschoubben"`} { + _ = os.WriteFile(p, []byte(`{"main-mergers":`+bad+`}`), 0o600) + if _, err := ReadMergers(p); err == nil { + t.Errorf("%s was accepted", bad) + } + } +} diff --git a/modules/gitea/cmd/repositories/fake_test.go b/modules/gitea/cmd/repositories/fake_test.go index b265057d..6eb04ecd 100644 --- a/modules/gitea/cmd/repositories/fake_test.go +++ b/modules/gitea/cmd/repositories/fake_test.go @@ -126,7 +126,8 @@ func lets(rule map[string]any, login string) bool { // and in the organisation the tools' team does (it is on every repository there); nobody else here. func reaches(full, login string) bool { owner := strings.SplitN(full, "/", 2)[0] - return owner == login || (owner == org && login == tools) + // The operator's own login is an owner of the organisation, so it reaches every repository there (ADR 0534 §2). + return owner == login || (owner == org && (login == tools || login == "jschoubben")) } // keepReaching drops from a rule's merge whitelist every user without access to the repository, as gitea's diff --git a/modules/gitea/cmd/repositories/main.go b/modules/gitea/cmd/repositories/main.go index 82d05a7d..06c38d82 100644 --- a/modules/gitea/cmd/repositories/main.go +++ b/modules/gitea/cmd/repositories/main.go @@ -30,7 +30,8 @@ func main() { func runSync() int { forge, missing := forgehttp.FromEnv() file, outcome := os.Getenv("MESH_REPOSITORIES_FILE"), os.Getenv("MESH_REPOSITORIES_OUTCOME") - for k, v := range map[string]string{"MESH_REPOSITORIES_FILE": file, "MESH_REPOSITORIES_OUTCOME": outcome} { + for k, v := range map[string]string{"MESH_REPOSITORIES_FILE": file, "MESH_REPOSITORIES_OUTCOME": outcome, + "MESH_GITEA_CONFIG_FILE": os.Getenv("MESH_GITEA_CONFIG_FILE")} { if v == "" { missing = append(missing, k) } @@ -56,6 +57,11 @@ func runSync() int { _ = WriteOutcome(outcome, o) return Report(o, os.Stderr) } + if s.Mergers, err = ReadMergers(os.Getenv("MESH_GITEA_CONFIG_FILE")); err != nil { + o := Outcome{At: time.Now().UTC(), Org: s.Org, Failed: err.Error()} + _ = WriteOutcome(outcome, o) + return Report(o, os.Stderr) + } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) defer cancel() o, _ := s.Sync(ctx, value) // a forge not reachable is in o.Failed diff --git a/modules/gitea/cmd/repositories/manifest_test.go b/modules/gitea/cmd/repositories/manifest_test.go index f92d46ce..f60422fc 100644 --- a/modules/gitea/cmd/repositories/manifest_test.go +++ b/modules/gitea/cmd/repositories/manifest_test.go @@ -64,6 +64,7 @@ func TestManifestDeclaresTheValueAndItsTrigger(t *testing.T) { if v, err := ReadValue(filled); err != nil || len(v) != 1 || v["mesh-delivery"].Description != "d" { t.Errorf("the filled template reads as %v, %v; want mesh-delivery", v, err) } + for _, r := range m.Resources { if r["type"] == "process" && has(strs(r["restart-on"]), file["id"].(string)) { process = r @@ -106,6 +107,55 @@ func TestManifestDeclaresTheValueAndItsTrigger(t *testing.T) { } } +// 0534-2: the manifest declares main-mergers as a trusted list naming the operator's login by default, merges it into +// the module's runtime config file (with url, which the TypeScript part reads), and the repositories program is given +// that file and runs again when it changes. +func TestManifestDeclaresTheMainMergers(t *testing.T) { + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m struct { + Config map[string]map[string]any `json:"config"` + Resources []map[string]any `json:"resources"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + v := m.Config[MergersKey] + if v == nil || v["type"] != "list" || v["trusted"] != true || strings.Join(strs(v["default"]), ",") != "jschoubben" { + t.Fatalf("config %s = %v, want a trusted list defaulting to the operator's login", MergersKey, v) + } + var cfg, process map[string]any + for _, r := range m.Resources { + if r["id"] == "runtime-config" { + cfg = r + } + if r["name"] == "gitea-repositories" { + process = r + } + } + if cfg == nil || !has(strs(cfg["takes-config"]), MergersKey) || cfg["merge"] != "json" { + t.Fatalf("runtime-config does not take %s: %v", MergersKey, cfg) + } + text, err := os.ReadFile("../../" + cfg["template"].(string)) + if err != nil { + t.Fatal(err) + } + file := filepath.Join(t.TempDir(), "config.json") + _ = os.WriteFile(file, text, 0o600) + if got, err := ReadMergers(file); err != nil || strings.Join(got, ",") != "jschoubben" { + t.Errorf("the template's mergers read as %v, %v; want the default", got, err) + } + env, _ := process["env"].(map[string]any) + if env["MESH_GITEA_CONFIG_FILE"] != cfg["path"] { + t.Errorf("the repositories program reads %v, want %v", env["MESH_GITEA_CONFIG_FILE"], cfg["path"]) + } + if !has(strs(process["restart-on"]), "runtime-config") { + t.Errorf("the repositories program does not run again when the mergers change: %v", process["restart-on"]) + } +} + func has(list []string, want string) bool { for _, s := range list { if s == want { diff --git a/modules/gitea/cmd/repositories/repositories.go b/modules/gitea/cmd/repositories/repositories.go index dc9c5f1a..00919f7f 100644 --- a/modules/gitea/cmd/repositories/repositories.go +++ b/modules/gitea/cmd/repositories/repositories.go @@ -11,6 +11,7 @@ import ( "os" "path" "regexp" + "slices" "sort" "strings" "time" @@ -71,6 +72,37 @@ type Repo struct { // Value is the config value: each repository of the organisation by name. type Value map[string]Repo +// MergersKey is the config value naming the forge logins that merge into main beside the tools' account (ADR 0534 §2). +const MergersKey = "main-mergers" + +// loginName is a forge login: what a merge whitelist may name. +var loginName = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.-]{0,39}$`) + +// ReadMergers reads the main-mergers value from the module's runtime config file, which the mesh merges the value into +// (module.json runtime-config); a file without the key names none. +func ReadMergers(file string) ([]string, error) { + raw, err := os.ReadFile(file) + if err != nil { + return nil, fmt.Errorf("the module's config file cannot be read (%s): %w", file, err) + } + var whole map[string]json.RawMessage + if err := json.Unmarshal(raw, &whole); err != nil { + return nil, fmt.Errorf("the module's config file %s is not JSON: %w", file, err) + } + var out []string + if r, ok := whole[MergersKey]; ok && string(r) != "null" { + if err := json.Unmarshal(r, &out); err != nil { + return nil, fmt.Errorf("%s in %s is not a list of forge logins: %w", MergersKey, file, err) + } + } + for _, n := range out { + if !loginName.MatchString(n) || strings.Contains(n, "..") { + return nil, fmt.Errorf("%s in %s lists %q, which is not a forge login", MergersKey, file, n) + } + } + return out, nil +} + // ReadValue reads the file the mesh renders from the config value. A file without the key is the empty value, // which makes nothing and touches nothing. func ReadValue(file string) (Value, error) { @@ -131,8 +163,11 @@ type Syncer struct { Forge forgehttp.Client // the setup login's Org string // the organisation, novox Admin string // the setup login, under whose own account a repository is made - Tools string // the tools' account, the only one that merges into main - Log func(format string, a ...any) + Tools string // the tools' account, which merges into main for mesh-delivery + // Mergers are the forge logins that merge into main beside the tools' account: the operator's own, so the operator + // can always merge by hand at the forge (ADR 0534 §2; the gitea module's config value main-mergers). + Mergers []string + Log func(format string, a ...any) // WaitFor is how long to wait for the forge to answer; none when zero. WaitFor, Poll time.Duration } @@ -311,9 +346,9 @@ func (s *Syncer) one(ctx context.Context, name string, r Repo) Result { return refused(name, StepFirstCommit, err) } } - if rule == nil || !isRight(rule, s.Tools) { + if rule == nil || !isRight(rule, s.Tools, s.Mergers) { if err := s.Forge.SendJSON(ctx, http.MethodPatch, repoAPI(s.Admin, name)+"/branch_protections/"+MainBranch, - narrowed(s.Tools), nil); err != nil { + narrowed(s.Tools, s.Mergers), nil); err != nil { return refused(name, StepNarrow, err) } } @@ -388,10 +423,10 @@ func required(r rule) []string { // narrowed is main's rule of ADR 0398 §2 (protection.ts mainRuleBody): no push, only the tools' account merges, no // administrator merging past the rule, as every repository's main has been since 2026-10-07 (issue 1141), and no // status required (ADR 0534 §1). A field the forge keeps beside these is left as it is. -func narrowed(toolsAccount string) map[string]any { +func narrowed(toolsAccount string, mergers []string) map[string]any { body := map[string]any{ "enable_push": false, "enable_push_whitelist": false, "push_whitelist_usernames": []string{}, - "enable_merge_whitelist": true, "merge_whitelist_usernames": []string{toolsAccount}, + "enable_merge_whitelist": true, "merge_whitelist_usernames": whitelist(toolsAccount, mergers), "block_admin_merge_override": true, } for k, v := range noStatus() { @@ -400,12 +435,23 @@ func narrowed(toolsAccount string) map[string]any { return body } +// whitelist is main's merge whitelist: the tools' account first, then each other merger once, in the value's order. +func whitelist(toolsAccount string, mergers []string) []string { + out := []string{toolsAccount} + for _, m := range mergers { + if !slices.Contains(out, m) { + out = append(out, m) + } + } + return out +} + // isRight says whether a rule already is narrowed and requires no status. -func isRight(r rule, toolsAccount string) bool { +func isRight(r rule, toolsAccount string, mergers []string) bool { if r["enable_push"] != false || r["enable_push_whitelist"] == true || r["enable_merge_whitelist"] != true { return false } - if m := strList(r["merge_whitelist_usernames"]); len(m) != 1 || m[0] != toolsAccount { + if m := strList(r["merge_whitelist_usernames"]); !slices.Equal(m, whitelist(toolsAccount, mergers)) { return false } return r["enable_status_check"] != true && len(strList(r["status_check_contexts"])) == 0 && @@ -421,11 +467,11 @@ func (s *Syncer) putRight(ctx context.Context, owner, name string) (bool, error) return false, err } if own := ruleNamed(rules, MainBranch); own != nil { - if isRight(own, s.Tools) { + if isRight(own, s.Tools, s.Mergers) { return false, nil } return true, s.Forge.SendJSON(ctx, http.MethodPatch, repoAPI(owner, name)+"/branch_protections/"+MainBranch, - narrowed(s.Tools), nil) + narrowed(s.Tools, s.Mergers), nil) } var covering []rule for _, r := range rules { @@ -433,7 +479,7 @@ func (s *Syncer) putRight(ctx context.Context, owner, name string) (bool, error) covering = append(covering, r) } } - body := narrowed(s.Tools) + body := narrowed(s.Tools, s.Mergers) body["rule_name"] = MainBranch approvals := 0.0 for _, r := range covering { diff --git a/modules/gitea/files/config.json b/modules/gitea/files/config.json index 0967ef42..7ee627f8 100644 --- a/modules/gitea/files/config.json +++ b/modules/gitea/files/config.json @@ -1 +1 @@ -{} +{"main-mergers": ["jschoubben"]} diff --git a/modules/gitea/guard.ts b/modules/gitea/guard.ts index 611eaab6..414cf46b 100644 --- a/modules/gitea/guard.ts +++ b/modules/gitea/guard.ts @@ -19,8 +19,7 @@ // // Pure, so it is tested without a forge; the tools do the asking (tools/index.ts). -import { MAIN_BRANCH, type BranchProtection, type ProtectionWanted } from "./protection.js"; -import { TOOLS_ACCOUNT } from "./tools-account.js"; +import { MAIN_BRANCH, mainMergers, type BranchProtection, type ProtectionWanted } from "./protection.js"; /** A refusal of policy, in the words the caller sees. Never an error of the forge's. */ export class Refused extends Error { @@ -319,21 +318,22 @@ export function judgeProtectionChange(before: BranchProtection | null, want: Pro `operator's, at the forge as themselves. Nothing was changed`; } -/** Main's rule takes no push and lets only the tools' account merge (novox/hq ADR 0398 §2, issue 948): a change - * that enables push, drops the merge whitelist or widens it past that account is refused, whatever the rule was. */ +/** Main's rule takes no push and lets only the tools' account and main-mergers merge (novox/hq ADR 0398 §2, issue 948, + * ADR 0534 §2): a change that enables push, drops the merge whitelist or widens it past them is refused, whatever the + * rule was. */ function judgeMain(before: BranchProtection | null, want: ProtectionWanted): string | null { const why: string[] = []; if (want.push === true) why.push("it lets a person push to the branch directly"); if (want.mergeWhitelist !== undefined) { const names = want.mergeWhitelist ?? []; if (names.length === 0) why.push("it drops the merge whitelist of main"); - else if (names.some((n) => n !== TOOLS_ACCOUNT)) { - why.push(`it widens who may merge into main beyond ${TOOLS_ACCOUNT}`); + else if (names.some((n) => !mainMergers().includes(n))) { + why.push(`it widens who may merge into main beyond ${mainMergers().join(", ")}`); } } if (!why.length) return null; return `the protection of ${before?.rule_name ?? before?.branch_name ?? "main"} is only made stronger through a tool: ` + - `${why.join("; ")}. Main takes no push, and only ${TOOLS_ACCOUNT} merges into it (novox/hq ADR 0398 §2) — ` + + `${why.join("; ")}. Main takes no push, and only ${mainMergers().join(" and ")} merge into it (novox/hq ADR 0398 §2, ADR 0534 §2) — ` + `weakening that is the operator's, at the forge as themselves. Nothing was changed`; } diff --git a/modules/gitea/module.json b/modules/gitea/module.json index 279529fb..27e22764 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -172,9 +172,15 @@ }, "repositories": { "type": "object", - "description": "The repositories of the organisation novox the mesh holds (ADR 0428): each by name, {\"\": {\"private\": true, \"description\": \"...\"}}. One that does not exist is made by the module's setup login under its own account, its main protected and given a first commit (a README naming the description and a merge-check.sh), its rule narrowed to no push and the tools' merge requiring mesh/check, and only then moved into the organisation; one that exists only has main's rule put right, mesh/check in place of mesh/merge-gate and mesh/repo-check and its other statuses kept (a main requiring mesh/repo-check alone keeps it). Every other repository whose main requires mesh/merge-gate has the two swapped for mesh/check; nothing else of a repository out of the value is touched.", + "description": "The repositories of the organisation novox the mesh holds (ADR 0428): each by name, {\"\": {\"private\": true, \"description\": \"...\"}}. One that does not exist is made by the module's setup login under its own account, its main protected and given a first commit (a README naming the description and a merge-check.sh), its rule narrowed to no push, the tools' account and main-mergers alone merging, and no status required (ADR 0534), and only then moved into the organisation; one that exists only has main's rule put right so. Every other repository has the statuses its main requires taken off; nothing else of a repository out of the value is touched.", "trusted": true, "default": {} + }, + "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.", + "trusted": true, + "default": ["jschoubben"] } }, "facts": { @@ -303,7 +309,8 @@ "template": "files/config.json", "merge": "json", "takes-config": [ - "url" + "url", + "main-mergers" ] }, { @@ -366,10 +373,12 @@ "MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret", "MESH_GITEA_TOOLS_USER": "mesh-tools", "MESH_REPOSITORIES_FILE": "${dir:mesh-state}/repositories.json", - "MESH_REPOSITORIES_OUTCOME": "${dir:runtime-state}/repositories-outcome.json" + "MESH_REPOSITORIES_OUTCOME": "${dir:runtime-state}/repositories-outcome.json", + "MESH_GITEA_CONFIG_FILE": "${dir:mesh-state}/config.json" }, "restart-on": [ - "repositories-file" + "repositories-file", + "runtime-config" ] } ], diff --git a/modules/gitea/protection.ts b/modules/gitea/protection.ts index 9631175a..f749c470 100644 --- a/modules/gitea/protection.ts +++ b/modules/gitea/protection.ts @@ -3,6 +3,7 @@ // merge tool judge them (ADR 0534 §3, §4), never the forge. Pure, so it is tested without a forge; the client does the // asking (client.ts). +import { readFileSync } from "node:fs"; import { TOOLS_ACCOUNT } from "./tools-account.js"; /** The branch whose rule takes no push and lets only the tools' account merge (novox/hq ADR 0398 §2, issue 948). */ @@ -39,10 +40,31 @@ export function noStatusBody(): Record { return { enable_status_check: false, status_check_contexts: [] }; } -/** What main's rule always says: no push, only the tools' account merges (novox/hq ADR 0398 §2, issue 948), and no - * status required (ADR 0534 §1). */ -export function mainRuleBody(): Record { - return { enable_push: false, enable_merge_whitelist: true, merge_whitelist_usernames: [TOOLS_ACCOUNT], ...noStatusBody() }; +/** A forge login a merge whitelist may name. */ +const LOGIN = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,39}$/; + +/** Who merges into main: the tools' account, for mesh-delivery, then each login of the module's config value + * main-mergers once, the operator's own, so the operator can always merge by hand at the forge (novox/hq ADR 0534 §2). + * Read from the module's runtime config file (MESH_GITEA_CONFIG_FILE); a file not there, or a name that is no login, + * leaves the tools' account alone, the narrowest whitelist. */ +export function mainMergers(env: NodeJS.ProcessEnv = process.env): string[] { + const out = [TOOLS_ACCOUNT]; + let given: unknown = []; + try { + if (env.MESH_GITEA_CONFIG_FILE) given = JSON.parse(readFileSync(env.MESH_GITEA_CONFIG_FILE, "utf8"))["main-mergers"] ?? []; + } catch { + given = []; + } + for (const name of Array.isArray(given) ? given : []) { + if (typeof name === "string" && LOGIN.test(name) && !name.includes("..") && !out.includes(name)) out.push(name); + } + return out; +} + +/** What main's rule always says: no push, only the tools' account and main-mergers merge (novox/hq ADR 0398 §2, + * issue 948, ADR 0534 §2), and no status required (ADR 0534 §1). */ +export function mainRuleBody(mergers: string[] = mainMergers()): Record { + return { enable_push: false, enable_merge_whitelist: true, merge_whitelist_usernames: mergers, ...noStatusBody() }; } /** The forge's body for a wanted protection. */ diff --git a/modules/gitea/test/protection.test.ts b/modules/gitea/test/protection.test.ts index 1e59fc4c..5f7779e0 100644 --- a/modules/gitea/test/protection.test.ts +++ b/modules/gitea/test/protection.test.ts @@ -48,3 +48,18 @@ test("the rule main is put right in the forge's own words: no push, the tools' a assert.deepEqual(mainRuleBody(), { enable_push: false, enable_merge_whitelist: true, merge_whitelist_usernames: ["mesh-tools"], enable_status_check: false, status_check_contexts: [] }); }); + +// The operator's own login stays on main's merge whitelist beside the tools' account (novox/hq ADR 0534 §2): read from +// the module's config value main-mergers, in the runtime config file; nothing there leaves the tools' account alone. +test("main's merge whitelist is the tools' account, then the configured mergers once each", async () => { + const { mainMergers, mainRuleBody } = await import("../dist/protection.js"); + const { mkdtempSync, writeFileSync } = await import("node:fs"); + const { join } = await import("node:path"); + const { tmpdir } = await import("node:os"); + const file = join(mkdtempSync(join(tmpdir(), "gitea-mergers-")), "config.json"); + writeFileSync(file, JSON.stringify({ url: "", "main-mergers": ["jschoubben", "mesh-tools", "jschoubben", "../x", "a b"] })); + assert.deepEqual(mainMergers({ MESH_GITEA_CONFIG_FILE: file }), ["mesh-tools", "jschoubben"]); + assert.deepEqual(mainMergers({}), ["mesh-tools"]); + assert.deepEqual(mainMergers({ MESH_GITEA_CONFIG_FILE: join(tmpdir(), "no-such-gitea-config.json") }), ["mesh-tools"]); + assert.deepEqual(mainRuleBody(["mesh-tools", "jschoubben"]).merge_whitelist_usernames, ["mesh-tools", "jschoubben"]); +});