Files
mesh-catalog/modules/gitea/cmd/repositories/repositories.go
T

573 lines
20 KiB
Go

package main
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"path"
"regexp"
"slices"
"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"
)
// noStatus is what main's rule says of statuses: none is required to merge (novox/hq ADR 0534 §1, the operator,
// 2026-10-23). The checks still run and report; mesh-delivery asks the operator's Approve on a passing verdict and the
// merge tool judges it at the merge (ADR 0534 §3, §4). The forge enforces none of them.
func noStatus() map[string]any {
return map[string]any{"enable_status_check": false, "status_check_contexts": []string{}}
}
// 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"
// StepList reads the organisation's repositories, for those the value does not list (issue 1301).
StepList = "list"
// StepNoStatus takes the required statuses off main's rule of a repository the value does not list (ADR 0534 §1).
StepNoStatus = "no-status"
)
// 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"`
}
// LeftAsSet is what a pass says when keep-main-rules is false.
const LeftAsSet = "main's rules are left as set at the forge (keep-main-rules is false): no existing repository's protection was changed but that a required status was taken away (ADR 0534 §1)"
// ReadKeepMainRules reads the setting keep-main-rules from the file the mesh renders; absent or not true is false.
func ReadKeepMainRules(file string) bool {
raw, err := os.ReadFile(file)
if err != nil {
return false
}
var whole struct {
Keep any `json:"keep-main-rules"`
}
if json.Unmarshal(raw, &whole) != nil {
return false
}
switch v := whole.Keep.(type) {
case bool:
return v
case string:
return v == "true"
}
return false
}
// 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) {
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"`
// MainRules says, when keep-main-rules is false, that no existing repository's protection was changed.
MainRules string `json:"main_rules,omitempty"`
}
// 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, 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
// KeepMainRules is the setting keep-main-rules: only when true does a pass put right the whole main rule of a repository
// that already exists (no push, the merge whitelist). False (the default) leaves every existing repository's protection as
// people set it at the forge, except that a required status is taken away once (ADR 0534 §1), and makes new repositories.
KeepMainRules bool
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)
}
if !s.KeepMainRules {
out.MainRules = LeftAsSet
s.say("%s", LeftAsSet)
}
for _, r := range s.switchUnlisted(ctx, v) {
if r.Did == DidRefused {
s.say("refused: %s/%s at the step %s: %s", s.Org, r.Name, r.Step, r.Said)
} else {
s.say("%s: %s/%s (main requires no status)", r.Did, s.Org, r.Name)
}
out.Repositories = append(out.Repositories, r)
}
return out, nil
}
// orgPage is how many repositories one read of the organisation's list asks for.
const orgPage = 50
// switchUnlisted takes every required status off a rule covering main in every repository of the organisation the
// value does not list (ADR 0534 §1): the forge requires no status to merge anywhere, with no list kept by hand. Only
// the statuses are sent; nothing else of a rule the value does not hold is changed (ADR 0428 §7). A main whose rules
// require no status gets no call. It answers only what it did: a repository put right, or refused.
func (s *Syncer) switchUnlisted(ctx context.Context, v Value) []Result {
var names []string
for page := 1; ; page++ {
var repos []struct {
Name string `json:"name"`
}
q := "?limit=" + fmt.Sprint(orgPage) + "&page=" + fmt.Sprint(page)
if err := s.Forge.GetJSON(ctx, "/api/v1/orgs/"+url.PathEscape(s.Org)+"/repos"+q, &repos); err != nil {
return []Result{refused("(the organisation's repositories)", StepList, err)}
}
for _, r := range repos {
if _, listed := v[r.Name]; !listed && r.Name != "" {
names = append(names, r.Name)
}
}
if len(repos) == 0 || page > 1000 {
break
}
}
sort.Strings(names)
var out []Result
for _, n := range names {
rules, err := s.rules(ctx, s.Org, n)
if err != nil {
out = append(out, refused(n, StepRead, err))
continue
}
var did *Result
changed, err := s.stripStatuses(ctx, s.Org, n, rules)
switch {
case err != nil:
did = &Result{Name: n, Did: DidRefused, Step: StepNoStatus, Said: err.Error()}
case changed:
did = &Result{Name: n, Did: DidPutRight}
}
if did != nil {
out = append(out, *did)
}
}
return out
}
// 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.
// stripStatuses takes every required status off each rule covering main of one repository, and nothing else of the rule (ADR
// 0534 §1): the one thing a pass does to an existing repository when keep-main-rules is false. A repository whose rules require
// none gets no call, so the statuses are taken away once. It answers whether it changed anything.
func (s *Syncer) stripStatuses(ctx context.Context, owner, name string, rules []rule) (bool, error) {
changed := false
for _, r := range rules {
if ok, _ := path.Match(ruleName(r), MainBranch); !ok || len(required(r)) == 0 {
continue
}
if err := s.Forge.SendJSON(ctx, http.MethodPatch,
repoAPI(owner, name)+"/branch_protections/"+url.PathEscape(ruleName(r)), noStatus(), nil); err != nil {
return changed, err
}
changed = true
}
return changed, nil
}
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 && !s.KeepMainRules {
rules, err := s.rules(ctx, s.Org, name)
if err != nil {
return refused(name, StepRead, err)
}
changed, err := s.stripStatuses(ctx, s.Org, name, rules)
if err != nil {
return refused(name, StepNoStatus, err)
}
if changed {
return Result{Name: name, Did: DidPutRight}
}
return Result{Name: name, Did: DidUnchanged}
}
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, s.Mergers) {
if err := s.Forge.SendJSON(ctx, http.MethodPatch, repoAPI(s.Admin, name)+"/branch_protections/"+MainBranch,
narrowed(s.Tools, s.Mergers), 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
}
// required is what a rule requires: its statuses when it checks them, else none.
func required(r rule) []string {
if r == nil || r["enable_status_check"] != true {
return nil
}
return strList(r["status_check_contexts"])
}
// 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, 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": whitelist(toolsAccount, mergers),
"block_admin_merge_override": true,
}
for k, v := range noStatus() {
body[k] = v
}
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, 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"]); !slices.Equal(m, whitelist(toolsAccount, mergers)) {
return false
}
return r["enable_status_check"] != true && len(strList(r["status_check_contexts"])) == 0 &&
r["block_admin_merge_override"] == true
}
// putRight makes main's rule in an organisation's repository the narrowed one, requiring no status (ADR 0534 §1).
// A repository with no rule named main gets one, carrying the approvals a glob rule covering main asks (client.ts
// ensureMainMerges, issue 948). It never unprotects (ADR 0428 §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, s.Mergers) {
return false, nil
}
return true, s.Forge.SendJSON(ctx, http.MethodPatch, repoAPI(owner, name)+"/branch_protections/"+MainBranch,
narrowed(s.Tools, s.Mergers), nil)
}
var covering []rule
for _, r := range rules {
if ok, _ := path.Match(ruleName(r), MainBranch); ok {
covering = append(covering, r)
}
}
body := narrowed(s.Tools, s.Mergers)
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
}