Make the organisation's repositories from the gitea module's config value, protected under the setup account before they join the organisation, and take the owner argument from gitea_create_repo, since a repository's maker becomes its administrator (issue 1124, ADR 0428)
This commit is contained in:
@@ -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`:
|
||||
`{"<name>": {"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
|
||||
|
||||
+5
-12
@@ -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<GiteaRepo> {
|
||||
const options = { method: "POST", body: JSON.stringify(data) };
|
||||
if (owner === undefined) return GiteaClient.mapRepo(await this.request<any>("/user/repos", options));
|
||||
const path = `/orgs/${pathSegment("organisation", owner)}/repos`;
|
||||
try {
|
||||
return GiteaClient.mapRepo(await this.request<any>(path, options));
|
||||
} catch (e) {
|
||||
throw new Error(`the forge did not create ${owner}/${data.name} in the organisation ${owner}: ${(e as Error).message}`);
|
||||
}
|
||||
}): Promise<GiteaRepo> {
|
||||
return GiteaClient.mapRepo(await this.request<any>("/user/repos", { method: "POST", body: JSON.stringify(data) }));
|
||||
}
|
||||
|
||||
async deleteRepo(owner: string, repo: string): Promise<void> {
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -169,6 +169,12 @@
|
||||
"description": "The read teams of the agents' forge accounts (ADR 0365 clause 3): each team by name, {\"<team>\": {\"repos\": [\"<owner>/<repo>\", ...], \"members\": [\"<node>\", ...]}}. The forge gets a team agent-read-<team> that reads only, with exactly those repositories and the accounts agent-<node>; 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, {\"<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",
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user