Files
mesh-delivery/modules/mesh-delivery-forge-bridge/internal/groups/groups.go
T
jschoubben 0b597eb417 FB3, FB4, FB5: the bridge performs what the gate owes through the seat git and says each outcome, reads groups from the seat, and its owed tools
Taken over from the join (6ada2089, f1417ab0, 6e162a60, 3a4e62b0, 73fb42d7, ee15a280, a5476f62, 94a4e62a, 33907a81, e519cc87, e8f268c1) and fitted to the final
contracts: the seat's events are pull-updated, pull-merged, pull-closed, forge-delete; OwedEnded keeps only the fields §164 names; merge-pull
takes no ask. A merge act is performed only as a switched act (§169); from G the gate owes none (§167).

Builds: design 47 G §43, §45, §157, §161, §162, §163, §164, §167, §168, §169, §171, §250

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 11:54:56 +01:00

237 lines
9.2 KiB
Go

// Package groups is what the forge bridge's process and its tools bundle share of a delivery group's end (novox/hq ADR
// 0633 §4, issue 1585): the keys of the bridge's state performed, and the asks of the state end-asks, through which the
// tool bridge_group_end_again asks the process to say a group's end again (the bridge serves no request subject, ADR 0437
// §4, so a tool asks in a state the process watches, as indexer_take_in asks the indexer in takeins).
package groups
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"slices"
"sort"
"strings"
"time"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery"
"git.novox.be/novox/mesh-delivery/sdk/trunk"
"git.novox.be/novox/mesh-sdk/go/keys"
)
// The bridge's states the tools read and write.
const (
// Module is the forge bridge's own name (its manifest's module, checked by the bridge's names test).
Module = "mesh-delivery-forge-bridge"
// PerformedState is the bridge's state of what it performed and published.
PerformedState = "performed"
// EndAsksState is the state of asks to say a group's end again, keyed by the group escaped as a key.
EndAsksState = "end-asks"
// EndAsksBucket is its bucket on the bus.
EndAsksBucket = Module + "_" + EndAsksState
)
// Key is the key of performed that says a group's end is written: `groups.<group, escaped as a key>`, its value the last
// member's merge commit the end named.
func Key(group string) (string, error) {
part, err := KeyOf(group)
if err != nil {
return "", err
}
return "groups." + part, nil
}
// KeyOf is a group's id (its branch) escaped as a key, as the states performed and end-asks key it.
func KeyOf(group string) (string, error) {
if strings.TrimSpace(group) == "" {
return "", errors.New("a group's id is empty")
}
return keys.EscapeName(group), nil
}
// PublishedKey is the key of performed that says a member's merged message is written: `published.<16 hex digits of the
// sha256 of <repository>#<number>>`, its value the merge commit the message named (a pull request merges once).
func PublishedKey(repository string, number int) string {
sum := sha256.Sum256([]byte(fmt.Sprintf("%s#%d", strings.ToLower(repository), number)))
return "published." + hex.EncodeToString(sum[:8])
}
// EndID is the message id of a group's end, and of one said again by hand at a time: the stream's duplicate window drops a
// second publish of the same id.
func EndID(group, lastMerge, again string) string {
id := "group-end." + group + "." + lastMerge
if again != "" {
id += "." + again
}
return id
}
// AgainStamp is the time a hand ask appends to a group end's message id.
func AgainStamp(at time.Time) string { return at.UTC().Format("20060102T150405.000000000Z") }
// EndAsk is a value of the end-asks state: which group's end a person asked to be said again, why, who and when, and, once
// the bridge read it, its answer.
type EndAsk struct {
V int `json:"v"`
Group string `json:"group"`
Why string `json:"why,omitempty"`
By string `json:"by,omitempty"`
At time.Time `json:"at"`
Written bool `json:"written,omitempty"`
Answer string `json:"answer,omitempty"`
Answered time.Time `json:"answered,omitzero"`
}
// Done says the ask is answered.
func (a EndAsk) Done() bool { return !a.Answered.IsZero() }
// NewEndAsk is the value bridge_group_end_again writes for a group: its key (the group escaped as a key) and the value.
func NewEndAsk(group, why, by string, at time.Time) (string, []byte, error) {
group = strings.TrimSpace(group)
if group == "" {
return "", nil, errors.New("name the group whose end is said again (group)")
}
key, err := KeyOf(group)
if err != nil {
return "", nil, err
}
data, err := json.Marshal(EndAsk{V: 1, Group: group, Why: strings.TrimSpace(why), By: by, At: at.UTC()})
return key, data, err
}
// DecodeEndAsk reads an end-asks value.
func DecodeEndAsk(data []byte) (EndAsk, error) {
var a EndAsk
if err := json.Unmarshal(data, &a); err != nil {
return a, err
}
if a.V != 1 || a.Group == "" {
return a, errors.New("an end-asks value of no version 1 naming no group")
}
return a, nil
}
// Row is one group as bridge_groups answers it: the seat mesh-delivery's group and what the bridge kept of its end.
type Row struct {
Key string `json:"key"`
Group string `json:"group"`
State string `json:"state,omitempty"`
Members []RowMember `json:"members"`
Ended bool `json:"ended"`
// EndedWith is the last member's merge commit the end named, when it is written.
EndedWith string `json:"ended-with,omitempty"`
Open bool `json:"open"`
Words string `json:"words"`
// LastEndAsk is the last ask to say the group's end again (bridge_group_end_again), its who, why and when, and the
// bridge's answer once it read it; absent when none was asked. The state end-asks keeps the last per group.
LastEndAsk *EndAsk `json:"last-end-ask,omitempty"`
}
// RowMember is one member of a group: its delivery state at the seat and, once its merged message is written, the merge
// commit it named.
type RowMember struct {
Repository string `json:"repository"`
Number int `json:"number"`
State string `json:"state"`
Commit string `json:"commit,omitempty"`
Published bool `json:"published"`
}
// Rows are the groups the seat mesh-delivery's verb groups answers, each with what the bridge kept: ended (its end written,
// with the last merge it named) or open (some member's merged message is not written, or its end not written). published
// is performed's `published.` values by key (the merge commit), ended its `groups.` values by key; asks are the end-asks
// values by the same key, and one that does not read is named unread as `end-asks.<key>`. A group whose id cannot be a
// key is named unread by its id.
func Rows(of []seat.Group, published, ended map[string]string, asks map[string][]byte) (rows []Row, unread []string) {
lastAsk := map[string]*EndAsk{}
for key, raw := range asks {
a, err := DecodeEndAsk(raw)
if err != nil {
unread = append(unread, EndAsksState+"."+key)
continue
}
lastAsk[key] = &a
}
for _, g := range of {
if len(g.Members) < 2 {
continue
}
key, err := KeyOf(g.ID)
if err != nil {
unread = append(unread, "a group with no id")
continue
}
r := Row{Key: key, Group: g.ID, State: g.State}
allPublished := true
for _, m := range g.Members {
commit, pub := published[PublishedKey(m.Repository, int(m.Number))]
r.Members = append(r.Members, RowMember{Repository: m.Repository, Number: int(m.Number), State: m.State, Commit: commit, Published: pub})
allPublished = allPublished && pub
}
r.EndedWith, r.Ended = ended["groups."+key]
r.LastEndAsk = lastAsk[key]
r.Open = !r.Ended
switch {
case r.Ended:
r.Words = "its end is written"
case allPublished:
r.Words = "every member's merged message is written, and its end is not written: bridge_group_end_again says it"
default:
r.Words = "gathered: not every member's merged message is written"
}
rows = append(rows, r)
}
sortRows(rows)
sort.Strings(unread)
return rows, unread
}
func sortRows(rows []Row) { sort.Slice(rows, func(i, j int) bool { return rows[i].Key < rows[j].Key }) }
// MembersOf are a group's members with their heads, in the group's order.
func MembersOf(g seat.Group) []meshdeliveryclient.Head {
out := make([]meshdeliveryclient.Head, 0, len(g.Members))
for _, m := range g.Members {
out = append(out, meshdeliveryclient.Head{Repository: m.Repository, Number: int(m.Number), Head: m.Head})
}
return out
}
// Named is the group of id among the groups the seat answered, when it has more than one member and names the pull
// request number of repository among them (a lone pull request is no group).
func Named(of []seat.Group, id, repository string, number int) (seat.Group, bool) {
for _, g := range of {
if g.ID != id || len(g.Members) < 2 {
continue
}
if slices.ContainsFunc(g.Members, func(m seat.Member) bool {
return trunk.SameRepository(m.Repository, repository) && int(m.Number) == number
}) {
return g, true
}
}
return seat.Group{}, false
}
// Read is every delivery group the seat mesh-delivery's verb groups answers, the ended ones too (all), called through call
// by the verb's manifest key: the bridge reads a group from the seat, never from the gate's state (design 47 *A group's
// merges are taken in together*). An answer saying an error, or one the contract cannot type, is an error: a failed read
// is never read as no group.
func Read(ctx context.Context, call func(ctx context.Context, key string, body any) (json.RawMessage, error)) ([]seat.Group, error) {
raw, err := call(ctx, "seat:"+seat.Seat+"."+seat.VerbGroups, seat.GroupsInput{All: seat.GroupsInputAllTrue})
if err != nil {
return nil, fmt.Errorf("the seat %s did not answer %s: %w", seat.Seat, seat.VerbGroups, err)
}
var a seat.GroupsAnswer
if err := json.Unmarshal(raw, &a); err != nil {
return nil, fmt.Errorf("the seat %s's answer of %s is not its contract's: %w", seat.Seat, seat.VerbGroups, err)
}
if a.Error != "" {
return nil, fmt.Errorf("the seat %s refused %s: %s", seat.Seat, seat.VerbGroups, a.Error)
}
return a.Groups, nil
}