462 lines
16 KiB
Go
462 lines
16 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
|
)
|
|
|
|
// What mesh-delivery asks of others, and where it keeps what it owns. **It never sends to a machine, writes
|
|
// a declaration, a grant or a bus object, and never registers a build** (novox/hq ADR 0239 decision 6): it
|
|
// asks the controller, through the controller's seat, and the forge's holder, through its tools.
|
|
|
|
// ControllerSeat is the seat whose verbs the controller serves.
|
|
const ControllerSeat = "mesh-controller"
|
|
|
|
// Member is one delivery as the controller's planner takes it (mesh-controller orderMember).
|
|
type Member struct {
|
|
ID string `json:"id"`
|
|
Repository string `json:"repository"`
|
|
Base string `json:"base,omitempty"`
|
|
Head string `json:"head,omitempty"`
|
|
Number int `json:"number,omitempty"`
|
|
Paths []string `json:"paths,omitempty"`
|
|
PathsTruncated bool `json:"paths_truncated,omitempty"`
|
|
Removed []string `json:"removed,omitempty"`
|
|
ModuleDirs []string `json:"module_dirs,omitempty"`
|
|
ModuleDirsSaid bool `json:"module_dirs_said,omitempty"`
|
|
CloneURL string `json:"clone_url,omitempty"`
|
|
// Ready is whether the operator's Approve of its merge is asked or given: its check is published on the ready stream (ADR 0406 §3, 0421 §10).
|
|
Ready bool `json:"ready,omitempty"`
|
|
// BaseSHA is the base branch's head the forge last announced the head with (novox/hq ADR 0323): the trunk commit
|
|
// the controller's verdict names when no build judges the head.
|
|
BaseSHA string `json:"base_sha,omitempty"`
|
|
}
|
|
|
|
// MemberOf is a delivery as a member.
|
|
func MemberOf(d *Delivery) Member {
|
|
return Member{Ready: d.Ready != nil && d.Ready.Merged == nil && (d.Ready.AskID != "" || d.Ready.Approved != nil), ID: d.ID, Repository: d.Repository, Base: d.Base, Head: d.Commit, Number: d.Number, Paths: d.Paths,
|
|
PathsTruncated: d.PathsTruncated, Removed: d.Removed, ModuleDirs: d.ModuleDirs, ModuleDirsSaid: d.ModuleDirsSaid,
|
|
CloneURL: d.CloneURL, BaseSHA: d.BaseSHA}
|
|
}
|
|
|
|
// Controller is the controller's verbs this owner asks with.
|
|
type Controller interface {
|
|
// Plan is the delivery plan of a diffset, kept nowhere.
|
|
Plan(repository, base, head string, paths, moduleDirs, removed []string) (*DeliveryPlan, error)
|
|
// Check asks a group's composed check, or — with no group and one member — that head's own check again.
|
|
Check(group string, members []Member) (string, error)
|
|
// Deliver lets a waiting walk start.
|
|
Deliver(walk, why string) error
|
|
// BackupsAgain asks the controller to take a held walk's failed backups again, and to go on when they pass
|
|
// (novox/hq ADR 0404 clause 4).
|
|
BackupsAgain(walk, why string) error
|
|
// Stop ends a walk, said as stopped by whom.
|
|
Stop(walk, why, by string) error
|
|
// StopWithout ends a walk and walks its other merges again without the merges named, each
|
|
// `<repository>@<commit>`, and without each later merge of their repository, which contains one (novox/hq
|
|
// ADR 0299).
|
|
StopWithout(walk, why, by string, without []string) (StopAnswer, error)
|
|
// Walks are the walks the controller keeps — one, given its id — and whether this seat has a holder on record.
|
|
Walks(walk string) (bool, []Walk, error)
|
|
// ForgetChecks lets go of the builds a pull request's merge checks made for its merge (novox/hq ADR 0350 §5).
|
|
ForgetChecks(owner, repo string, number int) error
|
|
}
|
|
|
|
// Forge is what the forge's holder does for a delivery.
|
|
type Forge interface {
|
|
// Statuses reads a commit's statuses — a pull request's head's, given its number — and whether the branch
|
|
// it merges into would let it merge. Reads only.
|
|
Statuses(owner, repo string, number int, commit, base string) (*ForgeChecks, error)
|
|
// Contains is whether a commit is contained in another — an ancestor of it, or it. Reads only.
|
|
Contains(owner, repo, commit, in string) (bool, error)
|
|
// OpenPulls is every open pull request, with what its base requires and what its head has (novox/hq issue
|
|
// 347). Reads only.
|
|
OpenPulls() ([]OpenPull, error)
|
|
}
|
|
|
|
// ForgeChecks is what the forge says of a commit's checks (the gitea module's gitea_commit_statuses).
|
|
type ForgeChecks struct {
|
|
Commit string `json:"commit"`
|
|
Pull *ForgePull `json:"pull,omitempty"`
|
|
Combined string `json:"combined,omitempty"`
|
|
Statuses []ForgeStatus `json:"statuses"`
|
|
Merge ForgeMerge `json:"merge"`
|
|
}
|
|
|
|
// ForgePull is a pull request as the forge says it.
|
|
type ForgePull struct {
|
|
Number int `json:"number"`
|
|
Title string `json:"title,omitempty"`
|
|
State string `json:"state,omitempty"`
|
|
Merged bool `json:"merged,omitempty"`
|
|
Base string `json:"base,omitempty"`
|
|
Head string `json:"head,omitempty"`
|
|
HeadSHA string `json:"head_sha,omitempty"`
|
|
MergeCommit string `json:"merge_commit_sha,omitempty"`
|
|
HTMLURL string `json:"html_url,omitempty"`
|
|
}
|
|
|
|
// ForgeStatus is one status of a commit, the newest of its context.
|
|
type ForgeStatus struct {
|
|
Context string `json:"context"`
|
|
State string `json:"state"`
|
|
Description string `json:"description"`
|
|
TargetURL string `json:"target_url,omitempty"`
|
|
CreatedAt string `json:"created_at,omitempty"`
|
|
UpdatedAt string `json:"updated_at,omitempty"`
|
|
Creator string `json:"creator,omitempty"`
|
|
}
|
|
|
|
// ForgeMerge is whether the branch's protection lets the commit merge: every required status a success.
|
|
// Mergeable is nil when the protection could not be read.
|
|
type ForgeMerge struct {
|
|
Branch string `json:"branch"`
|
|
Protected bool `json:"protected"`
|
|
Required []string `json:"required"`
|
|
Blocking []struct {
|
|
Context string `json:"context"`
|
|
State string `json:"state"`
|
|
} `json:"blocking"`
|
|
Mergeable *bool `json:"mergeable"`
|
|
Says string `json:"says"`
|
|
}
|
|
|
|
// Store keeps deliveries and groups: one key each, in the module's own state (ADR 0201).
|
|
type Store interface {
|
|
PutDelivery(d *Delivery) error
|
|
DeleteDelivery(id string) error
|
|
Deliveries() ([]*Delivery, error)
|
|
PutGroup(g *Group) error
|
|
DeleteGroup(id string) error
|
|
Groups() ([]*Group, error)
|
|
}
|
|
|
|
// --- over the runtime -----------------------------------------------------------------------------------
|
|
|
|
// asking is how a tool is asked: stdio.Ask, or a test's.
|
|
type asking func(key string, body any) (json.RawMessage, error)
|
|
|
|
// seatController asks the controller's seat through the runtime.
|
|
type seatController struct{ ask asking }
|
|
|
|
// answerOf reads a verb's answer whatever wraps it: the controller's {output, ok, answer}, a text the
|
|
// runtime handed over, or the protocol's content list.
|
|
func answerOf(raw json.RawMessage) (json.RawMessage, string, bool) {
|
|
var s string
|
|
if json.Unmarshal(raw, &s) == nil {
|
|
return answerOf(json.RawMessage(s))
|
|
}
|
|
var m map[string]json.RawMessage
|
|
if json.Unmarshal(raw, &m) != nil {
|
|
return raw, string(raw), true
|
|
}
|
|
if content, has := m["content"]; has {
|
|
var items []struct {
|
|
Text string `json:"text"`
|
|
}
|
|
var e struct {
|
|
IsError bool `json:"isError"`
|
|
}
|
|
_ = json.Unmarshal(raw, &e)
|
|
if json.Unmarshal(content, &items) == nil && len(items) > 0 {
|
|
inner, out, ok := answerOf(json.RawMessage(items[0].Text))
|
|
return inner, out, ok && !e.IsError
|
|
}
|
|
}
|
|
var v struct {
|
|
Output string `json:"output"`
|
|
OK *bool `json:"ok"`
|
|
Answer json.RawMessage `json:"answer"`
|
|
}
|
|
if json.Unmarshal(raw, &v) == nil && v.OK != nil {
|
|
return v.Answer, v.Output, *v.OK
|
|
}
|
|
return raw, string(raw), true
|
|
}
|
|
|
|
func lastLine(s string) string {
|
|
lines := strings.Split(strings.TrimSpace(s), "\n")
|
|
return strings.TrimSpace(lines[len(lines)-1])
|
|
}
|
|
|
|
func (c seatController) verb(name string, args map[string]any) (json.RawMessage, error) {
|
|
raw, err := c.ask("seat:"+ControllerSeat+"."+name, args)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
answer, output, ok := answerOf(raw)
|
|
if !ok {
|
|
return nil, fmt.Errorf("the controller refused %s: %s", name, lastLine(output))
|
|
}
|
|
return answer, nil
|
|
}
|
|
|
|
// Plan asks the controller's `plans` for the plan of a change as data (novox/hq ADR 0450 §2): the document the old
|
|
// per-change verb answered, now the what-if of the plans every person reads as text.
|
|
func (c seatController) Plan(repository, base, head string, paths, moduleDirs, removed []string) (*DeliveryPlan, error) {
|
|
args := map[string]any{"repository": repository, "paths": strings.Join(paths, ","), "data": "true"}
|
|
if base != "" {
|
|
args["base"] = base
|
|
}
|
|
if head != "" {
|
|
args["head"] = head
|
|
}
|
|
if len(moduleDirs) > 0 {
|
|
args["module-dirs"] = strings.Join(moduleDirs, ",")
|
|
}
|
|
if len(removed) > 0 {
|
|
args["removed"] = strings.Join(removed, ",")
|
|
}
|
|
raw, err := c.verb("plans", args)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var a struct {
|
|
Plan *DeliveryPlan `json:"plan"`
|
|
}
|
|
if err := json.Unmarshal(raw, &a); err != nil || a.Plan == nil {
|
|
return nil, errors.New("the controller's plans answered no plan as data")
|
|
}
|
|
return a.Plan, nil
|
|
}
|
|
|
|
func (c seatController) Check(group string, members []Member) (string, error) {
|
|
args := map[string]any{"members": string(mustJSON(members))}
|
|
if group != "" {
|
|
args["group"] = group
|
|
}
|
|
raw, err := c.verb("delivery-check", args)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var a struct {
|
|
Asked string `json:"asked"`
|
|
Rechecked string `json:"rechecked"`
|
|
}
|
|
_ = json.Unmarshal(raw, &a)
|
|
if a.Asked != "" {
|
|
return a.Asked, nil
|
|
}
|
|
if a.Rechecked != "" {
|
|
return a.Rechecked, nil
|
|
}
|
|
return "", fmt.Errorf("the controller's delivery-check answered no check id (%s): it is not taken as asked", clip(string(raw), 200))
|
|
}
|
|
|
|
func (c seatController) Deliver(walk, why string) error {
|
|
_, err := c.verb("deliver", map[string]any{"plan": walk, "why": why})
|
|
return err
|
|
}
|
|
|
|
func (c seatController) BackupsAgain(walk, why string) error {
|
|
_, err := c.verb("delivery-backups-again", map[string]any{"plan": walk, "why": why})
|
|
return err
|
|
}
|
|
|
|
func (c seatController) ForgetChecks(owner, repo string, number int) error {
|
|
_, err := c.verb("forget-checks", map[string]any{"owner": owner, "repo": repo, "number": strconv.Itoa(number)})
|
|
return err
|
|
}
|
|
|
|
func (c seatController) Stop(walk, why, by string) error {
|
|
_, err := c.verb("delivery-stop", map[string]any{"plan": walk, "why": why, "by": by})
|
|
return err
|
|
}
|
|
|
|
// StopAnswer is what the controller's `delivery-stop` with merges to leave out answers (novox/hq ADR 0299): the walk
|
|
// stopped, the batch that walks its other merges again with those merges, and every merge left out.
|
|
type StopAnswer struct {
|
|
Stopped string `json:"stopped"`
|
|
WalkedAgainBy string `json:"walked_again_by,omitempty"`
|
|
WalkedAgain []string `json:"walked_again,omitempty"`
|
|
LeftOut []WalkLeftOut `json:"left_out,omitempty"`
|
|
// Kept are the modules the stopped walk sent that nothing walks again, on the machines named; Withheld the
|
|
// modules the walk that walks the others withholds; StillCarriedBy the merges elsewhere that will deliver a
|
|
// stopped change when walked — a later merge of its repository, or one moving its modules (ADR 0299).
|
|
Kept []WalkKept `json:"kept,omitempty"`
|
|
Withheld []string `json:"withheld,omitempty"`
|
|
StillCarriedBy []string `json:"still_carried_by,omitempty"`
|
|
}
|
|
|
|
// WalkKept is a module a stopped walk left running its build, and where.
|
|
type WalkKept struct {
|
|
Module string `json:"module"`
|
|
Machines []string `json:"machines"`
|
|
}
|
|
|
|
func (c seatController) StopWithout(walk, why, by string, without []string) (StopAnswer, error) {
|
|
raw, err := c.verb("delivery-stop", map[string]any{"plan": walk, "why": why, "by": by,
|
|
"without": strings.Join(without, ",")})
|
|
if err != nil {
|
|
return StopAnswer{}, err
|
|
}
|
|
var a StopAnswer
|
|
if json.Unmarshal(raw, &a) != nil || a.Stopped == "" {
|
|
return StopAnswer{}, fmt.Errorf("the controller's delivery-stop did not say what it left out: %s",
|
|
lastLine(string(raw)))
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
func (c seatController) Walks(walk string) (bool, []Walk, error) {
|
|
args := map[string]any{}
|
|
if walk != "" {
|
|
args["plan"] = walk
|
|
}
|
|
raw, err := c.verb("delivery-walks", args)
|
|
if err != nil {
|
|
return false, nil, err
|
|
}
|
|
var a struct {
|
|
Held bool `json:"held"`
|
|
Walks []Walk `json:"walks"`
|
|
}
|
|
if err := json.Unmarshal(raw, &a); err != nil {
|
|
return false, nil, fmt.Errorf("the controller's delivery-walks is not readable: %w", err)
|
|
}
|
|
return a.Held, a.Walks, nil
|
|
}
|
|
|
|
// toolForge asks the forge's holder through its tools.
|
|
type toolForge struct{ ask asking }
|
|
|
|
func (f toolForge) tool(name string, args map[string]any) error {
|
|
raw, err := f.ask("gitea."+name, args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, output, ok := answerOf(raw); !ok {
|
|
return fmt.Errorf("the forge refused %s: %s", name, lastLine(output))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f toolForge) Statuses(owner, repo string, number int, commit, base string) (*ForgeChecks, error) {
|
|
args := map[string]any{"owner": owner, "repo": repo}
|
|
if number > 0 {
|
|
args["number"] = number
|
|
}
|
|
if commit != "" {
|
|
args["sha"] = commit
|
|
}
|
|
if base != "" {
|
|
args["base"] = base
|
|
}
|
|
raw, err := f.ask("gitea.gitea_commit_statuses", args)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
answer, output, ok := answerOf(raw)
|
|
if !ok {
|
|
return nil, fmt.Errorf("the forge refused gitea_commit_statuses: %s", lastLine(output))
|
|
}
|
|
var c ForgeChecks
|
|
if err := json.Unmarshal(answer, &c); err != nil || c.Commit == "" {
|
|
return nil, fmt.Errorf("the forge's gitea_commit_statuses is not readable: %s", clip(string(answer), 200))
|
|
}
|
|
return &c, nil
|
|
}
|
|
|
|
func (f toolForge) OpenPulls() ([]OpenPull, error) {
|
|
raw, err := f.ask("gitea.gitea_open_pulls", map[string]any{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
answer, output, ok := answerOf(raw)
|
|
if !ok {
|
|
return nil, fmt.Errorf("the forge refused gitea_open_pulls: %s", lastLine(output))
|
|
}
|
|
var open struct {
|
|
Pulls []OpenPull `json:"pulls"`
|
|
}
|
|
if err := json.Unmarshal(answer, &open); err != nil || open.Pulls == nil {
|
|
return nil, fmt.Errorf("the forge's gitea_open_pulls is not readable: %s", clip(string(answer), 200))
|
|
}
|
|
return open.Pulls, nil
|
|
}
|
|
|
|
func (f toolForge) Contains(owner, repo, commit, in string) (bool, error) {
|
|
raw, err := f.ask("gitea.gitea_contains", map[string]any{"owner": owner, "repo": repo, "commit": commit, "in": in})
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
answer, output, ok := answerOf(raw)
|
|
if !ok {
|
|
return false, fmt.Errorf("the forge refused gitea_contains: %s", lastLine(output))
|
|
}
|
|
var a struct {
|
|
Contains *bool `json:"contains"`
|
|
}
|
|
if err := json.Unmarshal(answer, &a); err != nil || a.Contains == nil {
|
|
return false, fmt.Errorf("the forge's gitea_contains is not readable: %s", clip(string(answer), 200))
|
|
}
|
|
return *a.Contains, nil
|
|
}
|
|
|
|
// kvStore is the module's own state: `deliveries` and `groups`, one key each.
|
|
type kvStore struct{}
|
|
|
|
// kvKey is an id as a bucket key: only the characters a key may carry.
|
|
func kvKey(id string) string {
|
|
var b strings.Builder
|
|
for _, r := range id {
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == '=':
|
|
b.WriteRune(r)
|
|
default:
|
|
b.WriteString("_" + fmt.Sprintf("%02x", r) + "_")
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func (kvStore) PutDelivery(d *Delivery) error {
|
|
_, err := stdio.State("deliveries").Put(kvKey(d.ID), d)
|
|
return err
|
|
}
|
|
func (kvStore) DeleteDelivery(id string) error { return stdio.State("deliveries").Delete(kvKey(id)) }
|
|
func (kvStore) PutGroup(g *Group) error {
|
|
_, err := stdio.State("groups").Put(kvKey(g.ID), g)
|
|
return err
|
|
}
|
|
func (kvStore) DeleteGroup(id string) error { return stdio.State("groups").Delete(kvKey(id)) }
|
|
|
|
// readAll reads every key of a state but the ones it is told to leave (the gate's mark `carried` is no delivery).
|
|
func readAll[T any](state string, leave ...string) ([]*T, error) {
|
|
keys, err := stdio.State(state).Keys()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sort.Strings(keys)
|
|
var out []*T
|
|
for _, k := range keys {
|
|
if slices.Contains(leave, k) {
|
|
continue
|
|
}
|
|
e, err := stdio.State(state).Get(k)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if e == nil {
|
|
continue
|
|
}
|
|
var v T
|
|
if err := json.Unmarshal(e.Value, &v); err != nil {
|
|
logf("[mesh-delivery] %s %s cannot be read (%v); left as it is", state, k, err)
|
|
continue
|
|
}
|
|
out = append(out, &v)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (kvStore) Deliveries() ([]*Delivery, error) { return readAll[Delivery]("deliveries", carriedKey) }
|
|
func (kvStore) Groups() ([]*Group, error) { return readAll[Group]("groups") }
|