ForgeGroupEnd gains base; the bridge reads each member's base from the seat's verb groups (Member.base is already in the contract and seatgen's output), keeps groups.<group>.<base>, says one end per base from that base's members, and bridge_group_end_again takes the base. Builds: design 47 G §715, A-236 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
298 lines
15 KiB
Go
298 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshdeliveryclient"
|
|
git "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/git"
|
|
seat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery"
|
|
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
|
|
|
"mesh-delivery-forge-bridge/internal/bridge"
|
|
|
|
"mesh-delivery-forge-bridge/internal/gitfixture"
|
|
"mesh-delivery-forge-bridge/internal/groups"
|
|
)
|
|
|
|
// 1585/T18, T19 (ADR 0633 §4), acceptance 49: bridge_groups names each group the seat mesh-delivery's verb groups answers
|
|
// with its members' state and whether its end is written (open or ended, with why), narrowed by group and by open;
|
|
// bridge_group_end_again writes an ask naming the group, who asked and why, and answers what the bridge answered.
|
|
func TestTheBridgeToolsReadTheGroupsAndAskTheEndAgain(t *testing.T) {
|
|
h1, h2 := strings.Repeat("1", 40), strings.Repeat("2", 40)
|
|
c1, c2 := strings.Repeat("c", 40), strings.Repeat("d", 40)
|
|
grp := func(id string, a, b int, stateB string) seat.Group {
|
|
return seat.Group{ID: id, State: "merging", Members: []seat.Member{{Repository: "novox/a", Number: int64(a), Head: h1, State: "merged", Base: "main"},
|
|
{Repository: "novox/b", Number: int64(b), Head: h2, State: stateB, Base: "main"}}}
|
|
}
|
|
of := []seat.Group{grp("ended", 1, 2, "merged"), grp("ready", 3, 4, "merged"), grp("gathered", 5, 6, "merging"),
|
|
{ID: "solo", Members: []seat.Member{{Repository: "novox/a", Number: 7, Head: h1}}}, grp("", 8, 9, "merged")}
|
|
published := map[string]string{}
|
|
for _, n := range []int{1, 3, 5} {
|
|
published[groups.PublishedKey("novox/a", n)] = c1
|
|
}
|
|
for _, n := range []int{2, 4} {
|
|
published[groups.PublishedKey("novox/b", n)] = c2
|
|
}
|
|
// The state end-asks keeps the last ask of the group ready, answered, and one that does not read.
|
|
answeredAsk, _ := json.Marshal(groups.EndAsk{V: 1, Group: "ready", Base: "main", Why: "lost", By: "lead@shanks", At: time.Date(2026, 11, 4, 22, 0, 0, 0, time.UTC),
|
|
Written: true, Answer: "the end of the group is written again", Answered: time.Date(2026, 11, 4, 22, 0, 1, 0, time.UTC)})
|
|
asks := map[string][]byte{"ready.main": answeredAsk, "gathered.main": []byte("{")}
|
|
var asked groups.EndAsk
|
|
var askedKey string
|
|
ask := func(key string, value []byte) (groups.EndAsk, error) {
|
|
askedKey = key
|
|
a, err := groups.DecodeEndAsk(value)
|
|
a.Written, a.Answer, a.Answered = true, "the end of the group is written again", time.Now()
|
|
asked = a
|
|
return a, err
|
|
}
|
|
tools := Tools(ask, func() time.Time { return time.Date(2026, 11, 4, 23, 0, 0, 0, time.UTC) },
|
|
func() ([]groups.Row, []string, error) {
|
|
rows, unread := groups.Rows(of, published, map[string]string{"groups.ended.main": c2}, asks)
|
|
return rows, unread, nil
|
|
}, nil, nil)
|
|
call := func(name string, args map[string]any) any {
|
|
for _, tl := range tools {
|
|
if tl.Name == name {
|
|
out, err := tl.Run(args)
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", name, err)
|
|
}
|
|
return out
|
|
}
|
|
}
|
|
t.Fatalf("no tool %s", name)
|
|
return nil
|
|
}
|
|
rowsOf := func(args map[string]any) map[string]groups.Row {
|
|
raw, _ := json.Marshal(call(GroupsTool, args))
|
|
var out struct {
|
|
Groups []groups.Row `json:"groups"`
|
|
Unread []string `json:"unread"`
|
|
}
|
|
if err := json.Unmarshal(raw, &out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m := map[string]groups.Row{}
|
|
for _, r := range out.Groups {
|
|
m[r.Group] = r
|
|
}
|
|
if len(args) == 0 && strings.Join(out.Unread, ", ") != "a group with no id, end-asks.gathered.main" {
|
|
t.Errorf("unread = %v, want the group with no id and end-asks.gathered", out.Unread)
|
|
}
|
|
return m
|
|
}
|
|
all := rowsOf(map[string]any{})
|
|
if len(all) != 3 || !all["ended"].Ended || all["ended"].EndedWith != c2 {
|
|
t.Fatalf("groups = %+v", all)
|
|
}
|
|
if r := all["ready"]; !r.Open || !strings.Contains(r.Words, "bridge_group_end_again") || !r.Members[1].Published || r.Members[1].Commit != c2 {
|
|
t.Errorf("ready = %+v, want open and published with its merge commit, its end the thing to say", r)
|
|
}
|
|
if a := all["ready"].LastEndAsk; a == nil || a.By != "lead@shanks" || a.Why != "lost" || !a.Written || a.Answer == "" || all["ended"].LastEndAsk != nil {
|
|
t.Errorf("ready's last end ask = %+v, ended's %+v: want ready's ask and its answer, none for ended", a, all["ended"].LastEndAsk)
|
|
}
|
|
if r := all["gathered"]; !r.Open || !strings.Contains(r.Words, "gathered") || r.Members[1].State != "merging" || r.Members[1].Published {
|
|
t.Errorf("gathered = %+v", r)
|
|
}
|
|
if open := rowsOf(map[string]any{"open": true}); len(open) != 2 || open["ended"].Group != "" {
|
|
t.Errorf("open = %+v, want ready and gathered", open)
|
|
}
|
|
if one := rowsOf(map[string]any{"group": "ready"}); len(one) != 1 {
|
|
t.Errorf("group ready = %+v", one)
|
|
}
|
|
out := call(EndAgainTool, map[string]any{"group": "ready", "base": "main", "why": "the indexer never took it in"}).(map[string]any)
|
|
if askedKey != "ready.main" || asked.Group != "ready" || asked.Base != "main" || asked.Why != "the indexer never took it in" || out["written"] != true {
|
|
t.Errorf("the ask %q %+v, answered %v", askedKey, asked, out)
|
|
}
|
|
for _, tl := range tools {
|
|
if tl.Name == EndAgainTool {
|
|
if _, err := tl.Run(map[string]any{}); err == nil {
|
|
t.Error("an ask naming no group was taken")
|
|
}
|
|
if _, err := tl.Run(map[string]any{"group": "ready"}); err == nil {
|
|
t.Error("an ask naming no base was taken")
|
|
}
|
|
}
|
|
}
|
|
|
|
// §715: a group with members on two bases is two rows, one per base, each with its own members and key.
|
|
split := seat.Group{ID: "feat/s", State: "merging", Members: []seat.Member{{Repository: "novox/a", Number: 11, Base: "beta"}, {Repository: "novox/b", Number: 12, Base: "main"}, {Repository: "novox/c", Number: 13, Base: "beta"}}}
|
|
if rows, unread := groups.Rows([]seat.Group{split}, nil, nil, nil); len(unread) != 0 || len(rows) != 2 || rows[0].Base != "beta" || len(rows[0].Members) != 2 || rows[1].Base != "main" || len(rows[1].Members) != 1 || rows[0].Key == rows[1].Key {
|
|
t.Errorf("rows of a group on two bases = %+v unread %v", rows, unread)
|
|
}
|
|
|
|
// 1585: bridge_merged_message answers the merged message as the bridge publishes it: for a lone pull request the same bytes
|
|
// the event gives through Convert, for a member of a delivery group those with its group and members set as the process
|
|
// sets them; a merge the gate does not hold, or a forge that does not answer, is said.
|
|
t.Run("bridge_merged_message answers the merged message as published", func(t *testing.T) {
|
|
h1, h2 := strings.Repeat("1", 40), strings.Repeat("2", 40)
|
|
c1, c2 := strings.Repeat("c", 40), strings.Repeat("d", 40)
|
|
trees := func(owner, repo, sha string) (string, map[string]string, error) {
|
|
return "root-" + sha[:4], map[string]string{"": "r", "modules/a": "ta-" + sha[:4]}, nil
|
|
}
|
|
files := func(owner, repo, path, ref string) ([]byte, error) {
|
|
if strings.HasSuffix(path, "module.json") {
|
|
return []byte(`{"module":"a"}`), nil
|
|
}
|
|
return nil, bridge.ErrNotFound
|
|
}
|
|
published := func(repo string, number int, head, branch, merge string) forgemsg.Forge {
|
|
msg, err := bridge.MergedMessage("novox", repo, number, branch, head, merge, "main", trees, files)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f, err := forgemsg.DecodeForge(msg.Data)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return f
|
|
}
|
|
// The seat mesh-delivery's groups: feat/g of novox/a#9 and novox/b#7; a lone pull request is in none.
|
|
of := []seat.Group{{ID: "fix/x", Members: []seat.Member{{Repository: "novox/a", Number: 9, Head: h2}, {Repository: "novox/b", Number: 7, Head: h1}}}}
|
|
for _, c := range []struct {
|
|
name string
|
|
of []seat.Group
|
|
repository string
|
|
merge string
|
|
number int
|
|
api bridge.Caller
|
|
want forgemsg.Forge
|
|
fail string
|
|
}{
|
|
{name: "a lone pull request, by number", repository: "novox/a", merge: c2, number: 9,
|
|
api: forgeAnswers(t, 9, "", "merged", "fix/x", h2, c2, "main"), want: published("a", 9, h2, "fix/x", c2)},
|
|
{name: "a lone pull request, by the merge commit", repository: "novox/a", merge: c2,
|
|
api: forgeAnswers(t, 0, c2, "merged", "fix/x", h2, c2, "main"), want: published("a", 9, h2, "fix/x", c2)},
|
|
{name: "a member of a group", of: of, repository: "novox/a", merge: c2,
|
|
api: forgeAnswers(t, 0, c2, "merged", "fix/x", h2, c2, "main"), want: func() forgemsg.Forge {
|
|
f := published("a", 9, h2, "fix/x", c2)
|
|
f.Group, f.Members = "fix/x", []meshdeliveryclient.Head{{Repository: "novox/a", Number: 9, Head: h2}, {Repository: "novox/b", Number: 7, Head: h1}}
|
|
return f
|
|
}()},
|
|
{name: "a forge pull request merged at another commit", repository: "novox/a", merge: c2, number: 9,
|
|
api: forgeAnswers(t, 9, "", "merged", "fix/x", h2, c1, "main"), fail: "not at " + c2},
|
|
{name: "a forge pull request not merged", repository: "novox/a", merge: c2, number: 9,
|
|
api: forgeAnswers(t, 9, "", "open", "fix/x", h2, "", "main"), fail: "not merged"},
|
|
// design 47 G §714: no filter to the trunk alone; the merged message names the branch it was merged into.
|
|
{name: "a forge pull request merged into another branch", repository: "novox/a", merge: c2, number: 9,
|
|
api: forgeAnswers(t, 9, "", "merged", "fix/x", h2, c2, "dev"), want: func() forgemsg.Forge {
|
|
f := published("a", 9, h2, "fix/x", c2)
|
|
f.Base = "dev"
|
|
return f
|
|
}()},
|
|
{name: "a forge pull request with no head branch", repository: "novox/a", merge: c2, number: 9,
|
|
api: forgeAnswers(t, 9, "", "merged", "", h2, c2, "main"), fail: "no head branch"},
|
|
{name: "a seat answer that says an error", repository: "novox/a", merge: c2, number: 9,
|
|
api: answering(gitfixture.Given(t, "pull answers an error, never none", "verb:pull.answer")), fail: "holds no pull request"},
|
|
{name: "a seat answer with no pull request", repository: "novox/a", merge: c2, number: 9,
|
|
api: answering(json.RawMessage(`{}`)), fail: "carries nothing"},
|
|
{name: "a forge that does not answer", repository: "novox/a", merge: c2,
|
|
api: func(context.Context, string, any) (json.RawMessage, error) {
|
|
return nil, errors.New("the forge is down")
|
|
}, fail: "the forge is down"},
|
|
} {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
raw, err := MergedOf(c.of, c.repository, c.merge, c.number, trees, files, c.api)
|
|
if c.fail != "" {
|
|
if err == nil || !strings.Contains(err.Error(), c.fail) {
|
|
t.Fatalf("err %v, want %q", err, c.fail)
|
|
}
|
|
return
|
|
}
|
|
want, _ := forgemsg.EncodeForge(c.want)
|
|
if err != nil || !bytes.Equal(raw, want) {
|
|
t.Fatalf("err %v, answer\n%s\nwant the published message\n%s", err, raw, want)
|
|
}
|
|
})
|
|
}
|
|
if _, err := MergedOf(nil, "novox/a", c2, 9, func(string, string, string) (string, map[string]string, error) {
|
|
return "", nil, errors.New("the forge does not answer")
|
|
}, files, forgeAnswers(t, 9, "", "merged", "fix/x", h2, c2, "main")); err == nil || !strings.Contains(err.Error(), "does not answer") {
|
|
t.Errorf("a forge that does not answer: %v", err)
|
|
}
|
|
})
|
|
|
|
// Design 47 *What the gate owes the forge*: bridge_owed reads performed and asks the backlog; bridge_owed_again asks the
|
|
// act again; bridge_owed_drop writes the hand-act log first and drops nothing it could not record.
|
|
t.Run("the owed tools", func(t *testing.T) {
|
|
at := time.Date(2026, 11, 9, 21, 0, 0, 0, time.UTC)
|
|
var asked []groups.OwedAsk
|
|
ask := func(key string, value []byte) ([]byte, error) {
|
|
a, err := groups.DecodeOwedAsk(value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
asked = append(asked, a)
|
|
a.Answered, a.Outcome, a.Answer = at, forgemsg.OwedPerformed, "said as owed.ended: performed"
|
|
if a.Kind == groups.OwedBacklog {
|
|
a.Backlog = []groups.Backlog{{Stream: seat.StreamOwed, Waiting: 1}}
|
|
}
|
|
return json.Marshal(a)
|
|
}
|
|
rows := []OwedRow{{Of: seat.Seat, ID: "a", Outcome: forgemsg.OwedPerformed, At: at}, {Of: "mesh-delivery-checker", ID: "b", Outcome: forgemsg.OwedRefused, At: at}}
|
|
var recorded []string
|
|
recordErr := error(nil)
|
|
record := func(what, why string) error { recorded = append(recorded, what+": "+why); return recordErr }
|
|
tools := map[string]func(map[string]any) (any, error){}
|
|
for _, tl := range OwedTools(ask, func() ([]OwedRow, error) { return rows, nil }, record, func() time.Time { return at }) {
|
|
tools[tl.Name] = tl.Run
|
|
}
|
|
out, err := tools[OwedTool](map[string]any{"of": seat.Seat})
|
|
if m, _ := out.(map[string]any); err != nil || len(m["performed"].([]OwedRow)) != 1 || len(m["backlog"].([]groups.Backlog)) != 1 {
|
|
t.Errorf("bridge_owed: %v %v", out, err)
|
|
}
|
|
if out, err := tools[OwedAgainTool](map[string]any{"of": seat.Seat, "id": "a"}); err != nil || out.(map[string]any)["outcome"] != forgemsg.OwedPerformed ||
|
|
asked[len(asked)-1].Kind != groups.OwedAgain {
|
|
t.Errorf("bridge_owed_again: %v %v", out, err)
|
|
}
|
|
if _, err := tools[OwedDropTool](map[string]any{"of": seat.Seat, "id": "a"}); err == nil || len(recorded) != 0 {
|
|
t.Errorf("a drop with no why: %v, recorded %v", err, recorded)
|
|
}
|
|
n := len(asked)
|
|
recordErr = errors.New("mesh-hand-acts has no holder that answers")
|
|
if _, err := tools[OwedDropTool](map[string]any{"of": seat.Seat, "id": "a", "why": "merged by hand"}); err == nil || len(asked) != n {
|
|
t.Errorf("a drop whose hand act was not recorded: %v, asked %d", err, len(asked)-n)
|
|
}
|
|
recordErr = nil
|
|
if _, err := tools[OwedDropTool](map[string]any{"of": seat.Seat, "id": "a", "why": "merged by hand"}); err != nil || asked[len(asked)-1].Why != "merged by hand" {
|
|
t.Errorf("bridge_owed_drop: %v %+v", err, asked)
|
|
}
|
|
if _, _, err := groups.NewOwedAsk(groups.OwedAgain, "", "a", "", "", at); err == nil {
|
|
t.Error("an ask naming no seat was taken")
|
|
}
|
|
})
|
|
}
|
|
|
|
// forgeAnswers is the seat git's verb pull for one pull request, asked by number (when number is given) or by the merge
|
|
// commit asked: the seat's own fixture with that pull request's state, branch, head, merge commit and base; any other
|
|
// ask is refused as the seat refuses it.
|
|
func forgeAnswers(t *testing.T, number int, byMerge, state, branch, head, merge, base string) bridge.Caller {
|
|
t.Helper()
|
|
var a git.PullAnswer
|
|
if err := json.Unmarshal(gitfixture.Given(t, "pull answers", "verb:pull.answer"), &a); err != nil || a.Pull == nil {
|
|
t.Fatalf("the seat's pull fixture: %v", err)
|
|
}
|
|
p := *a.Pull
|
|
p.Repository, p.Number, p.State, p.Branch, p.Head, p.MergeCommit, p.Base = "novox/a", 9, state, branch, head, merge, base
|
|
answer, _ := json.Marshal(git.PullAnswer{Pull: &p})
|
|
return func(_ context.Context, key string, body any) (json.RawMessage, error) {
|
|
in, ok := body.(git.PullInput)
|
|
if key != "seat:git.pull" || !ok || in.Repository != "novox/a" || int(in.Number) != number || in.MergeCommit != byMerge {
|
|
return json.RawMessage(`{"error":"the forge holds no such pull request"}`), nil
|
|
}
|
|
return answer, nil
|
|
}
|
|
}
|
|
|
|
// answering is a seat that answers raw to every ask.
|
|
func answering(raw json.RawMessage) bridge.Caller {
|
|
return func(context.Context, string, any) (json.RawMessage, error) { return raw, nil }
|
|
}
|