diff --git a/modules/gitea/README.md b/modules/gitea/README.md index 5592f0d6..002f0c57 100644 --- a/modules/gitea/README.md +++ b/modules/gitea/README.md @@ -140,6 +140,33 @@ the value is emptied, never deleted. A team without that prefix is never touched change of the value. It says each change in one line on stdout, and never a secret. `go test ./cmd/agent-teams` runs it against a fake forge. +## The organisation's repositories (novox/hq ADR 0428, issue 1124) + +Which repositories the organisation `novox` holds is one trusted config value, `repositories`: +`{"": {"private": true, "description": "..."}}`, changed only at the controller's terminal or on an approval +that proves who answered, as `agent-read-teams` is. The module renders it into `repositories.json` and runs +`cmd/repositories sync` when it starts and whenever that file changes. With the setup login (`mesh-admin`), for each +listed repository: + +- one that is not in the organisation and not under the setup account is made there, **empty and under the setup + account**, where neither the tools' team nor the agents' team (both on every repository of the organisation) + reaches it. Main gets a rule letting the setup login alone push; main's first commit is written with it: a README + naming the description, and a `merge-check.sh` declaring `# mesh-check-toolchain: go` that vets and runs the unit + tests, then the integration tests (`-tags integration`), of every Go module in the tree, and says so and passes on + a tree with none. The rule is then narrowed to main's rule of ADR 0398 §2 (no push, only `mesh-tools` merges), + requiring `mesh/repo-check` and `mesh/merge-gate`, and only then is the repository moved into the organisation; +- one found still under the setup account is finished: the steps it lacks are done, main is never written twice; +- one in the organisation is only put right: main's rule narrowed, a missing one of the two statuses added and every + status already there kept. + +A step the forge refuses stops that repository until the next pass, and the others go on. A repository taken out +of the value is never asked about: nothing is deleted, renamed, moved out or unprotected. The pass keeps what it did +per repository in `repositories-outcome.json`; the bundle, run by the tool runtime, serves `repositories_health`, +which judges it: unhealthy, naming the repository, the step and the forge's answer, when a step was refused. +No tool creates in the organisation: `gitea_create_repo` creates under the tools' own user only, the tools' team +keeps `can_create_org_repo` false and its token has no `write:organization` (ADR 0428 §8). +`go test ./cmd/repositories` runs it against a fake forge. + ## The package registry's retention (novox/hq ADR 0251 §4, to-be 51) Every publish adds a version to the registry, and until this nothing removed one. The Go bundle diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index 9aedbbad..49ec9d52 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -244,23 +244,16 @@ export class GiteaClient { return all; } - /** A repository, created under the tools' own user, or with `owner` in that organisation (novox/hq issue 1124). - * The forge's refusal to create in an organisation (403 not allowed, 404 no such organisation, 409 exists) is - * thrown with its status and its reason, naming the organisation. */ + /** A repository, created under the tools' own user and nowhere else (novox/hq ADR 0428 §8): the forge makes whoever + * creates an organisation's repository its administrator, so the organisation's repositories are made by the + * module's setup login from its config value `repositories` (cmd/repositories), never through a tool. */ async createRepo(data: { name: string; description?: string; private?: boolean; auto_init?: boolean; - }, owner?: string): Promise { - const options = { method: "POST", body: JSON.stringify(data) }; - if (owner === undefined) return GiteaClient.mapRepo(await this.request("/user/repos", options)); - const path = `/orgs/${pathSegment("organisation", owner)}/repos`; - try { - return GiteaClient.mapRepo(await this.request(path, options)); - } catch (e) { - throw new Error(`the forge did not create ${owner}/${data.name} in the organisation ${owner}: ${(e as Error).message}`); - } + }): Promise { + return GiteaClient.mapRepo(await this.request("/user/repos", { method: "POST", body: JSON.stringify(data) })); } async deleteRepo(owner: string, repo: string): Promise { diff --git a/modules/gitea/cmd/repositories/first.go b/modules/gitea/cmd/repositories/first.go new file mode 100644 index 00000000..b1303299 --- /dev/null +++ b/modules/gitea/cmd/repositories/first.go @@ -0,0 +1,43 @@ +package main + +import "strings" + +// MergeCheck is the first commit's merge-check.sh (ADR 0428 §4): it declares the Go toolchain (ADR 0238 §4, read +// among a script's first lines by the build seat), and runs the unit tests, then with -tags integration the +// integration tests, of every Go module in the tree; on a tree with none it says so and passes. +const MergeCheck = `#!/bin/sh +# mesh-check-toolchain: go +# +# This repository's own check, mesh/repo-check (novox/hq ADR 0238 §4), written with its first commit (ADR 0428 §4): +# every Go module in the tree is vetted, its unit tests run, and then its integration tests (-tags integration). +# A tree with no Go module has nothing to test, and the check says so and passes. +set -eu +cd "$(dirname "$0")" + +modules=$(find . -name go.mod -not -path '*/vendor/*' -not -path './.git/*' | sort) +if [ -z "$modules" ]; then + echo "This tree holds no Go module, so there are no tests to run; the check passes." + exit 0 +fi +for mod in $modules; do + dir=$(dirname "$mod") + echo "== $dir: go vet and the unit tests" + (cd "$dir" && go vet ./... && go test ./...) + echo "== $dir: the integration tests (-tags integration)" + (cd "$dir" && go vet -tags integration ./... && go test -tags integration ./...) +done +` + +// FirstCommit is main's first commit of a listed repository: its README, naming the repository's purpose, and +// its own check. +func FirstCommit(name string, r Repo) map[string]string { + purpose := strings.TrimSpace(r.Description) + if purpose == "" { + purpose = "(its purpose is not described yet: the gitea module's config value `repositories` describes it)" + } + readme := "# " + name + "\n\n" + purpose + "\n\n" + + "Made by the mesh from the gitea module's config value `repositories` (novox/hq ADR 0428). Main takes no push: " + + "a change reaches it by a pull request whose `mesh/repo-check` (this repository's `merge-check.sh`) and " + + "`mesh/merge-gate` passed.\n" + return map[string]string{"README.md": readme, "merge-check.sh": MergeCheck} +} diff --git a/modules/gitea/cmd/repositories/health.go b/modules/gitea/cmd/repositories/health.go new file mode 100644 index 00000000..55bfdcce --- /dev/null +++ b/modules/gitea/cmd/repositories/health.go @@ -0,0 +1,82 @@ +package main + +// The pass's health (ADR 0428 §6, ADR 0240), judged from the outcome it left, as restic-backups judges its nights +// (modules/restic/cmd/restic-backups/health.go Judge): unhealthy when a step of a listed repository was refused, +// naming the repository, the step and the forge's answer; when the pass could not run; or when no pass has left an +// outcome. Read through the tool repositories_health, which a health check of kind tool asks. + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/health" +) + +// WriteOutcome keeps the outcome where the health check reads it, whole or not at all. +func WriteOutcome(file string, o Outcome) error { + raw, err := json.MarshalIndent(o, "", " ") + if err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(file), ".repositories-*") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + if _, err := tmp.Write(append(raw, '\n')); err != nil { + tmp.Close() + return err + } + if err := tmp.Chmod(0o644); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmp.Name(), file) +} + +// ReadOutcome reads what the last pass left. +func ReadOutcome(file string) (*Outcome, error) { + raw, err := os.ReadFile(file) + if err != nil { + return nil, err + } + var o Outcome + if err := json.Unmarshal(raw, &o); err != nil { + return nil, fmt.Errorf("the outcome %s is not readable: %w", file, err) + } + return &o, nil +} + +func clock(t time.Time) string { return t.Local().Format("2006-01-02 15:04") } + +// Judge is the health of what the last pass left. Pure. +func Judge(o *Outcome, readErr error, now time.Time) health.Answer { + switch { + case errors.Is(readErr, os.ErrNotExist): + return health.Unhealthy("no pass over the config value repositories has left its outcome yet; the module runs " + + "one at its start: read the journal of gitea-repositories (node-service-manager.journal)") + case readErr != nil: + return health.Unhealthy(fmt.Sprintf("what the last pass over the config value repositories left does not read (%v)", readErr)) + case o.Failed != "": + return health.Unhealthy(fmt.Sprintf("the pass of %s over the config value repositories did not run: %s. Nothing "+ + "was made; it runs again when the module starts or the value changes", clock(o.At), o.Failed)) + } + var said []string + for _, r := range o.Refused() { + said = append(said, fmt.Sprintf("%s/%s: the step %s was refused by the forge (%s)", o.Org, r.Name, r.Step, r.Said)) + } + if len(said) > 0 { + return health.Unhealthy(strings.Join(said, "; ") + ". Nothing further of it was done; the next pass (the " + + "module's start, or a change of the value) takes it up again") + } + return health.Healthy(fmt.Sprintf("the %d repositories the config value lists are made, their main protected (pass of %s)", + len(o.Repositories), clock(o.At))) +} diff --git a/modules/gitea/cmd/repositories/main.go b/modules/gitea/cmd/repositories/main.go new file mode 100644 index 00000000..f8018c41 --- /dev/null +++ b/modules/gitea/cmd/repositories/main.go @@ -0,0 +1,95 @@ +// repositories: the organisation's repositories from the gitea module's config value `repositories` (novox/hq +// ADR 0428, issue 1124). One binary, two ways to run, as restic-backups is (modules/restic/cmd/restic-backups): +// +// - `sync`, a process the node-engine runs when the module starts and again whenever the value changes: it reads +// the file the mesh renders from that value and, with the setup login, makes every listed repository that does +// not exist under that login's own account, protects its main, writes main's first commit, narrows main's rule +// and only then moves it into the organisation; one that exists only has its main rule put right. A repository +// the value does not list is never touched. It keeps what it did, per repository, in the outcome file, says each +// change on stdout in one line and never a secret, and exits 1 when a step was refused; +// - with no argument, launched by the machine's tool runtime and speaking MCP to it over stdio: the tool +// repositories_health, the health of the last pass, judged from the outcome file (health.go). +package main + +import ( + "context" + "fmt" + "os" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + "gitea/internal/forgehttp" +) + +func main() { + if len(os.Args) > 1 && os.Args[1] == "sync" { + os.Exit(runSync()) + } + if err := stdio.Serve("", Tools(os.Getenv("MESH_REPOSITORIES_OUTCOME"))); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +// Tools are the bundle's tools: the health of the last pass. +func Tools(outcome string) []stdio.Tool { + return []stdio.Tool{{ + Name: "repositories_health", + Description: "Whether the organisation's repositories the gitea module's config value `repositories` lists " + + "are made and their main protected (novox/hq ADR 0428): unhealthy, naming the repository, the step and " + + "the forge's answer, when the last pass had a step refused, or when no pass has run. (r)", + Run: func(map[string]any) (any, error) { + if outcome == "" { + return nil, fmt.Errorf("MESH_REPOSITORIES_OUTCOME is not set by the manifest") + } + o, err := ReadOutcome(outcome) + return Judge(o, err, time.Now()), nil + }, + }} +} + +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} { + if v == "" { + missing = append(missing, k) + } + } + if forge.TokenFile != "" { + // The tools' account cannot make or protect a repository, and must not (ADR 0428 §8). + missing = append(missing, "the setup login (MESH_GITEA_TOKEN_FILE is set instead)") + } + if len(missing) > 0 { + fmt.Fprintf(os.Stderr, "repositories: %v not set by the manifest; nothing was changed\n", missing) + return 1 + } + toolsAccount := os.Getenv("MESH_GITEA_TOOLS_USER") + if toolsAccount == "" { + toolsAccount = forgehttp.ToolsAccount + } + s := &Syncer{Forge: forge, Org: "novox", Admin: forge.User, Tools: toolsAccount, + Log: func(format string, a ...any) { fmt.Printf(format+"\n", a...) }, + WaitFor: 3 * time.Minute, Poll: 5 * time.Second} + value, err := ReadValue(file) + if err != nil { + fmt.Fprintln(os.Stderr, "repositories:", err) + _ = WriteOutcome(outcome, Outcome{At: time.Now().UTC(), Org: s.Org, Failed: err.Error()}) + return 1 + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + o, err := s.Sync(ctx, value) + if werr := WriteOutcome(outcome, o); werr != nil { + fmt.Fprintln(os.Stderr, "repositories: the outcome could not be kept:", werr) + return 1 + } + if err != nil { + fmt.Fprintln(os.Stderr, "repositories:", err) + return 1 + } + if len(o.Refused()) > 0 { + return 1 + } + return 0 +} diff --git a/modules/gitea/cmd/repositories/repositories.go b/modules/gitea/cmd/repositories/repositories.go new file mode 100644 index 00000000..b15b34fd --- /dev/null +++ b/modules/gitea/cmd/repositories/repositories.go @@ -0,0 +1,415 @@ +package main + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "path" + "regexp" + "sort" + "strings" + "time" + + "gitea/internal/forgehttp" +) + +const ( + // ConfigKey is the module's config value this program applies (ADR 0428 §1). + ConfigKey = "repositories" + // MainBranch is the branch whose rule takes no push and lets only the tools' account merge (ADR 0398 §2, + // protection.ts MAIN_BRANCH). + MainBranch = "main" +) + +// RequiredStatuses are what main requires in every listed repository from its first commit (ADR 0428 §5): the +// repository's own check and the module graph's gate. A status already on the rule is kept beside them. +var RequiredStatuses = []string{"mesh/merge-gate", "mesh/repo-check"} + +// The steps of one repository, as an outcome names a refused one (ADR 0428 §6). +const ( + StepRead = "read" + StepCreate = "create" + StepAdminRule = "admin-rule" + StepFirstCommit = "first-commit" + StepNarrow = "narrow" + StepMove = "move" + StepPutRight = "put-right" +) + +// What a pass did with one repository. +const ( + DidMade = "made" + DidPutRight = "put right" + DidUnchanged = "unchanged" + DidRefused = "refused" +) + +// repoName is a repository name the forge takes and that is never another path. +var repoName = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.-]{0,99}$`) + +// Repo is one listed repository: whether it is private, and its description, which its README names as its purpose. +type Repo struct { + Private bool `json:"private"` + Description string `json:"description"` +} + +// Value is the config value: each repository of the organisation by name. +type Value map[string]Repo + +// 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) { + raw, err := os.ReadFile(file) + if err != nil { + return nil, fmt.Errorf("the repositories 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 repositories file %s is not JSON: %w", file, err) + } + v := Value{} + if r, ok := whole[ConfigKey]; ok { + dec := json.NewDecoder(strings.NewReader(string(r))) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return nil, fmt.Errorf("%s in %s is not {name: {private, description}}: %w", ConfigKey, file, err) + } + } + for name := range v { + if !repoName.MatchString(name) || strings.Contains(name, "..") || strings.HasSuffix(name, ".git") { + return nil, fmt.Errorf("%s in %s lists %q, which is not a repository name", ConfigKey, file, name) + } + } + return v, nil +} + +// Result is what a pass did with one repository; a refusal names its step and the forge's answer. +type Result struct { + Name string `json:"name"` + Did string `json:"did"` + Step string `json:"step,omitempty"` + Said string `json:"said,omitempty"` +} + +// Outcome is what a pass left, for the health check to judge (health.go). +type Outcome struct { + At time.Time `json:"at"` + Org string `json:"org"` + Failed string `json:"failed,omitempty"` // the pass did not run: why + Repositories []Result `json:"repositories"` +} + +// Refused names the repositories a step of was refused. +func (o Outcome) Refused() []Result { + var out []Result + for _, r := range o.Repositories { + if r.Did == DidRefused { + out = append(out, r) + } + } + return out +} + +// Syncer makes every listed repository of the organisation, with the setup login, and puts main's rule right in +// those that exist. A repository the value does not list is never asked about (ADR 0428 §7). +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) + // WaitFor is how long to wait for the forge to answer; none when zero. + WaitFor, Poll time.Duration +} + +func (s *Syncer) say(format string, a ...any) { + if s.Log != nil { + s.Log(format, a...) + } +} + +// Sync applies the value. The error is only the forge not being reachable; a refused step is in the outcome. +func (s *Syncer) Sync(ctx context.Context, v Value) (Outcome, error) { + out := Outcome{At: time.Now().UTC(), Org: s.Org, Repositories: []Result{}} + if err := s.waitForForge(ctx); err != nil { + out.Failed = err.Error() + return out, err + } + names := make([]string, 0, len(v)) + for n := range v { + names = append(names, n) + } + sort.Strings(names) + for _, n := range names { + r := s.one(ctx, n, v[n]) + if r.Did == DidRefused { + s.say("refused: %s/%s at the step %s: %s", s.Org, n, r.Step, r.Said) + } else if r.Did != DidUnchanged { + s.say("%s: %s/%s", r.Did, s.Org, n) + } + out.Repositories = append(out.Repositories, r) + } + return out, nil +} + +// waitForForge is cmd/agent-teams' (teams.go waitForForge): the forge starting with the module answers late. +func (s *Syncer) waitForForge(ctx context.Context) error { + deadline := time.Now().Add(s.WaitFor) + for { + err := s.Forge.GetJSON(ctx, "/api/v1/version", nil) + if err == nil { + return nil + } + var se *forgehttp.StatusError + if errors.As(err, &se) || time.Now().After(deadline) || ctx.Err() != nil { + return fmt.Errorf("the forge is not reachable: %w", err) + } + time.Sleep(s.Poll) + } +} + +func refused(name, step string, err error) Result { + return Result{Name: name, Did: DidRefused, Step: step, Said: err.Error()} +} + +func repoAPI(owner, name string) string { + return "/api/v1/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(name) +} + +// exists says whether owner/name is on the forge. +func (s *Syncer) exists(ctx context.Context, owner, name string) (bool, error) { + err := s.Forge.GetJSON(ctx, repoAPI(owner, name), nil) + if errors.Is(err, forgehttp.ErrNotFound) { + return false, nil + } + return err == nil, err +} + +// one is one listed repository (ADR 0428 §2, §3): put right when it is in the organisation; else made, or finished +// when found under the setup account, and moved in only once main's rule is narrowed. A refused step stops it. +func (s *Syncer) one(ctx context.Context, name string, r Repo) Result { + inOrg, err := s.exists(ctx, s.Org, name) + if err != nil { + return refused(name, StepRead, err) + } + if inOrg { + changed, err := s.putRight(ctx, s.Org, name) + if err != nil { + return refused(name, StepPutRight, err) + } + if changed { + return Result{Name: name, Did: DidPutRight} + } + return Result{Name: name, Did: DidUnchanged} + } + held, err := s.exists(ctx, s.Admin, name) + if err != nil { + return refused(name, StepRead, err) + } + if !held { + // Empty, so main exists only once the rule below is on it (ADR 0428 §3). + if err := s.Forge.SendJSON(ctx, http.MethodPost, "/api/v1/user/repos", map[string]any{ + "name": name, "private": r.Private, "description": r.Description, "auto_init": false, "default_branch": MainBranch, + }, nil); err != nil { + return refused(name, StepCreate, err) + } + } + rules, err := s.rules(ctx, s.Admin, name) + if err != nil { + return refused(name, StepRead, err) + } + rule := ruleNamed(rules, MainBranch) + if rule == nil { + body := map[string]any{"rule_name": MainBranch, "enable_push": true, "enable_push_whitelist": true, + "push_whitelist_usernames": []string{s.Admin}} + if err := s.Forge.SendJSON(ctx, http.MethodPost, repoAPI(s.Admin, name)+"/branch_protections", body, nil); err != nil { + return refused(name, StepAdminRule, err) + } + } + hasMain, err := s.branchExists(ctx, s.Admin, name, MainBranch) + if err != nil { + return refused(name, StepRead, err) + } + if !hasMain { + if err := s.firstCommit(ctx, name, r); err != nil { + return refused(name, StepFirstCommit, err) + } + } + if rule == nil || !isRight(rule, s.Tools) { + if err := s.Forge.SendJSON(ctx, http.MethodPatch, repoAPI(s.Admin, name)+"/branch_protections/"+MainBranch, + narrowed(rule, s.Tools), nil); err != nil { + return refused(name, StepNarrow, err) + } + } + if err := s.Forge.SendJSON(ctx, http.MethodPost, repoAPI(s.Admin, name)+"/transfer", + map[string]any{"new_owner": s.Org}, nil); err != nil { + return refused(name, StepMove, err) + } + return Result{Name: name, Did: DidMade} +} + +// firstCommit writes main's first commit: the README and the repository's own check (ADR 0428 §4). +func (s *Syncer) firstCommit(ctx context.Context, name string, r Repo) error { + files := FirstCommit(name, r) + paths := make([]string, 0, len(files)) + for p := range files { + paths = append(paths, p) + } + sort.Strings(paths) + var ops []map[string]any + for _, p := range paths { + ops = append(ops, map[string]any{"operation": "create", "path": p, + "content": base64.StdEncoding.EncodeToString([]byte(files[p]))}) + } + return s.Forge.SendJSON(ctx, http.MethodPost, repoAPI(s.Admin, name)+"/contents", map[string]any{ + "branch": MainBranch, "message": "Begin " + name + " with its README and its own check (ADR 0428)", "files": ops, + }, nil) +} + +// rule is a branch protection rule as the forge answers it; only the fields read here are typed. +type rule map[string]any + +func ruleNamed(rules []rule, name string) rule { + for _, r := range rules { + if ruleName(r) == name { + return r + } + } + return nil +} + +func ruleName(r rule) string { + if n, _ := r["rule_name"].(string); n != "" { + return n + } + n, _ := r["branch_name"].(string) + return n +} + +func strList(v any) []string { + var out []string + if l, ok := v.([]any); ok { + for _, s := range l { + if t, ok := s.(string); ok && strings.TrimSpace(t) != "" { + out = append(out, t) + } + } + } + return out +} + +// statuses is what a rule requires, with RequiredStatuses added and none dropped. +func statuses(rules ...rule) []string { + seen := map[string]bool{} + var out []string + add := func(s string) { + if !seen[s] { + seen[s] = true + out = append(out, s) + } + } + for _, r := range rules { + if r != nil && r["enable_status_check"] == true { + for _, s := range strList(r["status_check_contexts"]) { + add(s) + } + } + } + for _, s := range RequiredStatuses { + add(s) + } + return out +} + +// narrowed is main's rule of ADR 0398 §2 (protection.ts mainRuleBody): no push, only the tools' account merges, +// requiring the statuses (ADR 0428 §5). A field the forge keeps beside these is left as it is. +func narrowed(r rule, toolsAccount string, also ...rule) map[string]any { + return map[string]any{ + "enable_push": false, "enable_push_whitelist": false, "push_whitelist_usernames": []string{}, + "enable_merge_whitelist": true, "merge_whitelist_usernames": []string{toolsAccount}, + "enable_status_check": true, "status_check_contexts": statuses(append([]rule{r}, also...)...), + } +} + +// isRight says whether a rule already is narrowed and requires both statuses. +func isRight(r rule, toolsAccount 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 { + return false + } + return r["enable_status_check"] == true && hasAll(strList(r["status_check_contexts"])) +} + +func hasAll(have []string) bool { + for _, want := range RequiredStatuses { + found := false + for _, h := range have { + found = found || h == want + } + if !found { + return false + } + } + return true +} + +// putRight makes main's rule in an organisation's repository the narrowed one, keeping every status it requires. +// A repository with no rule named main gets one, carrying what a glob rule covering main asks (client.ts +// ensureMainMerges, issue 948). It never takes a status away and never unprotects (ADR 0428 §5, §7). +func (s *Syncer) putRight(ctx context.Context, owner, name string) (bool, error) { + rules, err := s.rules(ctx, owner, name) + if err != nil { + return false, err + } + if own := ruleNamed(rules, MainBranch); own != nil { + if isRight(own, s.Tools) { + return false, nil + } + return true, s.Forge.SendJSON(ctx, http.MethodPatch, repoAPI(owner, name)+"/branch_protections/"+MainBranch, + narrowed(own, s.Tools), nil) + } + var covering []rule + for _, r := range rules { + if ok, _ := path.Match(ruleName(r), MainBranch); ok { + covering = append(covering, r) + } + } + body := narrowed(nil, s.Tools, covering...) + body["rule_name"] = MainBranch + approvals := 0.0 + for _, r := range covering { + if n, _ := r["required_approvals"].(float64); n > approvals { + approvals = n + } + if r["block_admin_merge_override"] == true { + body["block_admin_merge_override"] = true + } + } + if approvals > 0 { + body["required_approvals"] = approvals + } + return true, s.Forge.SendJSON(ctx, http.MethodPost, repoAPI(owner, name)+"/branch_protections", body, nil) +} + +func (s *Syncer) rules(ctx context.Context, owner, name string) ([]rule, error) { + var out []rule + if err := s.Forge.GetJSON(ctx, repoAPI(owner, name)+"/branch_protections", &out); err != nil { + return nil, err + } + return out, nil +} + +func (s *Syncer) branchExists(ctx context.Context, owner, name, branch string) (bool, error) { + err := s.Forge.GetJSON(ctx, repoAPI(owner, name)+"/branches/"+url.PathEscape(branch), nil) + if errors.Is(err, forgehttp.ErrNotFound) { + return false, nil + } + return err == nil, err +} diff --git a/modules/gitea/files/repositories.json b/modules/gitea/files/repositories.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/modules/gitea/files/repositories.json @@ -0,0 +1 @@ +{} diff --git a/modules/gitea/module.json b/modules/gitea/module.json index ca4f4618..04711e2b 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -169,6 +169,12 @@ "description": "The read teams of the agents' forge accounts (ADR 0365 clause 3): each team by name, {\"\": {\"repos\": [\"/\", ...], \"members\": [\"\", ...]}}. The forge gets a team agent-read- that reads only, with exactly those repositories and the accounts agent-; a team taken out of the value is emptied. Nothing else on the forge is touched; an agent account is in no team until it is listed here.", "trusted": true, "default": {} + }, + "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/repo-check and mesh/merge-gate, and only then moved into the organisation; one that exists only has main's rule put right, its statuses kept. A repository taken out of the value is never touched.", + "trusted": true, + "default": {} } }, "facts": { @@ -311,6 +317,17 @@ "agent-read-teams" ] }, + { + "id": "repositories-file", + "type": "file", + "path": "${dir:mesh-state}/repositories.json", + "mode": "0600", + "template": "files/repositories.json", + "merge": "json", + "takes-config": [ + "repositories" + ] + }, { "id": "consumers", "type": "file", @@ -336,6 +353,28 @@ "restart-on": [ "agent-teams-file" ] + }, + { + "id": "repositories", + "type": "process", + "name": "gitea-repositories", + "artifact": "repositories", + "run": [ + "./repositories", + "sync" + ], + "run-once": true, + "env": { + "MESH_GITEA_URL": "http://127.0.0.1:${port:3000}", + "MESH_GITEA_ADMIN_USER": "mesh-admin", + "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" + }, + "restart-on": [ + "repositories-file" + ] } ], "provides": [ @@ -460,6 +499,20 @@ "agent-teams" ] }, + { + "name": "repositories", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/repositories", + "binary": "repositories", + "loads": [ + "repositories" + ], + "env": { + "MESH_REPOSITORIES_OUTCOME": "${dir:runtime-state}/repositories-outcome.json" + } + }, { "name": "forge-account-provider", "kind": "bundle", diff --git a/modules/gitea/tools/index.ts b/modules/gitea/tools/index.ts index 772a124e..eba1b8e1 100644 --- a/modules/gitea/tools/index.ts +++ b/modules/gitea/tools/index.ts @@ -82,10 +82,9 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini }, { name: "gitea_create_repo", - description: "Create a repository: with owner, in that organisation (the forge's refusal said with its status " + - "and reason); without owner, under the tools' own user.", + description: "Create a repository under the tools' own user. A repository of the mesh's own is made by the gitea " + + "module from its config value repositories, never by a tool (novox/hq ADR 0428 §8).", input: { - owner: { type: "string", description: "an organisation to create the repository in (default: the tools' own user)" }, name: { type: "string", description: "the repository name" }, description: { type: "string", description: "an optional description" }, private: { type: "boolean", description: "private repo (default true)" }, @@ -97,7 +96,7 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini description: args.description ? String(args.description) : undefined, private: args.private === undefined ? true : Boolean(args.private), auto_init: args.auto_init === undefined ? true : Boolean(args.auto_init), - }, args.owner === undefined ? undefined : String(args.owner)); + }); return { repo }; }, },