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)