gitea: keep the operator's own login on main's merge whitelist beside mesh-tools (ADR 0534 §2)
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.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1 +1 @@
|
||||
{}
|
||||
{"main-mergers": ["jschoubben"]}
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
|
||||
@@ -172,9 +172,15 @@
|
||||
},
|
||||
"repositories": {
|
||||
"type": "object",
|
||||
"description": "The repositories of the organisation novox the mesh holds (ADR 0428): each by name, {\"<name>\": {\"private\": true, \"description\": \"...\"}}. One that does not exist is made by the module's setup login under its own account, its main protected and given a first commit (a README naming the description and a merge-check.sh), its rule narrowed to no push and the tools' merge requiring mesh/check, and only then moved into the organisation; one that exists only has main's rule put right, mesh/check in place of mesh/merge-gate and mesh/repo-check and its other statuses kept (a main requiring mesh/repo-check alone keeps it). Every other repository whose main requires mesh/merge-gate has the two swapped for mesh/check; nothing else of a repository out of the value is touched.",
|
||||
"description": "The repositories of the organisation novox the mesh holds (ADR 0428): each by name, {\"<name>\": {\"private\": true, \"description\": \"...\"}}. One that does not exist is made by the module's setup login under its own account, its main protected and given a first commit (a README naming the description 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"
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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<string, unknown> {
|
||||
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<string, unknown> {
|
||||
return { enable_push: false, enable_merge_whitelist: true, merge_whitelist_usernames: mergers, ...noStatusBody() };
|
||||
}
|
||||
|
||||
/** The forge's body for a wanted protection. */
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user