From dea2a022bd068b0fe57dbc5557fe9382dc98b360 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 22:50:19 +0100 Subject: [PATCH] =?UTF-8?q?delivery:=20a=20merged=20message=20names=20its?= =?UTF-8?q?=20group,=20and=20a=20group's=20end=20is=20its=20own=20event=20?= =?UTF-8?q?(ADR=200633=20=C2=A74,=20issue=201585)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The forge bridge sets the group and its approved members on each member's merged message; once every member is merged it publishes forge.group.merged, so the indexer takes a group's merges in as one source revision. Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- go/delivery/forge_group.go | 78 ++++++++++++++++++++++++++++++++++++++ go/delivery/source.go | 46 +++++++++++++++++++++- go/delivery/source_test.go | 42 ++++++++++++++++++++ 3 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 go/delivery/forge_group.go diff --git a/go/delivery/forge_group.go b/go/delivery/forge_group.go new file mode 100644 index 0000000..1c0cf53 --- /dev/null +++ b/go/delivery/forge_group.go @@ -0,0 +1,78 @@ +package delivery + +import ( + "encoding/json" + "fmt" + "reflect" +) + +// ForgeGroupEndEvent is the name of a delivery group's end on the forge bridge's stream (novox/hq ADR 0633 §4): the +// bridge publishes it once every approved member of the group is merged, after the last member's merged message. It +// has three parts, so no repository's forge event (`forge...`) is ever this name. +const ForgeGroupEndEvent = "forge.group.merged" + +// GroupMerge is one merged member of a delivery group: the repository as `/`, its pull request and the +// merge commit the forge made of it. +type GroupMerge struct { + Repository string `json:"repository"` + Number int `json:"number"` + Merge string `json:"merge"` +} + +// ForgeGroupEnd is the body of ForgeGroupEndEvent: the group and every member merged in it, 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"` + Members []GroupMerge `json:"members"` +} + +func (e ForgeGroupEnd) check() error { + if e.Group == "" { + return fmt.Errorf("delivery: a group's end names its group") + } + if len(e.Members) == 0 { + return fmt.Errorf("delivery: the end of the group %q names no member merged in it", e.Group) + } + seen := map[string]bool{} + for _, m := range e.Members { + if err := checkRepository(m.Repository); err != nil { + return err + } + if m.Number <= 0 || m.Merge == "" { + return fmt.Errorf("delivery: the member %s of the group %q's end needs its pull request number and merge commit", m.Repository, e.Group) + } + k := fmt.Sprintf("%s#%d", m.Repository, m.Number) + if seen[k] { + return fmt.Errorf("delivery: the end of the group %q names its member %s twice", e.Group, k) + } + seen[k] = true + } + return nil +} + +// EncodeForgeGroupEnd is e as JSON at Version; it refuses an end with no group or no member, and a member without its +// pull request or merge commit. +func EncodeForgeGroupEnd(e ForgeGroupEnd) ([]byte, error) { + if err := e.check(); err != nil { + return nil, err + } + e.V = Version + return json.Marshal(e) +} + +// DecodeForgeGroupEnd reads a group's end; it refuses an unknown version (ErrUnknownVersion) and what +// EncodeForgeGroupEnd refuses. +func DecodeForgeGroupEnd(data []byte) (ForgeGroupEnd, error) { + var e ForgeGroupEnd + if err := decode(data, &e); err != nil { + return ForgeGroupEnd{}, err + } + if err := e.check(); err != nil { + return ForgeGroupEnd{}, err + } + return e, nil +} + +// SameForgeGroupEnd says whether two group ends are the same value. +func SameForgeGroupEnd(a, b ForgeGroupEnd) bool { return reflect.DeepEqual(a, b) } diff --git a/go/delivery/source.go b/go/delivery/source.go index 3956884..780cb67 100644 --- a/go/delivery/source.go +++ b/go/delivery/source.go @@ -612,6 +612,47 @@ type Forge struct { // held. Predefined json.RawMessage `json:"predefined,omitempty"` PredefinedRead bool `json:"predefined-read,omitempty"` + // Group is the delivery group a merged message's pull request was merged in, and Members every approved member of + // that group with its approved head, this one included (novox/hq ADR 0633 §4): the forge bridge sets both from the + // gate's merges state, and the indexer gathers the group's merges until the group's end (ForgeGroupEnd). A merge + // with no group carries neither, and an older bridge sends neither; an older reader ignores both. + Group string `json:"group,omitempty"` + Members []Head `json:"members,omitempty"` +} + +// checkGroup refuses a group on anything but a merged message, a group without its members or members without a +// group, a member that is not a repository's pull request at a head, a member named twice, and members that leave out +// the message's own pull request. +func (f Forge) checkGroup() error { + if f.Group == "" && len(f.Members) == 0 { + return nil + } + if f.Kind != ForgeMerged { + return fmt.Errorf("delivery: only a merged message names its delivery group, not a %s message", f.Kind) + } + if f.Group == "" || len(f.Members) == 0 { + return fmt.Errorf("delivery: a merged message names its group and the group's members together, or neither") + } + self := false + seen := map[string]bool{} + for _, m := range f.Members { + if err := checkRepository(m.Repository); err != nil { + return err + } + if m.Number <= 0 || m.Head == "" { + return fmt.Errorf("delivery: the member %s of the group %q needs its pull request number and head", m.Repository, f.Group) + } + k := fmt.Sprintf("%s#%d", m.Repository, m.Number) + if seen[k] { + return fmt.Errorf("delivery: the group %q names its member %s twice", f.Group, k) + } + seen[k] = true + self = self || (m.Repository == f.Repository && m.Number == f.Number) + } + if !self { + return fmt.Errorf("delivery: the members of the group %q leave out the merged pull request %s#%d", f.Group, f.Repository, f.Number) + } + return nil } // checkPredefined refuses the predefined list on anything but a merged message, a list that is not one JSON object, and @@ -650,6 +691,9 @@ func (f Forge) check() error { if err := f.checkPredefined(); err != nil { return err } + if err := f.checkGroup(); err != nil { + return err + } if err := checkRepository(f.Repository); err != nil { return err } @@ -759,7 +803,7 @@ func (f Forge) checkInputs(known func(dir string) bool, of string) error { // an open pull request that is not `/` and a number. func (f Forge) checkOpen() error { if f.Repository != "" || f.Branch != "" || f.Number != 0 || f.Head != "" || f.Action != "" || len(f.Dirs) > 0 || f.DirsKnown || - f.Merge != "" || len(f.Trees) > 0 || len(f.Manifests) > 0 || len(f.Locks) > 0 || len(f.Paths) > 0 || f.Root != "" || f.Act != "" || f.Words != "" { + f.Merge != "" || len(f.Trees) > 0 || len(f.Manifests) > 0 || len(f.Locks) > 0 || len(f.Paths) > 0 || f.Root != "" || f.Act != "" || f.Words != "" || f.Group != "" || len(f.Members) > 0 { return fmt.Errorf("delivery: an open message carries only the open pull requests it names") } for _, p := range f.Open { diff --git a/go/delivery/source_test.go b/go/delivery/source_test.go index 838d395..caf9809 100644 --- a/go/delivery/source_test.go +++ b/go/delivery/source_test.go @@ -150,6 +150,9 @@ func sampleForges() []Forge { Trees: map[string]string{"modules/a": "t1"}, Root: "t0"}, {Kind: ForgeRefused, Repository: "novox/mesh-sdk", Act: "novox/mesh-sdk.36.aaaa.view", Words: "403 forbidden"}, {Kind: ForgeOpen, Open: []OpenPull{{Repository: "novox/mesh-sdk", Number: 36}, {Repository: "novox/mesh-control", Number: 7}}}, + // 1585/T1: a group member's merged message names its group and every approved member (ADR 0633 §4). + {Kind: ForgeMerged, Repository: "novox/mesh-sdk", Number: 37, Head: "dddd", Merge: "eeee", Root: "t0", + Group: "feat/take-in", Members: []Head{{Repository: "novox/mesh-sdk", Number: 37, Head: "dddd"}, {Repository: "novox/mesh-delivery", Number: 9, Head: "ffff"}}}, } } @@ -199,6 +202,14 @@ func TestSourceValuesRoundTrip(t *testing.T) { // 1200/AC3 t.Errorf("forge %s round trip: %+v %v", f.Kind, got, err) } } + // 1585/T1: a group's end round-trips. + ge := ForgeGroupEnd{Group: "feat/take-in", Members: []GroupMerge{{Repository: "novox/mesh-sdk", Number: 37, Merge: "eeee"}}} + data, err = EncodeForgeGroupEnd(ge) + check("group end", data, err) + ge.V = Version + if got, err := DecodeForgeGroupEnd(data); err != nil || !SameForgeGroupEnd(got, ge) { + t.Errorf("group end round trip: %+v %v", got, err) + } // 1280: the taken and reach values (ADR 0556). tk := Taken{Repository: "novox/mesh-sdk", Merge: "cccc", At: time.Date(2026, 10, 26, 20, 0, 0, 0, time.UTC)} data, err = EncodeTaken(tk) @@ -220,6 +231,7 @@ func TestSourceValuesRoundTrip(t *testing.T) { // 1200/AC3 "candidate": func(b []byte) error { _, err := DecodeCandidate(b); return err }, "module": func(b []byte) error { _, err := DecodeModule(b); return err }, "forge": func(b []byte) error { _, err := DecodeForge(b); return err }, + "group end": func(b []byte) error { _, err := DecodeForgeGroupEnd(b); return err }, } { for _, in := range []string{`{"group":"g"}`, `{"v":2,"group":"g"}`} { if err := dec([]byte(in)); !errors.Is(err, ErrUnknownVersion) { @@ -303,6 +315,36 @@ func TestSourceValuesRefused(t *testing.T) { // 1200/AC4 f.Act = "" _, err = EncodeForge(f) wantErr("refused without act", err, "act") + // 1585/T1: a group only on a merged message, with its members, the message's own pull request among them; a group's + // end names its group and at least one member with its merge. + f = sampleForges()[0] + f.Group, f.Members = "g", []Head{{Repository: "novox/mesh-sdk", Number: 36, Head: "aaaa"}} + _, err = EncodeForge(f) + wantErr("pull with a group", err, "only a merged message names its delivery group") + f = sampleForges()[4] + f.Members = nil + _, err = EncodeForge(f) + wantErr("merged with a group and no members", err, "together") + f = sampleForges()[4] + f.Group = "" + _, err = EncodeForge(f) + wantErr("merged with members and no group", err, "together") + f = sampleForges()[4] + f.Members = f.Members[1:] + _, err = EncodeForge(f) + wantErr("merged with members leaving itself out", err, "leave out") + f = sampleForges()[4] + f.Members = append(f.Members, f.Members[0]) + _, err = EncodeForge(f) + wantErr("merged with a member twice", err, "twice") + _, err = EncodeForge(Forge{Kind: ForgeOpen, Group: "g"}) + wantErr("open with a group", err, "only the open pull requests") + _, err = EncodeForgeGroupEnd(ForgeGroupEnd{Group: "g"}) + wantErr("group end without members", err, "no member") + _, err = EncodeForgeGroupEnd(ForgeGroupEnd{Members: []GroupMerge{{Repository: "a/b", Number: 1, Merge: "m"}}}) + wantErr("group end without a group", err, "names its group") + _, err = EncodeForgeGroupEnd(ForgeGroupEnd{Group: "g", Members: []GroupMerge{{Repository: "a/b", Number: 1}}}) + wantErr("group end with a member without its merge", err, "merge commit") _, err = EncodeForge(Forge{Kind: "closed", Repository: "a/b"}) wantErr("unknown kind", err, "closed") _, err = EncodeForge(Forge{Kind: ForgePull, Repository: "nope", Number: 1, Head: "h", Action: "opened"})