delivery: a merged message names its group, and a group's end is its own event (ADR 0633 §4, issue 1585)
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
This commit is contained in:
@@ -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.<owner>.<name>.<kind>`) is ever this name.
|
||||
const ForgeGroupEndEvent = "forge.group.merged"
|
||||
|
||||
// GroupMerge is one merged member of a delivery group: the repository as `<owner>/<name>`, 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) }
|
||||
+45
-1
@@ -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 `<owner>/<name>` 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 {
|
||||
|
||||
@@ -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"})
|
||||
|
||||
Reference in New Issue
Block a user