Files
mesh-catalog/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/fakes_test.go
T
jschoubben fb89990e2b mesh-delivery-gate: ask a check the build queue lost again, and take a recheck from proposed when none is held (issue 1274)
A proposed delivery whose asked check no queue entry holds stayed proposed for good (met in 1311, 1336, 1726, 1792, 1795, 1833). The sweep reads the controller's queue and asks again past max(10 minutes, the repository's last check time); recheck from proposed is taken on the same fact; never while the queue holds one.
2026-10-22 16:34:33 +02:00

741 lines
22 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 gate's mark that today's state was carried (seat.go), 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 fakeController struct {
mu sync.Mutex
walks map[string]Walk
held bool
checks []string
asked int
delivers []string
// backupsAgain are the walks whose failed backups were asked again.
backupsAgain []string
// refuseBackupsAgain refuses the word to take a walk's backups again.
refuseBackupsAgain bool
stops []string
// withouts are the stops that left merges out (novox/hq ADR 0299): "<walk> without <merge>,…".
withouts []string
// refuseWithout refuses every stop with merges to leave out, as a controller before ADR 0299 does; mergedAt are
// the merges' times, by commit, for what a later merge contains.
refuseWithout bool
mergedAt map[string]time.Time
// refuseStop refuses every whole stop too; moves are the modules each merge moves, by commit, for what a left-out
// merge shares (ADR 0299).
refuseStop bool
moves map[string][]string
plans int
down bool
// emptyCheck makes Check answer with no id and no error, as the port does when the answer holds none.
emptyCheck bool
// walkReads counts the reads of the controller's walks (Walks).
walkReads int
// checkReady is the ready flag of each member of each check asked.
checkReady []bool
// baseSHAs is the base head each member of each check asked was sent with.
baseSHAs []string
// refuseDeliver refuses every word, as a controller that will not take it does (review of novox/hq issue 753).
refuseDeliver bool
// onCheck runs as a check is asked, before it is answered: what the forge held at that moment.
onCheck func()
// planned is what Plan answers instead of "builds app", when set.
planned *DeliveryPlan
// forgot are the pull requests whose check builds were let go of (novox/hq ADR 0350 §5), "owner/repo#n".
forgot []string
// queue is the build queue as Check fills it: every ask made is held, waiting, until a test takes it out (a lost
// ask, issue 1274); queueDown makes the queue unreadable.
queue []QueuedAsk
queueDown bool
}
func (c *fakeController) Queue() ([]QueuedAsk, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.queueDown {
return nil, errors.New("the queue cannot be read")
}
return append([]QueuedAsk(nil), c.queue...), nil
}
// BackupsAgain records the word, and clears the walk's failed backups as the controller does.
func (c *fakeController) BackupsAgain(walk, why string) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.down {
return errors.New("down")
}
if c.refuseBackupsAgain {
return errors.New("the controller refuses the backups")
}
w, known := c.walks[walk]
if !known || w.Backups == nil || w.Backups.State != "failed" {
return fmt.Errorf("%s holds no failed backup", walk)
}
w.Backups = nil
w.Revision++
c.walks[walk] = w
c.backupsAgain = append(c.backupsAgain, walk)
return nil
}
func (c *fakeController) ForgetChecks(owner, repo string, number int) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.down {
return errors.New("down")
}
c.forgot = append(c.forgot, fmt.Sprintf("%s/%s#%d", owner, repo, number))
return nil
}
func newFakeController() *fakeController {
return &fakeController{walks: map[string]Walk{}, held: true, mergedAt: map[string]time.Time{},
moves: map[string][]string{}}
}
func (c *fakeController) Plan(repository, base, head string, paths, dirs, removed []string) (*DeliveryPlan, error) {
c.mu.Lock()
defer c.mu.Unlock()
c.plans++
if c.down {
return nil, errors.New("down")
}
if c.planned != nil {
p := *c.planned
return &p, nil
}
return &DeliveryPlan{Repository: repository, Base: base, Head: head, Moved: []string{"app"}, Summary: "builds app"}, nil
}
func (c *fakeController) Check(group string, ms []Member) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.down {
return "", errors.New("down")
}
if c.onCheck != nil {
c.onCheck()
}
c.asked++
id := fmt.Sprintf("check-%d", c.asked)
if c.emptyCheck {
id = ""
}
var heads []string
for _, m := range ms {
heads = append(heads, m.ID)
}
c.checks = append(c.checks, group+":"+strings.Join(heads, ","))
for _, m := range ms {
c.queue = append(c.queue, QueuedAsk{ID: id, State: "waiting", Kind: "check", Repository: m.Repository, Commit: m.Head})
c.checkReady = append(c.checkReady, m.Ready)
c.baseSHAs = append(c.baseSHAs, m.BaseSHA)
}
return id, nil
}
func (c *fakeController) Deliver(walk, why string) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.down {
return errors.New("down")
}
if c.refuseDeliver {
return errors.New("the controller refuses the word")
}
w, known := c.walks[walk]
if !known || w.Delivery == nil || w.Delivery.Go != nil {
return fmt.Errorf("%s waits for nobody", walk)
}
now := time.Now()
w.Delivery.Go, w.Delivery.By, w.Delivery.Why = &now, byOwner, why
w.Revision++
c.walks[walk] = w
c.delivers = append(c.delivers, walk)
return nil
}
func (c *fakeController) Stop(walk, why, by string) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.down {
return errors.New("down")
}
c.stops = append(c.stops, walk)
if c.refuseStop {
return errors.New("the controller refuses")
}
if w, known := c.walks[walk]; known && !w.Open() {
// As the controller: only a walk that is building or rolling is stopped (`delivery-stop`).
return fmt.Errorf("%s is already %s", walk, w.State)
} else if known {
w.State = walkFailed
if w.Delivery == nil {
w.Delivery = &WalkDelivery{}
}
w.Delivery.Stopped, w.Delivery.StoppedWhy = by, why
w.Revision++
c.walks[walk] = w
}
return nil
}
// StopWithout ends a walk without the merges named, as the controller does (novox/hq ADR 0299): each merge named,
// and each later merge of its repository by merge time (mergedAt), is left out; the others are put in a batch,
// "<walk>-again", assembling until a test cuts it. Given a batch, the merges left out go to a stopped record,
// "<walk>-left", and the batch goes on with the others. refuseWithout refuses every such stop, as an older
// controller does.
func (c *fakeController) StopWithout(walk, why, by string, without []string) (StopAnswer, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.down {
return StopAnswer{}, errors.New("down")
}
c.withouts = append(c.withouts, walk+" without "+strings.Join(without, ","))
if c.refuseWithout {
return StopAnswer{}, errors.New(`delivery-stop takes no argument "without"`)
}
w, known := c.walks[walk]
if !known || !(w.Open() || w.IsBatch()) {
return StopAnswer{}, fmt.Errorf("%s is no open walk or batch", walk)
}
wd := WalkDelivery{}
if w.Delivery != nil {
wd = *w.Delivery
}
named := func(m WalkMerge) bool { return containsString(without, m.Repository+"@"+m.Commit) }
contains := func(m WalkMerge) string {
for _, s := range wd.Merges {
if named(s) && s.Repository == m.Repository && c.mergedAt[m.Commit].After(c.mergedAt[s.Commit]) {
return s.Commit
}
}
return ""
}
shares := func(m WalkMerge) (string, []string) {
for _, s := range wd.Merges {
if named(s) {
var both []string
for _, x := range c.moves[m.Commit] {
if containsString(c.moves[s.Commit], x) {
both = append(both, x)
}
}
if len(both) > 0 {
return s.Commit, both
}
}
}
return "", nil
}
a := StopAnswer{Stopped: walk}
var again, left []WalkMerge
for _, m := range wd.Merges {
s, both := shares(m)
switch {
case named(m):
a.LeftOut = append(a.LeftOut, WalkLeftOut{Repository: m.Repository, Commit: m.Commit})
left = append(left, m)
case len(both) > 0:
a.LeftOut = append(a.LeftOut, WalkLeftOut{Repository: m.Repository, Commit: m.Commit, Contains: s, Shares: both})
left = append(left, m)
case contains(m) != "":
a.LeftOut = append(a.LeftOut, WalkLeftOut{Repository: m.Repository, Commit: m.Commit, Contains: contains(m)})
left = append(left, m)
default:
m.Carried = ""
again = append(again, m)
a.WalkedAgain = append(a.WalkedAgain, m.Repository+"@"+m.Commit)
}
}
if len(a.LeftOut) == 0 {
return StopAnswer{}, fmt.Errorf("%s answers no merge %s", walk, strings.Join(without, ", "))
}
if w.IsBatch() && len(again) == 0 {
// Every merge left out: the batch itself is stopped, as the controller does.
wd.Stopped, wd.StoppedWhy, wd.LeftOut, wd.Batch = by, why, a.LeftOut, nil
w.Delivery, w.State = &wd, walkFailed
w.Revision++
c.walks[walk] = w
return a, nil
}
if w.IsBatch() {
rid := walk + "-left"
c.walks[rid] = Walk{ID: rid, Repository: left[0].Repository, Branch: "main", Commit: left[0].Commit,
Created: w.Created, State: walkFailed, Revision: 1, Modules: map[string]*WalkModule{},
Delivery: &WalkDelivery{Stopped: by, StoppedWhy: why, Merges: left, LeftOut: a.LeftOut, WalkedAgainBy: walk}}
wd.Merges = again
w.Delivery = &wd
w.Repository, w.Commit = again[0].Repository, again[0].Commit
w.Revision++
c.walks[walk] = w
a.Stopped, a.WalkedAgainBy = rid, walk
return a, nil
}
if len(again) > 0 {
id := walk + "-again"
closes := w.Created.Add(time.Minute)
c.walks[id] = Walk{ID: id, Repository: again[0].Repository, Branch: "main", Commit: again[0].Commit,
Created: w.Created.Add(time.Minute), State: walkAssembling, Revision: 1,
Modules: map[string]*WalkModule{}, Delivery: &WalkDelivery{Merges: again, Batch: &WalkBatch{ClosesAt: &closes}}}
wd.WalkedAgainBy, a.WalkedAgainBy = id, id
}
wd.Stopped, wd.StoppedWhy, wd.LeftOut = by, why, a.LeftOut
w.Delivery = &wd
w.State = walkFailed
w.Note = "stopped by " + by + ": " + why
w.Revision++
c.walks[walk] = w
return a, nil
}
// cut makes a batch the walk under the same id, waiting for its word, as the controller cuts it.
func (c *fakeController) cut(id string) Walk {
c.mu.Lock()
w := c.walks[id]
c.mu.Unlock()
wd := *w.Delivery
wd.Batch, wd.Awaits = nil, "mesh-delivery"
w.Delivery = &wd
w.State, w.Tiers, w.Modules = walkBuilding, [][]string{{"app"}}, map[string]*WalkModule{"app": {}}
return c.put(w)
}
func (c *fakeController) Walks(walk string) (bool, []Walk, error) {
c.mu.Lock()
defer c.mu.Unlock()
c.walkReads++
if c.down {
return false, nil, errors.New("down")
}
var out []Walk
for id, w := range c.walks {
if walk == "" || walk == id {
out = append(out, w)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return c.held, out, nil
}
// put is the controller keeping a walk; it answers the walk as `plan-moved` would say it.
func (c *fakeController) put(w Walk) Walk {
c.mu.Lock()
defer c.mu.Unlock()
if old, known := c.walks[w.ID]; known {
w.Revision = old.Revision + 1
} else if w.Revision == 0 {
w.Revision = 1
}
c.walks[w.ID] = w
return w
}
func (c *fakeController) walk(id string) Walk {
c.mu.Lock()
defer c.mu.Unlock()
return c.walks[id]
}
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
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.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
ctl *fakeController
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(), newFakeController(), newFakeForge(), time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC))
}
func newWorldOver(t *testing.T, store *memStore, ctl *fakeController, forge *fakeForge, now time.Time) *world {
w := &world{t: t, store: store, ctl: ctl, forge: forge, now: now}
w.h = &Holder{Store: store, Controller: ctl, 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, Plan: &DeliveryPlan{Repository: repo, Moved: []string{"app"}, Summary: "builds app → anchor"}}
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
}
// aWalk is the controller's walk of a merge commit, waiting for its delivery's word.
func aWalk(id, repo, commit string, waits bool) Walk {
w := Walk{ID: id, Repository: repo, Branch: "main", Commit: commit, Created: time.Date(2026, 10, 6, 12, 31, 0, 0, time.UTC),
State: walkBuilding, Tiers: [][]string{{"app"}}, Modules: map[string]*WalkModule{"app": {}}}
if waits {
w.Delivery = &WalkDelivery{Awaits: "mesh-delivery"}
}
return w
}
// 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() }