package main import ( "context" "encoding/json" "fmt" "os" "regexp" "slices" "sort" "strconv" "strings" "time" "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/link" ) // **Merges are assembled in a rolling window, and each batch is delivered by one walk** (novox/hq ADR 0276, // issue 362). // // Every merge opened a walk of its own, and the next merge of the branch superseded it. On 2026-10-10 two // catalogue merges eighteen seconds apart left one walk that no delivery held, and the operator started it by // hand 58 minutes later. A merge now joins the open **batch**. Each merge restarts the **merge window**; it // closes when none came for `merge-window` (90 seconds) or at the latest `merge-window-at-most` (10 minutes) // after the batch's first merge. A closed batch becomes one walk when no walk is open: one plan for every // commit in it, with at most one commit per repository, the latest of its branch, which contains the earlier // ones. The walk names every merge it answers. // // - One walk at a time. Merges that come while a walk runs join the next batch. A walk that started is never // superseded; one still waiting for its delivery's word is folded into the next batch's walk, which // answers every merge it answered — the only supersession left. // - A merge heard after a later merge of its branch was put in a walk is answered by that walk: a batch // still assembling, an open walk that builds everything it moves, or a walk done that did, which then // names it at once. Otherwise it joins the next batch. // - A failed walk marks nothing delivered. Each earlier merge it carried in a later commit is walked on its // own commit, the newest first, one walk each, until one is delivered; the older ones are then answered // by that one. // // **A merge waits only for a batch it shares something with** (novox/hq ADR 0304 decision 1, ADR 0306 decisions // 2 and 3, issue 525): it joins a batch it is related to, and one that is related to none and whose delivery // group has no member still to merge is a batch whose window is closed at once (batchToJoin). Walks stay one at // a time: such a batch is cut when no walk is open, and queued behind the one that is otherwise. // // A merge on the controller's own path (a module whose walk waits for nobody's word) never shares a batch with // one that waits for mesh-delivery's: each kind has a batch of its own, an own-path batch is cut first and folds // no waiting catalogue walk, and a catalogue walk let go while another walk runs starts once it ended — so no // catalogue batch's walk starts without the word (decided during the build, 2026-10-10). // // The batch, its merges and their times are in the store (batched_merge, and the batch's own plan record in // the state `assembling` or `queued`), so a restarted controller resumes the window where it stood. // The merge window's defaults. The controller's settings `merge-window` and `merge-window-at-most` reach it in // the file MESH_MERGE_WINDOW_FILE names (module.json), read at every look, so a change takes effect without a // restart. var ( mergeWindowDefault = 90 * time.Second mergeWindowAtMostDefault = 10 * time.Minute ) // mergeWindowFileVar names the file the controller's window settings are written to. const mergeWindowFileVar = "MESH_MERGE_WINDOW_FILE" // batchCutEvery is how often the controller looks whether a batch's window closed. const batchCutEvery = 5 * time.Second // batchLateAfter is how long past its maximum a batch may still be assembling, with no walk open, before // the controller is said to have failed to cut it (ADR 0276 decision 9). const batchLateAfter = time.Minute // edgeGroupOrder is a delivery group's order made an edge of a walk's tiers (ADR 0276 decision 5): the // modules of the member after depend on the modules of the member before, and are gated on them. const edgeGroupOrder = "group-order" // mergeWindowOf is the window and its maximum, from the controller's settings; a value unreadable is its // default, and said. func mergeWindowOf() (window, atMost time.Duration) { window, atMost = mergeWindowDefault, mergeWindowAtMostDefault path := os.Getenv(mergeWindowFileVar) if path == "" { return window, atMost } raw, err := os.ReadFile(path) if err != nil { fmt.Fprintf(os.Stderr, "the merge window's settings cannot be read (%v); %s and %s are used\n", err, window, atMost) return window, atMost } var values map[string]any if err := json.Unmarshal(raw, &values); err != nil { fmt.Fprintf(os.Stderr, "the merge window's settings are not JSON (%v); %s and %s are used\n", err, window, atMost) return window, atMost } for key, to := range map[string]*time.Duration{"merge-window": &window, "merge-window-at-most": &atMost} { v, has := values[key] if !has || v == "" { continue } if d, ok := durationSetting(v); ok { *to = d } else { fmt.Fprintf(os.Stderr, "the setting %s=%v is no duration; %s is used\n", key, v, *to) } } return window, atMost } // durationSetting reads a duration as a setting says it: "90s", "10m", or a number of seconds. func durationSetting(v any) (time.Duration, bool) { switch t := v.(type) { case string: if d, err := time.ParseDuration(strings.TrimSpace(t)); err == nil && d >= 0 { return d, true } if n, err := strconv.ParseFloat(strings.TrimSpace(t), 64); err == nil && n >= 0 { return time.Duration(n * float64(time.Second)), true } case float64: if t >= 0 { return time.Duration(t * float64(time.Second)), true } } return 0, false } // hearMerge puts a merge the forge announced into the batch that answers it, at the moment it was heard, // and cuts what is due. func (f following) hearMerge(ctx context.Context, m link.SourceMoved, now time.Time) error { // One merge acted on at a time, whoever hands it over: the bus, or the catch-up that reads back // what the bus did not hand over (novox/hq issue 266). actingOnMerges.Lock() defer actingOnMerges.Unlock() inv := f.open.inventory repository := m.Owner + "/" + m.Repo if kept, known, err := inv.MergeOf(ctx, repository, m.Commit); err != nil { return notNow(err) } else if known { fmt.Printf("%s merged into %s (%.8s) was heard before; %s answers it\n", repository, m.Base, m.Commit, orWords(kept.Plan, "a walk of its own commit, once the walks before it end")) return nil } entries, err := inv.Catalogued(ctx) if err != nil { return notNow(err) } read, err := readForPlanning(ctx, inv) if err != nil { return notNow(err) } merged, err := time.Parse(time.RFC3339Nano, m.MergedAt) if err != nil { merged = now } // **What it touches, history or not** (review of this change): a merge heard after a later merge of its // branch was cut reads as history for every module that cut marked seen, and was dropped from the record // here, never named by the walk that carried it. raw := m raw.MergedAt = "" touches := wouldMove(raw, entries, read) var later []inventory.BatchedMerge if len(touches) > 0 { if later, err = inv.LaterMergesOf(ctx, repository, m.Base, merged.UTC()); err != nil { return notNow(err) } } owed, err := owedLate(ctx, inv, m, merged, touches) if err != nil { return notNow(err) } if from, packaging, already := mergeCandidates(m, entries, read); len(from) == 0 && len(packaging) == 0 && len(later) == 0 && !owed { // "Already built from it" and "nothing reads it" are different facts, and reading the first // as the second sends somebody looking for a broken trigger when the mesh is up to date. if already > 0 { fmt.Printf("%s merged into %s (%.8s); %d module(s) the mesh holds are already built from it\n", repository, m.Base, m.Commit, already) return nil } fmt.Printf("%s merged into %s (%.8s); nothing the mesh holds reads it\n", repository, m.Base, m.Commit) return nil } // **Its delivery group's word, before the plans are held** (ADR 0306 decision 2): a slow answer holds up no walk. own := ownPath(touches) waits, err := groupWait(ctx, inv, m, deliverySeatHeld(entries), own) if err != nil { return notNow(err) } release, err := inv.HoldPlans(ctx, true) if err != nil { return notNow(err) } defer release() // Again under the hold: a controller handing over may have kept it since. if _, known, err := inv.MergeOf(ctx, repository, m.Commit); err != nil || known { return notNow(err) } var moves []string for _, e := range touches { moves = append(moves, e.Manifest.Module) } sort.Strings(moves) event, err := json.Marshal(keptMerge{SourceMoved: m, Moves: moves}) if err != nil { return err } heard := inventory.BatchedMerge{Repository: repository, Branch: m.Base, Commit: m.Commit, Merged: merged.UTC(), Heard: now, Event: event} // **A merge heard after a later merge of its branch** is answered by the walk holding that one. // **A merge on the controller's own path never shares a batch with one that waits for the delivery's // word** (decided during the build, 2026-10-10): batched together, the catalogue's deliveries would start // with the controller's and skip their turn. Each kind has a batch of its own. if p, ok, err := answeredByALaterMerge(ctx, inv, heard, touches, own, deliverySeatHeld(entries)); err != nil { return notNow(err) } else if ok { heard.Plan = p.ID if _, err := inv.AddMerge(ctx, heard); err != nil { return notNow(err) } if p.Batch() { if err := keepBatch(ctx, inv, &p, now, ""); err != nil { return notNow(err) } } else if err := nameMerges(ctx, inv, &p); err != nil { return notNow(err) } fmt.Printf("%s merged into %s (%.8s) after a later merge of its branch was put in %s (%s): that %s "+ "answers it\n", repository, m.Base, m.Commit, p.ID, p.State, walkOrBatch(p)) return cutBatchesHeld(ctx, f.open, now) } batch, why, err := batchToJoin(ctx, inv, heard, entries, read, now, own, waits) if err != nil { return notNow(err) } heard.Plan = batch.ID if added, err := inv.AddMerge(ctx, heard); err != nil || !added { return notNow(err) } if err := keepBatch(ctx, inv, &batch, now, ""); err != nil { return notNow(err) } fmt.Printf("%s merged into %s (%.8s); %s, it joins %s — %s\n", repository, m.Base, m.Commit, why, batch.ID, batchWords(batch, now)) return cutBatchesHeld(ctx, f.open, now) } // owedLate says a merge that reads as history is still the record's to answer (review of this change): a cut // marks every module it moved seen at that moment, so a merge of that branch the bus lost and the catch-up // hands over later — made before the cut, heard after it — reads as history and was dropped. One that touches // a module the mesh holds, of a branch whose merges the controller keeps, made since it began keeping them, // is kept; one made before it began is old news, as before. func owedLate(ctx context.Context, inv *inventory.Inventory, m link.SourceMoved, merged time.Time, touches []inventory.Entry) (bool, error) { if len(touches) == 0 { return false, nil } kept, first, err := inv.KeptOnBranch(ctx, m.Owner+"/"+m.Repo, m.Base) if err != nil || !kept { return false, err } return !merged.Before(first.Add(-mergeGrace)), nil } // answeredByALaterMerge is the plan that answers a merge heard after a later merge of its branch was put in // one (ADR 0276 decision 4): a batch not yet cut, whose walk plans every file of the repository's merges; an // open walk, or a walk done, that builds every module this merge would move — built from the branch after the // later merge, which contains it. A failed or stopped walk answers nothing more, and a walk folded into // another is followed to that one. False when none does: the merge joins the next batch. func answeredByALaterMerge(ctx context.Context, inv *inventory.Inventory, heard inventory.BatchedMerge, moves []inventory.Entry, own, held bool) (inventory.Plan, bool, error) { later, err := inv.LaterMergesOf(ctx, heard.Repository, heard.Branch, heard.Merged) if err != nil || len(later) == 0 { return inventory.Plan{}, false, err } for _, l := range later { p, err := followTakeOver(ctx, inv, l.Plan) if err != nil { return inventory.Plan{}, false, err } switch { case p.Batch(): // A batch of the other kind is not this merge's: it joins the batch of its own kind. if p.OwnPath() == own { return p, true, nil } case p.Open() || p.State == inventory.PlanDone: // A walk that waited for nobody's word is not a catalogue merge's to be answered by while the delivery // seat has a holder: its delivery would skip its turn (decided during the build, 2026-10-10). With no // holder on record nothing waits, and any walk that built it answers. waited := p.Delivery != nil && p.Delivery.Awaits != "" if buildsEvery(p, moves) && (own || waited || !held) { return p, true, nil } } } return inventory.Plan{}, false, nil } // followTakeOver is a plan, or the walk that took it over when it was folded before it started. func followTakeOver(ctx context.Context, inv *inventory.Inventory, id string) (inventory.Plan, error) { p, err := inv.PlanByID(ctx, id) for hops := 0; err == nil && hops < 16 && p.State == inventory.PlanSuperseded && p.Delivery != nil && p.Delivery.TakenOverBy != ""; hops++ { p, err = inv.PlanByID(ctx, p.Delivery.TakenOverBy) } return p, err } // buildsEvery says a walk builds every one of the modules, and that there is one: a merge that moves nothing // is no walk's to name. func buildsEvery(p inventory.Plan, modules []inventory.Entry) bool { if len(modules) == 0 { return false } for _, e := range modules { if _, in := p.Modules[e.Manifest.Module]; !in { return false } } return true } // ownPath says a merge touches a module on the controller's own path, whose walk waits for nobody's word. func ownPath(touches []inventory.Entry) bool { for _, e := range touches { if _, own := onTheControllersPath[e.Manifest.Module]; own { return true } } return false } // **A merge waits only for a batch it shares something with** (novox/hq ADR 0304 decision 1, as ADR 0306 // decisions 2 and 3 read it). batchToJoin is the batch a merge heard now joins, and why in words: // // - an assembling or queued batch of its kind it is related to (related.go): it joins it and waits for the // window, which its coming restarts. Related to two or more, the newer are folded into the oldest, which // answers all their merges: a merge never waits in two windows, and what it shares with each walks in one. // - none, and its delivery group has a member still to merge, or mesh-delivery could not say (groupWait, waits // names why): a new batch, which waits for the window, beside the ones it shares nothing with; its words say // why. // - none, and no member of its group is still to merge: a new batch whose window is closed at once. Cut at // once means only that: walks stay one at a time (ADR 0276 decision 4), so with a walk open the batch is // queued behind it as any closed batch is. func batchToJoin(ctx context.Context, inv *inventory.Inventory, heard inventory.BatchedMerge, entries []inventory.Entry, read map[string][]inventory.ReadRepository, now time.Time, own bool, waits string) (inventory.Plan, string, error) { edges, err := inv.Dependencies(ctx) if err != nil { return inventory.Plan{}, "", err } joins, whys, _, err := relatedBatches(ctx, inv, []inventory.BatchedMerge{heard}, own, "", entries, read, edges) if err != nil { return inventory.Plan{}, "", err } if len(joins) > 0 { into := joins[0] why := "related to " + into.ID + " (" + whys[0] + ")" if len(joins) > 1 { plans, moves, err := foldInto(ctx, inv, &into, joins[1:]) if err != nil { return inventory.Plan{}, "", err } if err := inv.SavePlansMoving(ctx, plans, moves); err != nil { return inventory.Plan{}, "", err } var folded []string for i, b := range joins[1:] { folded = append(folded, b.ID+" ("+whys[i+1]+")") } why += ", and to " + strings.Join(folded, ", ") + ", folded into it" } into.Delivery.Batch.CutAtOnce = false return into, why, nil } if waits != "" { // Its own window, beside the batches it shares nothing with: waiting is for its group's members, which // are related to it by their head and join it (ADR 0306 decision 2). b, err := newBatch(ctx, inv, heard, now, own, false, waits) return b, waits + ", it waits for the window in a batch of its own", err } b, err := newBatch(ctx, inv, heard, now, own, true, "") return b, "it shares nothing with a batch assembling and no member of a group is still to merge: it is cut at once", err } // relatedBatches are the batches not yet cut of a kind, oldest first, parted into those related to merges (with // the first reason of each) and the others. Each batch is judged by what its cut would move (ADR 0306 decision // 3): its merges' moves as they were kept and as they read now — the catalogue may have moved since a merge was // heard — widened to their dependents, and, for a catalogue batch, every walk waiting for its word, which the // cut folds in (cutBatch's carry). A merge kept with no moves leaves its batch unknown, related to everything. // except is a walk not to count as folded: the one being stopped. func relatedBatches(ctx context.Context, inv *inventory.Inventory, merges []inventory.BatchedMerge, own bool, except string, entries []inventory.Entry, read map[string][]inventory.ReadRepository, edges []inventory.Edge) (joins []inventory.Plan, whys []string, others []inventory.Plan, err error) { batches, err := inv.Batches(ctx) if err != nil { return nil, nil, nil, err } var kind []inventory.Plan for _, b := range batches { if b.OwnPath() == own { kind = append(kind, b) } } if len(kind) == 0 { return nil, nil, nil, nil } carry := newFootprint() if !own { // An own-path batch folds no waiting walk (cutBatchesHeld); a catalogue batch may fold every one. plans, err := inv.OpenPlans(ctx) if err != nil { return nil, nil, nil, err } for _, p := range plans { if p.Release != nil || !p.Waiting() || p.ID == except { continue } kept, err := inv.MergesOf(ctx, p.ID) if err != nil { return nil, nil, nil, err } carry = unionFootprint(carry, footprintOfWalk(p, kept)) } } mine := footprintAsCut(merges, entries, read, edges) for _, b := range kind { held, err := inv.MergesOf(ctx, b.ID) if err != nil { return nil, nil, nil, err } if b.Delivery == nil { b.Delivery = &inventory.PlanDelivery{} } if b.Delivery.Batch == nil { b.Delivery.Batch = &inventory.PlanBatch{Own: own} } if ok, why := related(mine, unionFootprint(footprintAsCut(held, entries, read, edges), carry), edges); ok { joins, whys = append(joins, b), append(whys, why) } else { others = append(others, b) } } return joins, whys, others, nil } // footprintAsCut is a batch's footprint as a cut now would read it: footprintOfBatch, widened by the modules each // merge moves as the catalogue reads now (planBatch judges each merge again at the cut, so what it moves may // have changed since it was heard). Both are kept: when in doubt, related. func footprintAsCut(merges []inventory.BatchedMerge, entries []inventory.Entry, read map[string][]inventory.ReadRepository, edges []inventory.Edge) footprint { f := footprintOfBatch(merges, edges) var moved []string for _, m := range merges { s := announcedOf(m).SourceMoved if s.Owner == "" { continue } s.MergedAt = "" // news, whatever a cut marked seen since (as planBatch reads it) for _, e := range wouldMove(s, entries, read) { moved = append(moved, e.Manifest.Module) } } for _, name := range reachableFrom(moved, edges) { f.modules[name] = true } return f } // unionFootprint is what two changes reach together. func unionFootprint(a, b footprint) footprint { f := newFootprint() for _, from := range []footprint{a, b} { for k := range from.repos { f.repos[k] = true } for k := range from.modules { f.modules[k] = true } for k := range from.heads { f.heads[k] = true } } f.unknown = a.unknown if f.unknown == "" { f.unknown = b.unknown } return f } // foldInto folds batches into the oldest one related to a merge: the plans to write (into first, so no merge // names a plan the store does not hold) and the merges to move, for one SavePlansMoving. The folded batches name // the batch that took them over; a stop's closed window and its withheld modules (ADR 0299) go with their merges. func foldInto(ctx context.Context, inv *inventory.Inventory, into *inventory.Plan, from []inventory.Plan) ([]*inventory.Plan, []inventory.MergeMove, error) { if into.Delivery == nil { into.Delivery = &inventory.PlanDelivery{} } if into.Delivery.Batch == nil { into.Delivery.Batch = &inventory.PlanBatch{Own: into.OwnPath()} } plans := []*inventory.Plan{into} var moves []inventory.MergeMove for i := range from { f := from[i] merges, err := inv.MergesOf(ctx, f.ID) if err != nil { return nil, nil, err } for _, m := range merges { moves = append(moves, inventory.MergeMove{Repository: m.Repository, Commit: m.Commit, Plan: into.ID, Alone: m.Alone}) } if f.Delivery == nil { f.Delivery = &inventory.PlanDelivery{} } if w := f.Delivery.Batch; w != nil { into.Delivery.Batch.Closed = into.Delivery.Batch.Closed || w.Closed into.Delivery.Batch.Withheld = union(into.Delivery.Batch.Withheld, w.Withheld) sort.Strings(into.Delivery.Batch.Withheld) } f.State = inventory.PlanSuperseded f.Delivery.Batch = nil f.Delivery.TakenOverBy = into.ID f.Note = fmt.Sprintf("folded into %s before it was cut: a merge related to both joined it, and %s answers "+ "its merges", into.ID, into.ID) plans = append(plans, &f) } return plans, moves, nil } // newBatch is a new batch of a kind, kept before its first merge names it: a merge never names a plan the store // does not hold. cutAtOnce closes its window at once; waits says why it waits for its group, when it does. func newBatch(ctx context.Context, inv *inventory.Inventory, first inventory.BatchedMerge, now time.Time, own, cutAtOnce bool, waits string) (inventory.Plan, error) { b := inventory.Plan{ID: fmt.Sprintf("plan-%d", now.UnixNano()), Repository: first.Repository, Branch: first.Branch, Commit: first.Commit, Merged: first.Merged, Created: now, State: inventory.PlanAssembling, Tiers: [][]string{}, Modules: map[string]*inventory.PlanModule{}, Delivery: &inventory.PlanDelivery{Batch: &inventory.PlanBatch{Own: own, CutAtOnce: cutAtOnce, Waits: waits}}} if err := inv.SavePlan(ctx, &b); err != nil { return inventory.Plan{}, err } return b, nil } // keepBatch writes a batch as its merges and the clock say it now: the commits it holds, the merges they // answer, its window, and assembling or queued behind a walk. Saved only when that changed. func keepBatch(ctx context.Context, inv *inventory.Inventory, b *inventory.Plan, now time.Time, behind string) error { merges, err := inv.MergesOf(ctx, b.ID) if err != nil { return err } window, atMost := mergeWindowOf() // The note's seconds left are no change of the batch's: it is saved when what it holds or its state moved. unNoted := func(p inventory.Plan) string { p.Note = "" return planSnapshot(p) } before := unNoted(*b) commits, named := carriedOf(merges) b.Commits = commits if len(commits) > 0 { newest := newestCommit(commits) b.Repository, b.Branch, b.Commit, b.Merged = newest.Repository, newest.Branch, newest.Commit, newest.Merged } if b.Delivery == nil { b.Delivery = &inventory.PlanDelivery{} } closes, latest := windowOf(merges, b.Created, window, atMost) b.Delivery.Merges = named var closed, cutAtOnce bool var withheld []string var waits string if was := b.Delivery.Batch; was != nil { closed, withheld = was.Closed, was.Withheld // a stop's, which no look takes back (novox/hq ADR 0299) cutAtOnce = was.CutAtOnce // the merge's, until another joins it (ADR 0304 decision 1) waits = was.Waits // its group's, as said when it was made (ADR 0306 decision 2) } b.Delivery.Batch = &inventory.PlanBatch{ClosesAt: closes, AtMost: latest, Behind: behind, Own: b.OwnPath(), Closed: closed, Withheld: withheld, CutAtOnce: cutAtOnce, Waits: waits} b.State = inventory.PlanAssembling if behind != "" && windowClosed(*b, now) { b.State = inventory.PlanQueued } else { b.Delivery.Batch.Behind = "" } b.Note = batchWords(*b, now) if unNoted(*b) == before { return nil } return inv.SavePlan(ctx, b) } // windowOf is when a batch's window closes, unless another merge comes, and when at the latest: the last // merge heard plus the window, and the batch's first merge plus the maximum. func windowOf(merges []inventory.BatchedMerge, created time.Time, window, atMost time.Duration) (closes, latest time.Time) { var last time.Time for _, m := range merges { if m.Heard.After(last) { last = m.Heard } } if last.IsZero() { last = created } return last.Add(window), created.Add(atMost) } // windowClosed says a batch's window is closed: closed by a stop, closed at once for a merge that shares // nothing (ADR 0304 decision 1), no merge for the window's length, or its maximum reached. func windowClosed(b inventory.Plan, now time.Time) bool { if b.Delivery == nil || b.Delivery.Batch == nil { return true } w := b.Delivery.Batch return w.Closed || w.CutAtOnce || !now.Before(w.ClosesAt) || !now.Before(w.AtMost) } // carriedOf is a set of merges as a walk carries them: the latest merge of each repository's branch, and // every merge named with the commit that carries it. func carriedOf(merges []inventory.BatchedMerge) ([]inventory.PlanCommit, []inventory.PlanMerge) { latest := map[string]inventory.BatchedMerge{} spelled := map[string]string{} for _, m := range merges { k := strings.ToLower(m.Repository) + "\x00" + m.Branch if l, ok := latest[k]; !ok || laterMerge(m, l) { latest[k] = m } var e link.SourceMoved if json.Unmarshal(m.Event, &e) == nil && e.Owner != "" { spelled[k] = e.Owner + "/" + e.Repo } } var commits []inventory.PlanCommit for k, l := range latest { repo := spelled[k] if repo == "" { repo = l.Repository } commits = append(commits, inventory.PlanCommit{Repository: repo, Branch: l.Branch, Commit: l.Commit, Merged: l.Merged}) } sort.Slice(commits, func(i, j int) bool { if !strings.EqualFold(commits[i].Repository, commits[j].Repository) { return strings.ToLower(commits[i].Repository) < strings.ToLower(commits[j].Repository) } return commits[i].Branch < commits[j].Branch }) var named []inventory.PlanMerge for _, m := range merges { k := strings.ToLower(m.Repository) + "\x00" + m.Branch repo := spelled[k] if repo == "" { repo = m.Repository } n := namedOf(m, repo) if l := latest[k]; l.Commit != m.Commit { n.Carried = l.Commit } named = append(named, n) } sort.SliceStable(named, func(i, j int) bool { return strings.ToLower(named[i].Repository) < strings.ToLower(named[j].Repository) }) return commits, named } // keptMerge is a merge as the record keeps it: the forge's announcement, and the modules it moved when it was // heard. The announcement reads back as a SourceMoved alone, which ignores the rest. type keptMerge struct { link.SourceMoved Moves []string `json:"moves,omitempty"` } // announcedOf is a kept merge as the forge announced it, and what it moved; empty where the record says none. func announcedOf(m inventory.BatchedMerge) keptMerge { var k keptMerge _ = json.Unmarshal(m.Event, &k) return k } // spelledOf is a kept merge's repository as the forge spelled it; empty when its announcement does not say. func spelledOf(m inventory.BatchedMerge) string { if e := announcedOf(m); e.Owner != "" { return e.Owner + "/" + e.Repo } return "" } // namedOf is one merge as a walk or batch names it: its commit, and its pull request and moves as announced. func namedOf(m inventory.BatchedMerge, repository string) inventory.PlanMerge { k := announcedOf(m) return inventory.PlanMerge{Repository: repository, Commit: m.Commit, Number: k.Number, Title: k.Title, Moves: k.Moves, Merged: m.Merged, Heard: m.Heard} } // laterMerge says a was merged after b: by the forge's merge time, then by when each was heard. func laterMerge(a, b inventory.BatchedMerge) bool { if !a.Merged.Equal(b.Merged) { return a.Merged.After(b.Merged) } return a.Heard.After(b.Heard) } // newestCommit is the commit merged last among those a walk carries. func newestCommit(commits []inventory.PlanCommit) inventory.PlanCommit { newest := commits[0] for _, c := range commits[1:] { if c.Merged.After(newest.Merged) { newest = c } } return newest } // nameMerges writes the merges a walk answers onto its record, after a merge heard late was given to it. func nameMerges(ctx context.Context, inv *inventory.Inventory, p *inventory.Plan) error { merges, err := inv.MergesOf(ctx, p.ID) if err != nil { return err } // The walk's own commits stand: a merge heard late is carried by the one of its repository's branch. var named []inventory.PlanMerge for _, m := range merges { repo := m.Repository if e := spelledOf(m); e != "" { repo = e } n := namedOf(m, repo) if c := p.CommitOn(m.Repository, m.Branch); c != "" && c != m.Commit { n.Carried = c } named = append(named, n) } sort.SliceStable(named, func(i, j int) bool { return strings.ToLower(named[i].Repository) < strings.ToLower(named[j].Repository) }) if p.Delivery == nil { p.Delivery = &inventory.PlanDelivery{} } p.Delivery.Merges = named return inv.SavePlan(ctx, p) } // batchCutter cuts what is due on a timer, until the context ends: a window closes with no merge to say so. func batchCutter(ctx context.Context, open *stores) { tick := time.NewTicker(batchCutEvery) defer tick.Stop() for { select { case <-ctx.Done(): return case <-tick.C: } release, err := open.inventory.HoldPlans(ctx, false) if err != nil { continue // whoever holds the plans moves them, and cuts at the end } if err := cutBatchesHeld(ctx, open, time.Now().UTC()); err != nil { fmt.Printf("batches: %v\n", err) } release() } } // cutBatchesHeld takes the step that is due, for a caller holding the plans: after a failed walk, the search // for the merge that brought the failure; with no walk open, the next merge of that search walked alone, or a // closed batch cut into one walk with the waiting walks folded in; with a walk open, a closed batch said // queued behind it. func cutBatchesHeld(ctx context.Context, open *stores, now time.Time) error { inv := open.inventory if err := searchAfterFailures(ctx, inv); err != nil { return err } alone, err := inv.AloneMerges(ctx) if err != nil { return err } if alone, err = coverAlone(ctx, inv, alone); err != nil { return err } plans, err := inv.OpenPlans(ctx) if err != nil { return err } var started *inventory.Plan var waiting []inventory.Plan for i := range plans { p := plans[i] if p.Release != nil { continue // the backlog's walk through the gates: no merge's } if p.Waiting() { waiting = append(waiting, p) } else if started == nil { started = &plans[i] } } batches, err := inv.Batches(ctx) if err != nil { return err } keepAll := func(behind string) error { for i := range batches { if err := keepBatch(ctx, inv, &batches[i], now, behind); err != nil { return err } } return nil } if started != nil { // **One walk at a time** (ADR 0276 decision 4): every batch waits behind it, whatever its window says. return keepAll(started.ID) } if len(alone) > 0 { if len(waiting) > 0 { // The waiting walk is the one open; the search walks after it, and the batches wait behind both. return keepAll(waiting[0].ID) } return walkAlone(ctx, open, alone[0], now) } if err := keepAll(""); err != nil { return err } // The oldest batch whose window closed is cut, an own-path one before a catalogue one (it goes first, and // the catalogue batch queues behind it rather than being cut and overtaken). Several of a kind may be open // since a merge that shares nothing is cut at once (ADR 0304 decision 1): walks stay one at a time, so one // is cut per look, and once its walk started the others are queued behind it. sort.SliceStable(batches, func(i, j int) bool { return batches[i].OwnPath() && !batches[j].OwnPath() }) for i := range batches { b := &batches[i] if !windowClosed(*b, now) { continue } if b.OwnPath() { // A walk on the controller's own path folds no walk waiting for its word (decided during the build, // 2026-10-10): that walk keeps waiting beside it, and starts once this one ended (advanceOnce). waiting = nil } if err := cutBatch(ctx, open, b, waiting, now); err != nil { return err } // The batches left wait behind the walk just cut when it started; the next look does the same. if b.Open() && !b.Waiting() { if batches, err = inv.Batches(ctx); err != nil { return err } return keepAll(b.ID) } return nil } return nil } // cutBatch makes a closed batch one walk, folding in the walks that wait for their word: it keeps its id, and // the folded walks name it as the walk that took them over. func cutBatch(ctx context.Context, open *stores, batch *inventory.Plan, waiting []inventory.Plan, now time.Time) error { inv := open.inventory var carry []string for _, w := range waiting { merges, err := inv.MergesOf(ctx, w.ID) if err != nil { return err } for _, m := range merges { if err := inv.AnswerMerge(ctx, m.Repository, m.Commit, batch.ID, m.Alone); err != nil { return err } } // What the folded walk was to build is built by the batch's: its merges were acted on when it was cut, // so they read as history now. for name := range w.Modules { carry = append(carry, name) } } if err := planBatch(ctx, open, batch, carry, now); err != nil { return err } // Closed after the batch's walk is kept, never before: a controller replaced between the two leaves both // open, which the next cut settles, rather than neither. for i := range waiting { w := waiting[i] w.State = inventory.PlanSuperseded if w.Delivery == nil { w.Delivery = &inventory.PlanDelivery{} } w.Delivery.TakenOverBy = batch.ID w.Note = fmt.Sprintf("superseded at tier %d by %s (%s) before it started: that walk answers its merges", w.Tier, batch.ID, batch.Named()) if err := inv.SavePlan(ctx, &w); err != nil { return err } fmt.Printf(" %s is %s\n", w.ID, w.Note) } return nil } // walkAlone walks one merge on its own commit (ADR 0276 decision 3): a plan of its own, cut at once. func walkAlone(ctx context.Context, open *stores, m inventory.BatchedMerge, now time.Time) error { inv := open.inventory p := inventory.Plan{ID: fmt.Sprintf("plan-%d", now.UnixNano()), Repository: m.Repository, Branch: m.Branch, Commit: m.Commit, Merged: m.Merged, Created: now, State: inventory.PlanQueued, Tiers: [][]string{}, Modules: map[string]*inventory.PlanModule{}, Note: "walked alone after a failed walk carried it", Delivery: &inventory.PlanDelivery{Alone: true}} if err := inv.SavePlan(ctx, &p); err != nil { return err } if err := inv.AnswerMerge(ctx, m.Repository, m.Commit, p.ID, true); err != nil { return err } fmt.Printf("%s %.8s, carried by a walk that failed, is walked on its own commit as %s\n", m.Repository, m.Commit, p.ID) return planBatch(ctx, open, &p, nil, now) } // planBatch plans a batch's merges as one walk, under the batch's id: per repository the latest merge of its // branch with every file the batch's merges of it changed, the moved modules and their dependents in tiers // along the graph and the order of any delivery group among them. func planBatch(ctx context.Context, open *stores, batch *inventory.Plan, carry []string, now time.Time) error { inv := open.inventory merges, err := inv.MergesOf(ctx, batch.ID) if err != nil { return err } if len(merges) == 0 { batch.State = inventory.PlanSuperseded batch.Note = "a batch that holds no merge: nothing to walk" batch.Delivery.Batch = nil return inv.SavePlan(ctx, batch) } entries, err := inv.Catalogued(ctx) if err != nil { return err } read, err := readForPlanning(ctx, inv) if err != nil { return err } edges, err := inv.Dependencies(ctx) if err != nil { return err } commits, named := carriedOf(merges) var moved []string var members []batchMember for _, m := range combinedMerges(merges) { // **News, whatever was seen since** (review of this change): each merge was judged when it was heard, and // a cut, a fold or a failed walk since marks its modules seen — so a merge walked alone after a failed // walk, or folded, would read as history and move nothing. When it was made is not asked again here. m.MergedAt = "" names, err := movesOfMerge(ctx, inv, m, entries, read) if err != nil { return err } members = append(members, batchMember{merge: m, moved: names}) for _, n := range names { if !slices.Contains(moved, n) { moved = append(moved, n) } } } // **Whether it waits for its delivery's word** is read from what its merges moved (novox/hq ADR 0239), never // from what a folded walk carried along, which holds dependents too. awaits := awaitsFor(entries, moved) held := map[string]bool{} for _, e := range entries { held[e.Manifest.Module] = true } var also []string for _, n := range carry { // One the catalogue no longer holds would fail the walk's ask; it is nobody's to build. if held[n] && !slices.Contains(moved, n) { moved = append(moved, n) also = append(also, n) } } sort.Strings(also) ordered := groupOrderEdges(members, entries, read, edges) var withheld []string plan := planOfMoves(moved, append(append([]inventory.Edge{}, edges...), ordered...)) // **Withheld, a stopped merge's modules** (novox/hq ADR 0299): a module is built at its branch, which holds the // merge a stop left out of the walk these merges were walked again from — built here only as a dependent, it // would deliver that merge. One a merge of this batch moves is built: that merge carries the stopped change, // and the stop named it. if batch.Delivery != nil && batch.Delivery.Batch != nil { if out := withhold(&plan, batch.Delivery.Batch.Withheld, moved); len(out) > 0 { withheld = out fmt.Printf(" withheld, a stopped merge's modules built only as dependents: %s\n", strings.Join(out, ", ")) } } plan.ID, plan.Revision, plan.Created, plan.Epoch = batch.ID, batch.Revision, now, batch.Epoch plan.Commits = commits newest := newestCommit(commits) plan.Repository, plan.Branch, plan.Commit, plan.Merged = newest.Repository, newest.Branch, newest.Commit, newest.Merged plan.Delivery = awaits if plan.Delivery == nil { plan.Delivery = &inventory.PlanDelivery{} } plan.Delivery.Merges = named plan.Delivery.Alone = batch.Delivery != nil && batch.Delivery.Alone plan.Delivery.Withheld = withheld // **Its moments and its class** (novox/hq ADR 0282 decision 6): measured, never acted on. plan.Times = walkTimesAtCut(*batch, plan, entries, now) if len(moved) == 0 { plan.State = inventory.PlanDone plan.Tiers = [][]string{} plan.Note = "moved nothing the mesh holds: nothing to walk" if err := inv.SavePlan(ctx, &plan); err != nil { return err } *batch = plan fmt.Printf("%s (%s): its merges move nothing the mesh holds\n", plan.ID, plan.Named()) return nil } if hasCycle(plan.Tiers, edges) { fmt.Printf(" the last tier depends on itself: %s — built together, in no order\n", strings.Join(plan.Tiers[len(plan.Tiers)-1], ", ")) } if len(also) > 0 { fmt.Printf(" %s, left unbuilt by a walk folded into it, are planned here again\n", strings.Join(also, ", ")) } var tiers []string for i, t := range plan.Tiers { tiers = append(tiers, fmt.Sprintf("%d: %s", i, strings.Join(t, ", "))) } var answers []string for _, n := range named { answers = append(answers, n.Repository+"@"+short(n.Commit)) } fmt.Printf("%s cut: %s, answering %s; %d module(s) in %d tier(s)\n %s\n", plan.ID, plan.Named(), strings.Join(answers, ", "), len(plan.Modules), len(plan.Tiers), strings.Join(tiers, "\n ")) if plan.Waiting() { plan.Note = waitingNote(plan) if err := inv.SavePlan(ctx, &plan); err != nil { return err } *batch = plan fmt.Printf(" %s waits for %s's word before its first tier is asked\n", plan.ID, plan.Delivery.Awaits) return nil } // Written and its first tier asked as one act on the plans (novox/hq issue 213), under the caller's hold. if err := askTier(ctx, inv, &plan); err != nil { return err } if err := inv.SavePlan(ctx, &plan); err != nil { return err } *batch = plan return nil } // withhold takes from a plan every module named that its merges do not move, and the tiers left empty: the // modules taken. func withhold(plan *inventory.Plan, names, moved []string) []string { var out []string for _, n := range names { if _, in := plan.Modules[n]; in && !slices.Contains(moved, n) { delete(plan.Modules, n) out = append(out, n) } } if len(out) == 0 { return nil } var tiers [][]string for _, t := range plan.Tiers { var kept []string for _, n := range t { if !slices.Contains(out, n) { kept = append(kept, n) } } if len(kept) > 0 { tiers = append(tiers, kept) } } if tiers == nil { tiers = [][]string{} } plan.Tiers = tiers sort.Strings(out) return out } // walkTimesAtCut is a walk's own moments as it is cut (novox/hq ADR 0282 decision 6): when its batch's window // closed — no merge for the window's length, or its maximum, whichever came first — when it was cut, and its // class. A merge walked alone had no window. func walkTimesAtCut(batch, walk inventory.Plan, entries []inventory.Entry, now time.Time) *inventory.PlanTimes { cut := now t := &inventory.PlanTimes{Cut: &cut, Class: classOf(walk, entries)} if batch.Delivery != nil && batch.Delivery.Batch != nil { w := batch.Delivery.Batch closed := w.ClosesAt if !w.AtMost.IsZero() && w.AtMost.Before(closed) { closed = w.AtMost } if w.CutAtOnce && !batch.Created.IsZero() { closed = batch.Created // closed when it was made (ADR 0304 decision 1) } if !closed.IsZero() { if closed.After(now) { closed = now } t.WindowClosed = &closed } } return t } // resolverSeat is the seat of the mesh's resolver: a module claiming it is a core module (ADR 0282 decision 1). const resolverSeat = "mesh-dns-resolver" // classOf is a walk's class (novox/hq ADR 0282 decision 1): core when it walks a module on the controller's own // path or one holding the mesh's resolver, leaf otherwise. func classOf(walk inventory.Plan, entries []inventory.Entry) string { resolvers := map[string]bool{} for _, e := range entries { for _, c := range e.Manifest.Claims { if c.Name == resolverSeat { resolvers[e.Manifest.Module] = true } } } for m := range walk.Modules { if _, own := onTheControllersPath[m]; own || resolvers[m] { return inventory.ClassCore } } return inventory.ClassLeaf } // combinedMerges is a batch's merges as one merge per repository's branch: the latest, with every file the // merges of it changed and said to be a module's — on a linear trunk the latest contains the others. A file is // removed when the last merge of the batch that changed it removed it. merges are in the order they were made. func combinedMerges(merges []inventory.BatchedMerge) []link.SourceMoved { type combined struct { latest inventory.BatchedMerge m link.SourceMoved said bool removed map[string]bool paths []string dirs []string cut bool } by := map[string]*combined{} var keys []string for _, b := range merges { var e link.SourceMoved if err := json.Unmarshal(b.Event, &e); err != nil { continue } k := strings.ToLower(b.Repository) + "\x00" + b.Branch c, ok := by[k] if !ok { c = &combined{latest: b, m: e, said: true, removed: map[string]bool{}} by[k] = c keys = append(keys, k) } else if laterMerge(b, c.latest) { c.latest, c.m = b, e } c.said = c.said && e.ModuleDirsSaid c.cut = c.cut || e.PathsTruncated c.paths, c.dirs = union(c.paths, e.Paths), union(c.dirs, e.ModuleDirs) for _, path := range e.Paths { c.removed[path] = slices.Contains(e.Removed, path) } for _, path := range e.Removed { c.removed[path] = true } } sort.Strings(keys) var out []link.SourceMoved for _, k := range keys { c := by[k] m := c.m m.Paths, m.ModuleDirs, m.PathsTruncated, m.ModuleDirsSaid = c.paths, c.dirs, c.cut, c.said m.Removed = nil for _, path := range c.paths { if c.removed[path] { m.Removed = append(m.Removed, path) } } out = append(out, m) } return out } // union is a and b without repeats, in order. func union(a, b []string) []string { out := append([]string{}, a...) for _, s := range b { if !slices.Contains(out, s) { out = append(out, s) } } return out } // batchMember is one repository's merge in a batch and what it moves. type batchMember struct { merge link.SourceMoved moved []string } // afterLines are the `after: ` lines of a pull request's description, as mesh-delivery reads them. var afterLines = regexp.MustCompile(`(?im)^\s*after:\s*([A-Za-z0-9_.\-/]+)\s*$`) // groupOrderEdges is the order of every delivery group among a batch's merges as edges of its walk's tiers // (ADR 0276 decision 5, ADR 0249 unchanged): merges of two repositories or more from one head branch name are // a group; each pair its order rules give puts the modules of the member after in a later tier than the // member before's. A pair in a contradiction orders nothing: its group was refused before it merged. func groupOrderEdges(members []batchMember, entries []inventory.Entry, read map[string][]inventory.ReadRepository, edges []inventory.Edge) []inventory.Edge { byHead := map[string][]batchMember{} for _, m := range members { if m.merge.Head != "" { byHead[m.merge.Head] = append(byHead[m.merge.Head], m) } } var out []inventory.Edge heads := make([]string, 0, len(byHead)) for h := range byHead { heads = append(heads, h) } sort.Strings(heads) for _, h := range heads { group := byHead[h] repos := map[string]bool{} for _, m := range group { repos[strings.ToLower(m.merge.Owner+"/"+m.merge.Repo)] = true } if len(repos) < 2 { continue } var om []orderMember moved := map[string][]string{} for _, m := range group { repository := m.merge.Owner + "/" + m.merge.Repo id := repository + "@" + m.merge.Commit var after []string for _, l := range afterLines.FindAllStringSubmatch(m.merge.Body, -1) { after = append(after, strings.TrimSuffix(l[1], ".git")) } om = append(om, orderMember{ID: id, Repository: repository, Base: m.merge.Base, Head: m.merge.Commit, Number: m.merge.Number, Paths: m.merge.Paths, PathsTruncated: m.merge.PathsTruncated, Removed: m.merge.Removed, ModuleDirs: m.merge.ModuleDirs, ModuleDirsSaid: m.merge.ModuleDirsSaid, CloneURL: m.merge.CloneURL, After: after}) moved[id] = m.moved } order := orderOf(om, reachOfMembers(om, entries, read, edges), edges) contradicted := map[string]bool{} for _, c := range order.Contradictions { for _, id := range c.Members { contradicted[id] = true } } for _, p := range order.Pairs { if contradicted[p.Before] && contradicted[p.After] { continue } for _, before := range moved[p.Before] { for _, after := range moved[p.After] { if before != after { out = append(out, inventory.Edge{From: after, To: before, Kind: edgeGroupOrder}) } } } fmt.Printf(" group %s: %s before %s (%s) inside the walk\n", h, p.Before, p.After, p.Why) } } return out } // searchAfterFailures starts the search a failed walk leaves (ADR 0276 decision 3): every merge it answered // in a later commit of its repository waits to be walked on its own commit. A stopped walk starts none: a // person ended it. func searchAfterFailures(ctx context.Context, inv *inventory.Inventory) error { plans, err := inv.RecentPlans(ctx, 20) if err != nil { return err } for _, p := range plans { if p.State != inventory.PlanFailed || p.Delivery == nil || p.Delivery.Stopped != "" || strings.HasPrefix(p.Note, "stopped by hand") || strings.HasPrefix(p.Note, "closed by hand") { continue } merges, err := inv.MergesOf(ctx, p.ID) if err != nil { return err } for _, m := range merges { if m.Alone || p.CommitOf(m.Repository) == m.Commit { continue // its own commit was walked } if err := inv.AnswerMerge(ctx, m.Repository, m.Commit, "", true); err != nil { return err } fmt.Printf("%s failed; %s %.8s, which it carried in %.8s, is walked on its own commit\n", p.ID, m.Repository, m.Commit, p.CommitOf(m.Repository)) } } return nil } // coverAlone gives every merge waiting to be walked alone to a walk done that holds a later merge of its // branch — one walked alone, or the failed walk retried by a person: the search stops at the first merge // delivered, and that walk answers the older ones. What is left waits, the newest first. func coverAlone(ctx context.Context, inv *inventory.Inventory, alone []inventory.BatchedMerge) ([]inventory.BatchedMerge, error) { var left []inventory.BatchedMerge for _, m := range alone { later, err := inv.LaterMergesOf(ctx, m.Repository, m.Branch, m.Merged) if err != nil { return nil, err } covered := false for _, l := range later { p, err := inv.PlanByID(ctx, l.Plan) if err != nil { return nil, err } if p.State != inventory.PlanDone { continue } if err := inv.AnswerMerge(ctx, m.Repository, m.Commit, p.ID, true); err != nil { return nil, err } if err := nameMerges(ctx, inv, &p); err != nil { return nil, err } fmt.Printf("%s %.8s is answered by %s, which delivered a later merge of its branch on its own commit\n", m.Repository, m.Commit, p.ID) covered = true break } if !covered { left = append(left, m) } } return left, nil } // batchWords is a batch in the operator's words (ADR 0276 decision 7): "assembling: 13 s left (at the latest // 00:31:09); grouped: novox/mesh-catalog@48bda475 (answers 553b7191), …; plan not yet calculated". func batchWords(b inventory.Plan, now time.Time) string { grouped := groupedWords(b) if b.Delivery == nil || b.Delivery.Batch == nil { return "grouped: " + grouped + "; plan not yet calculated" } w := b.Delivery.Batch if w.Own { grouped += " (on the controller's own path)" } if b.State == inventory.PlanQueued { return fmt.Sprintf("queued behind %s; grouped: %s; plan not yet calculated", w.Behind, grouped) } if w.CutAtOnce { return fmt.Sprintf("cut at once, sharing nothing with a batch assembling; grouped: %s; plan not yet calculated", grouped) } left := w.ClosesAt.Sub(now) if w.AtMost.Before(w.ClosesAt) { left = w.AtMost.Sub(now) } if left < 0 { left = 0 } waits := "" if w.Waits != "" { waits = " — " + w.Waits } return fmt.Sprintf("assembling: %s left (at the latest %s)%s; grouped: %s; plan not yet calculated", secondsWords(left), w.AtMost.Local().Format("15:04:05"), waits, grouped) } // groupedWords is a batch's commits, each with the earlier merges it answers. func groupedWords(b inventory.Plan) string { var out []string for _, c := range b.Carried() { s := c.Repository + "@" + short(c.Commit) var answers []string if b.Delivery != nil { for _, m := range b.Delivery.Merges { if strings.EqualFold(m.Repository, c.Repository) && m.Carried == c.Commit { answers = append(answers, short(m.Commit)) } } } if len(answers) > 0 { s += " (answers " + strings.Join(answers, ", ") + ")" } out = append(out, s) } if len(out) == 0 { return "nothing yet" } return strings.Join(out, ", ") } // batchLines is a batch's grouped list as `plans` prints it under its line (asked by the operator, 2026-10-10): // one line per repository, its pull request and title, the earlier merges it answers, and what its merges move. func batchLines(b inventory.Plan) []string { var out []string for _, c := range b.Carried() { var head *inventory.PlanMerge var answers []string moves := map[string]bool{} if b.Delivery != nil { for i := range b.Delivery.Merges { m := &b.Delivery.Merges[i] if !strings.EqualFold(m.Repository, c.Repository) { continue } for _, n := range m.Moves { moves[n] = true } switch { case m.Commit == c.Commit: head = m case m.Carried == c.Commit: answers = append(answers, pullWords(*m)) } } } line := repoName(c.Repository) + " " + short(c.Commit) if head != nil && head.Number > 0 { line = repoName(c.Repository) + " " + pullWords(*head) } if len(answers) > 0 { line += " (answers " + strings.Join(answers, ", ") + ")" } if len(moves) > 0 { line += " · " + strings.Join(sortedKeysOf(boolsToStrings(moves)), ", ") } out = append(out, line) } return out } // pullWords is a merge as its pull request: "#175 a tap shows its outcome", the title cut at fifty runes; the // commit where the announcement named no pull request. func pullWords(m inventory.PlanMerge) string { if m.Number == 0 { return short(m.Commit) } s := fmt.Sprintf("#%d", m.Number) if t := cutTitle(m.Title, 50); t != "" { s += " " + t } return s } // cutTitle is a title cut at n runes, with an ellipsis where it was cut. func cutTitle(title string, n int) string { r := []rune(strings.TrimSpace(title)) if len(r) <= n { return string(r) } return strings.TrimSpace(string(r[:n-1])) + "…" } // boolsToStrings is a set's members, for sortedKeysOf. func boolsToStrings(set map[string]bool) map[string]string { out := make(map[string]string, len(set)) for k := range set { out[k] = "" } return out } // secondsWords is a short wait as a person reads it. func secondsWords(d time.Duration) string { if d < 2*time.Minute { return fmt.Sprintf("%d s", int(d.Round(time.Second)/time.Second)) } return d.Round(time.Second).String() } // walkOrBatch is what a plan record is, in a word. func walkOrBatch(p inventory.Plan) string { if p.Batch() { return "batch" } return "walk" } // orWords is s, or the words for when it is empty. func orWords(s, none string) string { if s == "" { return none } return s }