mesh/delivery merged: source.main moved to novox/mesh-catalog@27a7757b970e
mesh/check pass 24b244bc88dc on 27a7757b970e | carried from 6b6d5cba7195 | unit 2046 run, 2327 kept, 6 skipped | integration 2066 run, 2346 kept, 10 s…
car-hunter #2 was approved and its commit status then refused 403. Before the ask the gate reads merge.writes for each repository it covers; where the merging account cannot write, nothing is asked, the delivery waits on 'the forge account cannot write <repo>: gitea_grant_write' and a condition is raised. gitea.repo.writable reads it again and asks an approved, refused merge of it again.
404 lines
12 KiB
Go
404 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// What a test stands the holder on: a store in memory that can be made to fail, a controller that records
|
|
// what it was asked and answers what it is told, a forge that records every note, view and status.
|
|
|
|
type memStore struct {
|
|
mu sync.Mutex
|
|
deliveries map[string][]byte
|
|
groups map[string][]byte
|
|
failing bool
|
|
// puts counts the deliveries written, failed or not.
|
|
puts int
|
|
}
|
|
|
|
func newMemStore() *memStore {
|
|
return &memStore{deliveries: map[string][]byte{}, groups: map[string][]byte{}}
|
|
}
|
|
|
|
func (s *memStore) PutDelivery(d *Delivery) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.puts++
|
|
if s.failing {
|
|
return errors.New("the bus is away")
|
|
}
|
|
s.deliveries[d.ID] = mustJSON(d)
|
|
return nil
|
|
}
|
|
func (s *memStore) DeleteDelivery(id string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
delete(s.deliveries, id)
|
|
return nil
|
|
}
|
|
func (s *memStore) PutGroup(g *Group) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.failing {
|
|
return errors.New("the bus is away")
|
|
}
|
|
s.groups[g.ID] = mustJSON(g)
|
|
return nil
|
|
}
|
|
func (s *memStore) DeleteGroup(id string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
delete(s.groups, id)
|
|
return nil
|
|
}
|
|
func (s *memStore) Deliveries() ([]*Delivery, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
var out []*Delivery
|
|
for key, raw := range s.deliveries {
|
|
if key == carriedKey { // the mark an earlier owner's carry-over wrote, as the real store skips it
|
|
continue
|
|
}
|
|
var d Delivery
|
|
if err := json.Unmarshal(raw, &d); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, &d)
|
|
}
|
|
return out, nil
|
|
}
|
|
func (s *memStore) Groups() ([]*Group, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
var out []*Group
|
|
for _, raw := range s.groups {
|
|
var g Group
|
|
if err := json.Unmarshal(raw, &g); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, &g)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
type fakeForge struct {
|
|
mu sync.Mutex
|
|
notes map[string][]string
|
|
views map[string]string
|
|
statuses map[string]string
|
|
// statusWrites counts the statuses set.
|
|
statusWrites int
|
|
down bool
|
|
// viewErr is what View answers instead, when set: a pull request the forge no longer holds.
|
|
viewErr error
|
|
viewCalls int
|
|
// What Statuses reads: the pull requests by owner/repo#number, the base's required statuses, whether the
|
|
// protection cannot be read, and what it was asked.
|
|
pulls map[string]ForgePull
|
|
// unwritable are the repositories (owner/repo) the forge's merging account cannot write (issue 1945).
|
|
unwritable map[string]bool
|
|
required []string
|
|
protectionDown bool
|
|
asked []string
|
|
// contains answers gitea_contains by "<commit> in <in>"; a pair it does not hold is a forge that cannot say.
|
|
contains map[string]bool
|
|
containsDown bool
|
|
containsAsks []string
|
|
// open is what OpenPulls answers; openDown a forge that cannot say.
|
|
open []OpenPull
|
|
openDown bool
|
|
openCalls int
|
|
// merges and comments are what was asked of the forge for an approved merge (novox/hq issue 722); approvedHeads the
|
|
// heads each merge was handed as approved (ADR 0421 §9).
|
|
merges, comments []string
|
|
approvedHeads [][]string
|
|
refuse error
|
|
// refuseRepo refuses the merges of one repository only ("owner/name"), beside refuse (issue 1049).
|
|
refuseRepo map[string]error
|
|
// calls are the comments in the order asked.
|
|
calls []string
|
|
}
|
|
|
|
func (f *fakeForge) OpenPulls() ([]OpenPull, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.openCalls++
|
|
if f.openDown {
|
|
return nil, errors.New("the forge is away")
|
|
}
|
|
return append([]OpenPull(nil), f.open...), nil
|
|
}
|
|
|
|
func newFakeForge() *fakeForge {
|
|
return &fakeForge{notes: map[string][]string{}, views: map[string]string{}, statuses: map[string]string{},
|
|
pulls: map[string]ForgePull{}, contains: map[string]bool{}}
|
|
}
|
|
|
|
func (f *fakeForge) Note(owner, repo, commit, line string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.down {
|
|
return errors.New("the forge is away")
|
|
}
|
|
key := owner + "/" + repo + "@" + commit
|
|
for _, l := range f.notes[key] {
|
|
if l == line {
|
|
return nil // appended once
|
|
}
|
|
}
|
|
f.notes[key] = append(f.notes[key], line)
|
|
return nil
|
|
}
|
|
func (f *fakeForge) View(owner, repo string, number int, body string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.viewCalls++
|
|
if f.viewErr != nil {
|
|
return f.viewErr
|
|
}
|
|
if f.down {
|
|
return errors.New("the forge is away")
|
|
}
|
|
f.views[fmt.Sprintf("%s/%s#%d", owner, repo, number)] = body
|
|
return nil
|
|
}
|
|
func (f *fakeForge) Status(owner, repo, commit, context, state, description, target string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.down {
|
|
return errors.New("the forge is away")
|
|
}
|
|
f.statusWrites++
|
|
f.statuses[owner+"/"+repo+"@"+commit+" "+context] = state + " " + description + " → " + target
|
|
return nil
|
|
}
|
|
|
|
// Statuses answers what the fake was told: a pull request's head from pulls, the statuses set on the commit
|
|
// (those Status set too, as the forge would keep them), and the base's required statuses from required.
|
|
func (f *fakeForge) Statuses(owner, repo string, number int, commit, base string) (*ForgeChecks, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.down {
|
|
return nil, errors.New("the forge is away")
|
|
}
|
|
f.asked = append(f.asked, fmt.Sprintf("%s/%s#%d@%s base=%s", owner, repo, number, commit, base))
|
|
out := &ForgeChecks{}
|
|
if number > 0 {
|
|
if p, ok := f.pulls[fmt.Sprintf("%s/%s#%d", owner, repo, number)]; ok {
|
|
out.Pull = &p
|
|
if commit == "" {
|
|
commit = p.HeadSHA
|
|
}
|
|
if base == "" {
|
|
base = p.Base
|
|
}
|
|
} else if commit == "" {
|
|
return nil, fmt.Errorf("no pull request %d", number)
|
|
}
|
|
}
|
|
for key, said := range f.statuses {
|
|
at, context, _ := strings.Cut(key, " ")
|
|
if !strings.HasPrefix(at, owner+"/"+repo+"@") || !sameCommit(strings.TrimPrefix(at, owner+"/"+repo+"@"), commit) {
|
|
continue
|
|
}
|
|
if len(strings.TrimPrefix(at, owner+"/"+repo+"@")) > len(commit) {
|
|
commit = strings.TrimPrefix(at, owner+"/"+repo+"@")
|
|
}
|
|
state, rest, _ := strings.Cut(said, " ")
|
|
description, link, _ := strings.Cut(rest, " → ")
|
|
out.Statuses = append(out.Statuses, ForgeStatus{Context: context, State: state, Description: description,
|
|
TargetURL: link, UpdatedAt: "2026-10-06T12:00:00Z", Creator: "mesh-admin"})
|
|
}
|
|
sort.Slice(out.Statuses, func(i, j int) bool { return out.Statuses[i].Context < out.Statuses[j].Context })
|
|
out.Commit = commit
|
|
if base == "" {
|
|
base = "main"
|
|
}
|
|
out.Merge = ForgeMerge{Branch: base, Protected: f.required != nil, Required: f.required}
|
|
if f.unwritable[owner+"/"+repo] {
|
|
no := false
|
|
out.Merge.Writes, out.Merge.CannotWrite = &no, "the forge account cannot write "+owner+"/"+repo+": gitea_grant_write"
|
|
}
|
|
if f.protectionDown {
|
|
out.Merge.Says = "the protection of " + base + " could not be read"
|
|
return out, nil
|
|
}
|
|
ok := true
|
|
for _, r := range f.required {
|
|
state := "missing"
|
|
for _, st := range out.Statuses {
|
|
if st.Context == r {
|
|
state = st.State
|
|
}
|
|
}
|
|
if state != "success" {
|
|
ok = false
|
|
out.Merge.Blocking = append(out.Merge.Blocking, struct {
|
|
Context string `json:"context"`
|
|
State string `json:"state"`
|
|
}{r, state})
|
|
}
|
|
}
|
|
out.Merge.Mergeable = &ok
|
|
out.Merge.Says = fmt.Sprintf("%s requires %v", base, f.required)
|
|
return out, nil
|
|
}
|
|
|
|
// Contains answers what the fake was told of two commits.
|
|
func (f *fakeForge) Contains(owner, repo, commit, in string) (bool, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.containsAsks = append(f.containsAsks, commit+" in "+in)
|
|
if f.containsDown || f.down {
|
|
return false, errors.New("the forge is away")
|
|
}
|
|
yes, said := f.contains[commit+" in "+in]
|
|
if !said {
|
|
return false, fmt.Errorf("the forge has no commit %s or %s", commit, in)
|
|
}
|
|
return yes, nil
|
|
}
|
|
|
|
func (f *fakeForge) notesOn(commit string) []string {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
for key, lines := range f.notes {
|
|
if strings.HasSuffix(key, "@"+commit) {
|
|
return append([]string(nil), lines...)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// world is a holder over fakes, with a clock a test moves.
|
|
type world struct {
|
|
t *testing.T
|
|
h *Holder
|
|
store *memStore
|
|
forge *fakeForge
|
|
op *fakeOperator
|
|
merges *fakeMerges
|
|
source *fakeSource
|
|
owed *fakeOwed
|
|
// asked is the key of the ask a test answered.
|
|
asked string
|
|
now time.Time
|
|
logbuf strings.Builder
|
|
said []map[string]any
|
|
mu sync.Mutex
|
|
}
|
|
|
|
func newWorld(t *testing.T) *world {
|
|
return newWorldOver(t, newMemStore(), newFakeForge(), time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC))
|
|
}
|
|
|
|
func newWorldOver(t *testing.T, store *memStore, forge *fakeForge, now time.Time) *world {
|
|
w := &world{t: t, store: store, forge: forge, now: now}
|
|
w.h = &Holder{Store: store, Forge: forge,
|
|
Now: func() time.Time { w.mu.Lock(); defer w.mu.Unlock(); return w.now },
|
|
Logf: func(format string, a ...any) { t.Logf(format, a...); fmt.Fprintf(&w.logbuf, format+"\n", a...) },
|
|
Emit: func(event string, body any) error {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
var m map[string]any
|
|
_ = json.Unmarshal(mustJSON(body), &m)
|
|
m["event-kind"] = event
|
|
w.said = append(w.said, m)
|
|
return nil
|
|
}}
|
|
// The operator approves at once unless a test says otherwise (w.op.auto = false): most tests are not about the Approve.
|
|
w.op = newFakeOperator(w.h.Now)
|
|
w.op.auto = true
|
|
w.h.Operator = w.op
|
|
w.merges, w.source = newFakeMerges(forge), &fakeSource{}
|
|
w.h.Merges, w.h.Source = w.merges, w.source
|
|
w.merges.h = w.h
|
|
w.owed = newFakeOwed(forge)
|
|
w.h.Owed = w.owed
|
|
w.h.init()
|
|
if err := w.h.Load(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return w
|
|
}
|
|
|
|
func (w *world) later(d time.Duration) {
|
|
w.mu.Lock()
|
|
w.now = w.now.Add(d)
|
|
w.mu.Unlock()
|
|
}
|
|
|
|
// settleAll takes a look a few times: the answers the teller wrote are read at each, and each member of a group handed to the
|
|
// forge bridge is merged by the fake bridge, whose word is heard by the next look.
|
|
func (w *world) settleAll() {
|
|
for range 4 {
|
|
w.h.React()
|
|
}
|
|
}
|
|
|
|
func (w *world) delivery(id string) *Delivery {
|
|
w.t.Helper()
|
|
w.h.mu.Lock()
|
|
defer w.h.mu.Unlock()
|
|
d := w.h.deliveries[id]
|
|
if d == nil {
|
|
w.t.Fatalf("no delivery %s; there are %v", id, w.ids())
|
|
}
|
|
c := *d
|
|
return &c
|
|
}
|
|
|
|
func (w *world) ids() []string {
|
|
var out []string
|
|
for id := range w.h.deliveries {
|
|
out = append(out, id)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func (w *world) state(id string) State { return w.delivery(id).State }
|
|
|
|
// pr is a pull request's head as the forge announces it.
|
|
func pr(repo string, number int, sha, branch string, paths ...string) PullEvent {
|
|
owner, name, _ := strings.Cut(repo, "/")
|
|
return PullEvent{Owner: owner, Repo: name, Number: number, Base: "main", Head: branch, HeadSHA: sha,
|
|
HTMLURL: fmt.Sprintf("https://forge.invalid/%s/pulls/%d", repo, number), Paths: paths,
|
|
// Reviewed at its head, as the Approve is asked only for a reviewed head (novox/hq ADR 0496, issue 1803).
|
|
Body: "Reviewed-by: tester@shanks at " + sha}
|
|
}
|
|
|
|
// verdict is the controller's `checked` for a head.
|
|
func verdict(repo string, number int, sha, gate string) CheckedEvent {
|
|
owner, name, _ := strings.Cut(repo, "/")
|
|
c := CheckedEvent{Owner: owner, Repo: name, Number: number, Commit: sha, Verdict: gate, Summary: "the gate said " + gate,
|
|
ID: "check-of-" + sha}
|
|
return c
|
|
}
|
|
|
|
// merged is the forge's `pull.merged`.
|
|
func merged(repo string, number int, head, merge string) PullEvent {
|
|
p := pr(repo, number, head, "feature")
|
|
p.MergeCommit = merge
|
|
p.MergedAt = "2026-10-06T12:30:00Z"
|
|
return p
|
|
}
|
|
|
|
// Comment records what was asked (novox/hq issue 722).
|
|
func (f *fakeForge) Comment(owner, repo string, number int, body string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.comments = append(f.comments, fmt.Sprintf("%s/%s#%d: %s", owner, repo, number, body))
|
|
f.calls = append(f.calls, "comment "+body)
|
|
return nil
|
|
}
|
|
|
|
func (w *world) logs() string { return w.logbuf.String() }
|