mesh/merge-gate head 54db38be0896 merged into trunk 4301da630d82 — pass: builds gitea → novox; no bus step; every machine composes with the change as it di…
mesh/repo-check head 54db38be0896 merged into trunk 4301da630d82 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
narrowed never set block_admin_merge_override, so novox/mesh-delivery's main let an administrator merge past mesh/merge-gate and mesh/repo-check, unlike every other repository's main since 2026-10-07. narrowed sets it and isRight requires it, so the module's next start puts mesh-delivery right.
422 lines
14 KiB
Go
422 lines
14 KiB
Go
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"
|
|
// StepRuleAfterMove reads main's rule back once the repository is in the organisation and puts it right: the
|
|
// forge drops from a merge whitelist a user without access, and under the setup account the tools' have none.
|
|
StepRuleAfterMove = "rule-after-move"
|
|
)
|
|
|
|
// 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, kept in the outcome file and said on stderr (outcome.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)
|
|
}
|
|
if _, err := s.putRight(ctx, s.Org, name); err != nil {
|
|
return refused(name, StepRuleAfterMove, 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), and no administrator merging past them, as every repository's main has been
|
|
// since 2026-10-07 (issue 1141). 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...)...),
|
|
"block_admin_merge_override": true,
|
|
}
|
|
}
|
|
|
|
// 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"])) &&
|
|
r["block_admin_merge_override"] == true
|
|
}
|
|
|
|
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 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
|
|
}
|