Keep a group member's siblings and refuse its merge until the group passed and every sibling is reviewed (issue 867, ADR 0382)
mesh/merge-gate head de9d29dc657d merged into trunk 62ec68355cd1 — pass: builds gitea, mesh-delivery → novox; no bus step; every machine composes with the …
mesh/repo-check head de9d29dc657d merged into trunk 62ec68355cd1 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
mesh/delivery-group group fix/867-group-siblings delivered: every member is delivered

mesh-delivery's groups names each member's pull request, head, base and whether it is open (member_details), so the controller can read a member's siblings beside its check. The forge's holder keeps the siblings each verdict read, puts that verdict out of date when a sibling gets a new head, closes, or its base moves by anything but its own merge, and the merge tool merges such a member only when mesh/delivery-group passed at the heads read and each sibling head is approved. The statuses name the siblings, or say they could not be learned.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-10-15 23:27:13 +00:00
co-authored by Claude
parent 62ec68355c
commit de9d29dc65
10 changed files with 812 additions and 10 deletions
@@ -1,6 +1,7 @@
package main
import (
"encoding/json"
"strings"
"testing"
"time"
@@ -573,3 +574,49 @@ func TestStalledAndCloseWorkFromTheTable(t *testing.T) {
t.Fatalf("H2 closed a held delivery: %v", err)
}
}
// novox/hq issue 867, ADR 0382 §1 (contract A): each member of a group in `groups` also says its pull
// request number, full head, base branch and whether it is open; `members` keeps its text form.
func TestGroupsNamesEachMembersPullRequestHeadBaseAndOpen(t *testing.T) {
w := newWorld(t)
headA := "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
headB := "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2"
pb := pr("novox/b", 2, headB, "feat/z")
pb.Base = "release/1"
w.h.PullUpdated(pr("novox/a", 1, headA, "feat/z"))
w.h.PullUpdated(pb)
lines := w.h.Groups(false)
if len(lines) != 1 || len(lines[0].Details) != 2 || len(lines[0].Members) != 2 {
t.Fatalf("groups: %+v", lines)
}
got := map[string]MemberLine{}
for _, m := range lines[0].Details {
got[m.Repository] = m
}
a, b := got["novox/a"], got["novox/b"]
if a.Number != 1 || a.Head != headA || a.Base != "main" || !a.Open || a.ID == "" {
t.Fatalf("member a: %+v", a)
}
if b.Number != 2 || b.Head != headB || b.Base != "release/1" || !b.Open {
t.Fatalf("member b: %+v", b)
}
if !strings.Contains(lines[0].Members[0], "(") {
t.Fatalf("members lost its text form: %v", lines[0].Members)
}
// Closed unmerged: still listed while the group is, but not open.
w.h.PullClosed(pr("novox/b", 2, headB, "feat/z"))
for _, l := range w.h.Groups(true) {
for _, m := range l.Details {
if m.Repository == "novox/b" && m.Open {
t.Fatalf("a closed pull request is open: %+v", m)
}
}
}
// JSON keys are the contract's.
raw, _ := json.Marshal(a)
for _, k := range []string{`"number":1`, `"head":"` + headA + `"`, `"base":"main"`, `"open":true`} {
if !strings.Contains(string(raw), k) {
t.Fatalf("%s lacks %s", raw, k)
}
}
}
@@ -154,6 +154,21 @@ type GroupLine struct {
Contradictions []Contradiction `json:"contradictions,omitempty"`
Check string `json:"composed,omitempty"`
Members []string `json:"members"`
// Details are the members again, one object each, with what a reader needs to find the pull request's
// siblings: its number, full head, base branch and whether it is open (novox/hq ADR 0382 §1). Additive:
// `members` keeps its text form for older readers.
Details []MemberLine `json:"member_details,omitempty"`
}
// MemberLine is one member of a group, as `groups` lists it beside its text line.
type MemberLine struct {
ID string `json:"id"`
Repository string `json:"repository"`
State State `json:"state"`
Number int `json:"number"`
Head string `json:"head"`
Base string `json:"base"`
Open bool `json:"open"`
}
// Groups is the `groups` verb.
@@ -174,6 +189,9 @@ func (h *Holder) Groups(all bool) []GroupLine {
Contradictions: g.Contradictions}
for _, d := range members {
l.Members = append(l.Members, fmt.Sprintf("%s (%s)", d.ID, d.State))
l.Details = append(l.Details, MemberLine{ID: d.ID, Repository: d.Repository, State: d.State,
Number: d.Number, Head: d.Commit, Base: d.Base,
Open: !d.State.Final() && d.MergedAs == "" && !d.ClosedUnmerged})
}
if g.Check != nil {
l.Check = g.Check.Verdict