From a600090fcd8777114e22285cb372a97611eadc42 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 09:19:46 +0100 Subject: [PATCH] G-715: the forge bridge says a group's end once per base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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.., 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 --- .../mesh-delivery-forge-bridge-tools/main.go | 7 +- .../main_test.go | 25 ++-- .../mesh-delivery-forge-bridge/groupask.go | 4 +- .../cmd/mesh-delivery-forge-bridge/groups.go | 24 ++-- .../mesh-delivery-forge-bridge/groups_test.go | 30 +++-- .../integration_test.go | 4 +- .../forgemsg/group.go | 8 +- .../internal/groups/groups.go | 120 ++++++++++++------ 8 files changed, 145 insertions(+), 77 deletions(-) diff --git a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge-tools/main.go b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge-tools/main.go index 31ab8967..3a62f468 100644 --- a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge-tools/main.go +++ b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge-tools/main.go @@ -101,11 +101,12 @@ func Tools(ask asker, now func() time.Time, groupsRead func() ([]groups.Row, []s "writes it again from the seat mesh-delivery's groups, with the time appended to the message id so the stream does not drop it as " + "a duplicate; the indexer takes an end for a group it already took in without changing anything. Refused while " + "a member's merged message is not written. Answers whether it was written.", - Input: map[string]any{"type": "object", "required": []string{"group"}, "properties": map[string]any{ + Input: map[string]any{"type": "object", "required": []string{"group", "base"}, "properties": map[string]any{ "group": map[string]any{"type": "string", "description": "the group, by its id (its branch)"}, + "base": map[string]any{"type": "string", "description": "the watched branch its members merged into, whose end is said again"}, "why": map[string]any{"type": "string", "description": "why its end is said again, in plain words"}}}, Run: func(args map[string]any) (any, error) { - key, value, err := groups.NewEndAsk(str(args, "group"), str(args, "why"), stdio.CallerOf(args), now()) + key, value, err := groups.NewEndAsk(str(args, "group"), str(args, "base"), str(args, "why"), stdio.CallerOf(args), now()) if err != nil { return nil, err } @@ -113,7 +114,7 @@ func Tools(ask asker, now func() time.Time, groupsRead func() ([]groups.Row, []s if err != nil { return nil, err } - return map[string]any{"group": a.Group, "written": a.Written, "answer": a.Answer, "by": a.By, "why": a.Why}, nil + return map[string]any{"group": a.Group, "base": a.Base, "written": a.Written, "answer": a.Answer, "by": a.By, "why": a.Why}, nil }}, } } diff --git a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge-tools/main_test.go b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge-tools/main_test.go index 83d8a758..c88ff795 100644 --- a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge-tools/main_test.go +++ b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge-tools/main_test.go @@ -27,8 +27,8 @@ 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"}, - {Repository: "novox/b", Number: int64(b), Head: h2, State: stateB}}} + 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")} @@ -40,9 +40,9 @@ func TestTheBridgeToolsReadTheGroupsAndAskTheEndAgain(t *testing.T) { 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", Why: "lost", By: "lead@shanks", At: time.Date(2026, 11, 4, 22, 0, 0, 0, time.UTC), + 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": answeredAsk, "gathered": []byte("{")} + 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) { @@ -54,7 +54,7 @@ func TestTheBridgeToolsReadTheGroupsAndAskTheEndAgain(t *testing.T) { } 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": c2}, asks) + 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 { @@ -83,7 +83,7 @@ func TestTheBridgeToolsReadTheGroupsAndAskTheEndAgain(t *testing.T) { 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" { + 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 @@ -107,8 +107,8 @@ func TestTheBridgeToolsReadTheGroupsAndAskTheEndAgain(t *testing.T) { 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", "why": "the indexer never took it in"}).(map[string]any) - if askedKey != "ready" || asked.Group != "ready" || asked.Why != "the indexer never took it in" || out["written"] != true { + 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 { @@ -116,9 +116,18 @@ func TestTheBridgeToolsReadTheGroupsAndAskTheEndAgain(t *testing.T) { 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. diff --git a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/groupask.go b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/groupask.go index ef54a70f..88158e91 100644 --- a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/groupask.go +++ b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/groupask.go @@ -58,10 +58,10 @@ func (g *grouper) answerEnd(ctx context.Context, a groups.EndAsk, at time.Time) switch { case !ok || len(m.Members) < 2: a.Answer = "the seat " + seat.Seat + " holds no group " + a.Group + " of more than one member" - case g.end(ctx, m, groups.AgainStamp(a.At)): + case g.end(ctx, m, a.Base, groups.AgainStamp(a.At)): a.Written, a.Answer = true, "the end of the group is written again" default: - a.Answer = "the end is not written: a member's merged message is not written, or FORGE refused it (the bridge's journal says which)" + a.Answer = "the end on " + a.Base + " is not written: a member's merged message is not written, or FORGE refused it (the bridge's journal says which)" } return a } diff --git a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/groups.go b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/groups.go index aade6a14..e5aba649 100644 --- a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/groups.go +++ b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/groups.go @@ -20,7 +20,8 @@ import ( // What it keeps of it is in its state performed: `published.` for each member's merged message written, its value // the merge commit it named (the hash is the first 16 hex digits of the sha256 of `#`), and // `groups.` once the group's end is written, its value the last member's merge commit. A group whose end is not -// written yet is open (bridge_groups reads them). +// written yet is open (bridge_groups reads them). A group gathers per watched branch: its end is said once per base, from +// the members whose base the seat says is that branch, and its key is `groups..` (design 47 G §715). // groupsRead is every delivery group the seat answers. type groupsRead func(ctx context.Context) ([]seat.Group, error) @@ -93,19 +94,24 @@ func (g *grouper) published(ctx context.Context, f forgemsg.Forge) { return } if m, ok := g.groupOf(ctx, f); ok { - g.end(ctx, m, "") + g.end(ctx, m, f.Base, "") } } // end says the group's end (forge.group.merged) once every member's merged message is written, after the last of them; // never on a refusal (ADR 0441 §3: a held group merges on from it), since a refused member's message is never written. It -// is written once per group: `groups.` in performed says it was. again says it is written again by hand +// is written once per group and base: `groups..` in performed says it was. again says it is written again by hand // (bridge_group_end_again), with the time appended to the message id, so the duplicate window does not drop it. -func (g *grouper) end(ctx context.Context, m seat.Group, again string) bool { +func (g *grouper) end(ctx context.Context, m seat.Group, base, again string) bool { if len(m.Members) < 2 { return false } - key, err := groupkeys.Key(m.ID) + onBase := groupkeys.OnBase(m, base) + if len(onBase) == 0 { + g.logger.Printf("the group %q has no member on %q, so no end is said on it", m.ID, base) + return false + } + key, err := groupkeys.Key(m.ID, base) if err != nil { g.logger.Printf("the group %q cannot be kept, so its end is not said: %v", m.ID, err) return false @@ -116,8 +122,8 @@ func (g *grouper) end(ctx context.Context, m seat.Group, again string) bool { } } // The members in the group's order, which the gate merges them in (ADR 0441 §5), so the last commit names the end. - end := forgemsg.ForgeGroupEnd{Group: m.ID} - for _, member := range m.Members { + end := forgemsg.ForgeGroupEnd{Group: m.ID, Base: base} + for _, member := range onBase { merge, ok, err := g.done.Get(ctx, groupkeys.PublishedKey(member.Repository, int(member.Number))) if err != nil || !ok || merge == "" { return false // its merged message is not written yet: said when it is (published) @@ -130,13 +136,13 @@ func (g *grouper) end(ctx context.Context, m seat.Group, again string) bool { return false } last := end.Members[len(end.Members)-1].Merge - if err := g.send(ctx, forgeSubject(forgemsg.ForgeGroupEndEvent), data, groupkeys.EndID(m.ID, last, again)); err != nil { + if err := g.send(ctx, forgeSubject(forgemsg.ForgeGroupEndEvent), data, groupkeys.EndID(m.ID, base, last, again)); err != nil { g.logger.Printf("the end of the group %q is not written on FORGE, written by bridge_group_end_again: %v", m.ID, err) return false } if err := g.done.Put(ctx, key, last); err != nil { g.logger.Printf("the end of the group %q is written and not kept: the stream's duplicate window drops a second one: %v", m.ID, err) } - g.logger.Printf("the end of the group %q is written: %d members merged, the last %s", m.ID, len(end.Members), last) + g.logger.Printf("the end of the group %q on %q is written: %d members merged, the last %s", m.ID, base, len(end.Members), last) return true } diff --git a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/groups_test.go b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/groups_test.go index 34b8b28f..ad922071 100644 --- a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/groups_test.go +++ b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/groups_test.go @@ -33,7 +33,8 @@ func TestAGroupsMembersNameItAndItsEndIsSaidOnce(t *testing.T) { h1, h2 := strings.Repeat("1", 40), strings.Repeat("2", 40) c1, c2 := strings.Repeat("c", 40), strings.Repeat("d", 40) group := seat.Group{ID: "feat/take-in", State: "merging", Members: []seat.Member{ - {Repository: "novox/mesh-sdk", Number: 37, Head: h1, State: "merged"}, {Repository: "novox/mesh-delivery", Number: 9, Head: h2, State: "merging"}}} + {Repository: "novox/mesh-sdk", Number: 37, Head: h1, State: "merged", Base: "beta"}, {Repository: "novox/mesh-delivery", Number: 9, Head: h2, State: "merging", Base: "beta"}, + {Repository: "novox/mesh-tools", Number: 5, Head: h2, State: "merging", Base: "main"}}} var of []seat.Group var read error var logs bytes.Buffer @@ -41,7 +42,7 @@ func TestAGroupsMembersNameItAndItsEndIsSaidOnce(t *testing.T) { g := &grouper{send: pub.send, done: done, logger: log.New(&logs, "", 0), groups: func(context.Context) ([]seat.Group, error) { return of, read }} mergedOf := func(repo string, number int, head, commit string) []byte { - data, err := forgemsg.EncodeForge(forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: repo, Branch: "feat/take-in", Number: number, Head: head, Merge: commit, Root: "r"}) + data, err := forgemsg.EncodeForge(forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: repo, Branch: "feat/take-in", Base: "beta", Number: number, Head: head, Merge: commit, Root: "r"}) if err != nil { t.Fatal(err) } @@ -60,7 +61,7 @@ func TestAGroupsMembersNameItAndItsEndIsSaidOnce(t *testing.T) { read = nil // The first member: its message names the group and both members in the group's order; the group is not ended. data, f, ok := g.member(ctx, mergedOf("novox/mesh-sdk", 37, h1, c1)) - if back, err := forgemsg.DecodeForge(data); !ok || err != nil || back.Group != "feat/take-in" || len(back.Members) != 2 || back.Members[0].Number != 37 { + if back, err := forgemsg.DecodeForge(data); !ok || err != nil || back.Group != "feat/take-in" || len(back.Members) != 3 || back.Members[0].Number != 37 { t.Fatalf("a member's message: %+v %v", back, err) } g.published(ctx, f) @@ -71,20 +72,27 @@ func TestAGroupsMembersNameItAndItsEndIsSaidOnce(t *testing.T) { _, f, _ = g.member(ctx, mergedOf("novox/mesh-delivery", 9, h2, c2)) g.published(ctx, f) g.published(ctx, f) - if len(pub.subjects) != 1 || pub.subjects[0] != "mesh.stream.mesh-delivery-forge-bridge.event.forge.group.merged" || pub.ids[0] != "group-end.feat/take-in."+c2 { - t.Fatalf("ends %v ids %v, want one forge.group.merged with id group-end.feat/take-in.%s", pub.subjects, pub.ids, c2) + if len(pub.subjects) != 1 || pub.subjects[0] != "mesh.stream.mesh-delivery-forge-bridge.event.forge.group.merged" || pub.ids[0] != "group-end.feat/take-in.beta."+c2 { + t.Fatalf("ends %v ids %v, want one forge.group.merged with id group-end.feat/take-in.beta.%s", pub.subjects, pub.ids, c2) } end, err := forgemsg.DecodeForgeGroupEnd(pub.data[0]) - if err != nil || len(end.Members) != 2 || end.Members[0].Merge != c1 || end.Members[1].Merge != c2 { - t.Errorf("the end = %+v %v, want both members in the group's order", end, err) + if err != nil || end.Base != "beta" || len(end.Members) != 2 || end.Members[0].Merge != c1 || end.Members[1].Merge != c2 { + t.Errorf("the end = %+v %v, want the two members on beta in the group's order", end, err) } - if key, _ := groupkeys.Key("feat/take-in"); done[key] != c2 { + if key, _ := groupkeys.Key("feat/take-in", "beta"); done[key] != c2 { t.Errorf("performed %v: want groups.feat~take-in kept with the last merge", done) } + // §715: the member on main is on another base, and its merged message ends the group on main alone, once. + done[groupkeys.PublishedKey("novox/mesh-tools", 5)] = c1 + g.published(ctx, forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: "novox/mesh-tools", Branch: "feat/take-in", Base: "main", Number: 5, Merge: c1}) + g.published(ctx, forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: "novox/mesh-tools", Branch: "feat/take-in", Base: "main", Number: 5, Merge: c1}) + if mainEnd, err := forgemsg.DecodeForgeGroupEnd(pub.data[len(pub.data)-1]); len(pub.subjects) != 2 || err != nil || mainEnd.Base != "main" || len(mainEnd.Members) != 1 || mainEnd.Members[0].Number != 5 { + t.Errorf("ends %d, the last %+v %v: want one more end, on main, of the member on main alone", len(pub.subjects), mainEnd, err) + } // 1585/T19: an ask to say a group's end again is answered from the seat's groups, with the ask's time on the message id; // not written while a member's merged message is not written, with the reason; not for a group the seat does not hold, // nor when the groups cannot be read. - ask := groupkeys.EndAsk{V: 1, Group: "feat/take-in", By: "operator@shanks", Why: "the indexer never took it in", At: time.Date(2026, 11, 4, 23, 0, 0, 123, time.UTC)} + ask := groupkeys.EndAsk{V: 1, Group: "feat/take-in", Base: "beta", By: "operator@shanks", Why: "the indexer never took it in", At: time.Date(2026, 11, 4, 23, 0, 0, 123, time.UTC)} for _, tc := range []struct { name string of []seat.Group @@ -94,6 +102,7 @@ func TestAGroupsMembersNameItAndItsEndIsSaidOnce(t *testing.T) { answer string }{ {"every message written: written again", []seat.Group{group}, nil, true, true, "written again"}, + {"a base no member is on", []seat.Group{{ID: group.ID, Members: []seat.Member{group.Members[2], {Repository: "novox/mesh-sdk", Number: 37, Base: "main"}}}}, nil, true, false, "not written"}, {"a member's message not written", []seat.Group{group}, nil, false, false, "not written"}, {"no such group at the seat", []seat.Group{{ID: "other", Members: group.Members}}, nil, true, false, "holds no group"}, {"the groups cannot be read", nil, errors.New("no answer"), true, false, "cannot be read"}, @@ -101,6 +110,7 @@ func TestAGroupsMembersNameItAndItsEndIsSaidOnce(t *testing.T) { pub, done := &idPub{}, memStrs{} if tc.published { done[groupkeys.PublishedKey("novox/mesh-sdk", 37)], done[groupkeys.PublishedKey("novox/mesh-delivery", 9)] = c1, c2 + done[groupkeys.PublishedKey("novox/mesh-tools", 5)] = c1 } g := &grouper{send: pub.send, done: done, logger: log.New(&bytes.Buffer{}, "", 0), groups: func(context.Context) ([]seat.Group, error) { return tc.of, tc.read }} @@ -108,7 +118,7 @@ func TestAGroupsMembersNameItAndItsEndIsSaidOnce(t *testing.T) { if got.Written != tc.written || !strings.Contains(got.Answer, tc.answer) || !got.Done() || len(pub.ids) != map[bool]int{true: 1}[tc.written] { t.Errorf("%s: %+v, ids %v", tc.name, got, pub.ids) } - if tc.written && pub.ids[0] != groupkeys.EndID("feat/take-in", c2, groupkeys.AgainStamp(ask.At)) { + if tc.written && pub.ids[0] != groupkeys.EndID("feat/take-in", "beta", c2, groupkeys.AgainStamp(ask.At)) { t.Errorf("%s: the id %s does not carry the ask's time", tc.name, pub.ids[0]) } } diff --git a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/integration_test.go b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/integration_test.go index 6f658a15..8c3147a7 100644 --- a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/integration_test.go +++ b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/integration_test.go @@ -754,7 +754,7 @@ func TestBridgeSaysAGroupFromTheSeatsGroups(t *testing.T) { // 1585, ADR 0633 § headB := strings.Repeat("b", 40) mergeB := strings.Repeat("e", 40) groups, _ := json.Marshal(seat.GroupsAnswer{Groups: []seat.Group{{ID: "feat/x", State: "merging", Members: []seat.Member{ - {Repository: "novox/mesh-delivery", Number: 12, Head: head, State: "merged"}, {Repository: "novox/mesh-sdk", Number: 4, Head: headB, State: "merging"}}}}}) + {Repository: "novox/mesh-delivery", Number: 12, Head: head, State: "merged", Base: "main"}, {Repository: "novox/mesh-sdk", Number: 4, Head: headB, State: "merging", Base: "main"}}}}}) b.answer("mesh-delivery.groups", string(groups)) ch := heard(t, b) landedOn(b, "novox/mesh-delivery", 12, "feat/x", head, merge) @@ -768,7 +768,7 @@ func TestBridgeSaysAGroupFromTheSeatsGroups(t *testing.T) { // 1585, ADR 0633 § } } end, err := forgemsg.DecodeForgeGroupEnd(next(t, ch, forgeSubject(forgemsg.ForgeGroupEndEvent))) - if err != nil || end.Group != "feat/x" || len(end.Members) != 2 || end.Members[0].Merge != merge || end.Members[1].Merge != mergeB { + if err != nil || end.Group != "feat/x" || end.Base != "main" || len(end.Members) != 2 || end.Members[0].Merge != merge || end.Members[1].Merge != mergeB { t.Errorf("the group's end: %+v %v", end, err) } refusedRead(t, b, "mesh-delivery-gate_merges") diff --git a/modules/mesh-delivery-forge-bridge/forgemsg/group.go b/modules/mesh-delivery-forge-bridge/forgemsg/group.go index a36702c5..527c3b7f 100644 --- a/modules/mesh-delivery-forge-bridge/forgemsg/group.go +++ b/modules/mesh-delivery-forge-bridge/forgemsg/group.go @@ -19,11 +19,13 @@ type GroupMerge struct { Merge string `json:"merge"` } -// ForgeGroupEnd is the body of ForgeGroupEndEvent: the group and every member merged in it, with its merge commit. +// ForgeGroupEnd is the body of ForgeGroupEndEvent: the group and every member merged in it on its base, with its merge commit. // The indexer takes the members it holds in as one source revision once it hears it (ADR 0633 §4). type ForgeGroupEnd struct { - V int `json:"v"` - Group string `json:"group"` + V int `json:"v"` + Group string `json:"group"` + // Base is the watched branch the members merged into: a group's end is said once per base (design 47 G §715). + Base string `json:"base,omitempty"` Members []GroupMerge `json:"members"` } diff --git a/modules/mesh-delivery-forge-bridge/internal/groups/groups.go b/modules/mesh-delivery-forge-bridge/internal/groups/groups.go index ca759043..ca15a0ec 100644 --- a/modules/mesh-delivery-forge-bridge/internal/groups/groups.go +++ b/modules/mesh-delivery-forge-bridge/internal/groups/groups.go @@ -34,22 +34,51 @@ const ( EndAsksBucket = Module + "_" + EndAsksState ) -// Key is the key of performed that says a group's end is written: `groups.`, its value the last -// member's merge commit the end named. -func Key(group string) (string, error) { - part, err := KeyOf(group) +// Key is the key of performed that says a group's end on a base is written: `groups..`, each part escaped as +// a key name, its value the last member's merge commit the end named (design 47 G §715: one end per base). +func Key(group, base string) (string, error) { + part, err := KeyOf(group, base) 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) { +// KeyOf is a group's id (its branch) and a base, each escaped as a key name, as the states performed and end-asks key +// them: `.`. +func KeyOf(group, base string) (string, error) { if strings.TrimSpace(group) == "" { return "", errors.New("a group's id is empty") } - return keys.EscapeName(group), nil + if strings.TrimSpace(base) == "" { + return "", errors.New("a group's base is empty") + } + return keys.EscapeName(group) + "." + keys.EscapeName(base), nil +} + +// Bases are the distinct bases of a group's members, in the order the members name them; a member naming none is +// named in missing by its repository and number. +func Bases(g seat.Group) (bases, missing []string) { + for _, m := range g.Members { + switch { + case m.Base == "": + missing = append(missing, fmt.Sprintf("%s#%d", m.Repository, m.Number)) + case !slices.Contains(bases, m.Base): + bases = append(bases, m.Base) + } + } + return bases, missing +} + +// OnBase is the members of a group whose base is base, in the group's order. +func OnBase(g seat.Group, base string) []seat.Member { + var out []seat.Member + for _, m := range g.Members { + if m.Base == base { + out = append(out, m) + } + } + return out } // PublishedKey is the key of performed that says a member's merged message is written: `published.<16 hex digits of the @@ -59,10 +88,10 @@ func PublishedKey(repository string, number int) string { 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 +// EndID is the message id of a group's end on a base, 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, base, lastMerge, again string) string { + id := "group-end." + group + "." + base + "." + lastMerge if again != "" { id += "." + again } @@ -77,6 +106,7 @@ func AgainStamp(at time.Time) string { return at.UTC().Format("20060102T150405.0 type EndAsk struct { V int `json:"v"` Group string `json:"group"` + Base string `json:"base"` Why string `json:"why,omitempty"` By string `json:"by,omitempty"` At time.Time `json:"at"` @@ -89,16 +119,19 @@ type EndAsk struct { 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) +func NewEndAsk(group, base, why, by string, at time.Time) (string, []byte, error) { + group, base = strings.TrimSpace(group), strings.TrimSpace(base) if group == "" { return "", nil, errors.New("name the group whose end is said again (group)") } - key, err := KeyOf(group) + if base == "" { + return "", nil, errors.New("name the watched branch its end is said again on (base)") + } + key, err := KeyOf(group, base) if err != nil { return "", nil, err } - data, err := json.Marshal(EndAsk{V: 1, Group: group, Why: strings.TrimSpace(why), By: by, At: at.UTC()}) + data, err := json.Marshal(EndAsk{V: 1, Group: group, Base: base, Why: strings.TrimSpace(why), By: by, At: at.UTC()}) return key, data, err } @@ -108,16 +141,17 @@ func DecodeEndAsk(data []byte) (EndAsk, error) { 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") + if a.V != 1 || a.Group == "" || a.Base == "" { + return a, errors.New("an end-asks value of no version 1 naming no group and base") } 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. +// Row is one group on one base 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"` + Base string `json:"base"` State string `json:"state,omitempty"` Members []RowMember `json:"members"` Ended bool `json:"ended"` @@ -159,30 +193,36 @@ func Rows(of []seat.Group, published, ended map[string]string, asks map[string][ if len(g.Members) < 2 { continue } - key, err := KeyOf(g.ID) - if err != nil { - unread = append(unread, "a group with no id") - continue + bases, missing := Bases(g) + for _, m := range missing { + unread = append(unread, "the member "+m+" of the group "+g.ID+" names no base") } - 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 + for _, base := range bases { + key, err := KeyOf(g.ID, base) + if err != nil { + unread = append(unread, "a group with no id") + continue + } + r := Row{Key: key, Group: g.ID, Base: base, State: g.State} + allPublished := true + for _, m := range OnBase(g, base) { + 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) } - 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)