From d0f1afbede33f7495ed71e6f981b1f64a48cb2cc Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 15:53:23 +0100 Subject: [PATCH] =?UTF-8?q?mesh-delivery-contracts:=20the=20gate's=20value?= =?UTF-8?q?s=20in=20the=20seat's=20types=20with=20their=20rules=20(source,?= =?UTF-8?q?=20merge,=20ask,=20owed,=20turn,=20answer:=20encode,=20decode,?= =?UTF-8?q?=20check,=20same),=20the=20turns=20and=20approved=20keys,=20the?= =?UTF-8?q?=20merge=20states,=20and=20the=20checker's=20verdict=20key,=20g?= =?UTF-8?q?oldens=20from=20go/delivery's=20output;=20the=20turns'=20times?= =?UTF-8?q?=20are=20time.Time=20(B5e=20ruling=203;=20ADR=200625=20=C2=A74,?= =?UTF-8?q?=20ADR=200634=20=C2=A72;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- .../meshdeliverycheckerclient/client.go | 32 ++ .../meshdeliverycheckerclient/client_test.go | 24 ++ .../go/clients/meshdeliveryclient/gate.go | 407 ++++++++++++++++++ .../clients/meshdeliveryclient/gate_test.go | 186 ++++++++ contracts/go/seats/meshdelivery/contract.go | 17 +- contracts/seats/mesh-delivery/seat.json | 3 + 6 files changed, 661 insertions(+), 8 deletions(-) create mode 100644 contracts/go/clients/meshdeliveryclient/gate.go create mode 100644 contracts/go/clients/meshdeliveryclient/gate_test.go diff --git a/contracts/go/clients/meshdeliverycheckerclient/client.go b/contracts/go/clients/meshdeliverycheckerclient/client.go index c7d75a2e..5263a5c8 100644 --- a/contracts/go/clients/meshdeliverycheckerclient/client.go +++ b/contracts/go/clients/meshdeliverycheckerclient/client.go @@ -278,3 +278,35 @@ func CheckPreview(p checker.PreviewsByRepositoryHeadTrunkValue) error { } return nil } + +// VerdictKey is the key, in the state verdicts, of a group's check at its heads: `.`, the +// group escaped so a branch such as `fix/1.2` keys, the heads hash as the mesh-delivery client's HeadsHash gives it +// (12 lowercase hex digits). +func VerdictKey(group, headsHash string) (string, error) { + if group == "" { + return "", fmt.Errorf("meshdeliverycheckerclient: the group name is empty") + } + if !isHex12(headsHash) { + return "", fmt.Errorf("meshdeliverycheckerclient: %q is not a heads hash, 12 lowercase hex digits", headsHash) + } + return keys.EscapeName(group) + "." + headsHash, nil +} + +// ParseVerdictKey is the inverse of VerdictKey. +func ParseVerdictKey(key string) (group, headsHash string, err error) { + i := strings.LastIndex(key, ".") + if i < 0 { + return "", "", fmt.Errorf("meshdeliverycheckerclient: %q is not a verdict key, .", key) + } + part, headsHash := key[:i], key[i+1:] + if !isHex12(headsHash) { + return "", "", fmt.Errorf("meshdeliverycheckerclient: %q is not a verdict key: %q is not a heads hash, 12 lowercase hex digits", key, headsHash) + } + if part == "" || strings.Contains(part, ".") { + return "", "", fmt.Errorf("meshdeliverycheckerclient: %q is not a verdict key: the group part is empty or holds a dot", key) + } + if group, err = keys.UnescapeName(part); err != nil { + return "", "", fmt.Errorf("meshdeliverycheckerclient: %q is not a verdict key: %w", key, err) + } + return group, headsHash, nil +} diff --git a/contracts/go/clients/meshdeliverycheckerclient/client_test.go b/contracts/go/clients/meshdeliverycheckerclient/client_test.go index 04191ae5..c9ef2850 100644 --- a/contracts/go/clients/meshdeliverycheckerclient/client_test.go +++ b/contracts/go/clients/meshdeliverycheckerclient/client_test.go @@ -125,3 +125,27 @@ func previewIsRefused(t *testing.T) { t.Errorf("a head in conflict is refused: %v", err) } } + +// A verdict's key as go/delivery's CheckerVerdictKey wrote it (golden): the group escaped, the heads hash after the +// last dot. +func TestVerdictKeyAsGoDeliveryWroteIt(t *testing.T) { + key, err := VerdictKey("fix/1.2", "0123456789ab") + if err != nil || key != "fix/1=2e2.0123456789ab" { + t.Fatalf("%q %v", key, err) + } + group, heads, err := ParseVerdictKey(key) + if err != nil || group != "fix/1.2" || heads != "0123456789ab" { + t.Fatalf("%q %q %v", group, heads, err) + } + for _, bad := range []string{"nodot", "g.XYZ", ".0123456789ab", "a.b.0123456789ab", "g=zz.0123456789ab"} { + if _, _, err := ParseVerdictKey(bad); err == nil { + t.Errorf("%q parses", bad) + } + } + if _, err := VerdictKey("", "0123456789ab"); err == nil { + t.Error("an empty group keys") + } + if _, err := VerdictKey("g", "0123"); err == nil { + t.Error("a short hash keys") + } +} diff --git a/contracts/go/clients/meshdeliveryclient/gate.go b/contracts/go/clients/meshdeliveryclient/gate.go new file mode 100644 index 00000000..de79bd28 --- /dev/null +++ b/contracts/go/clients/meshdeliveryclient/gate.go @@ -0,0 +1,407 @@ +package meshdeliveryclient + +import ( + "bytes" + "encoding/json" + "fmt" + "reflect" + "strconv" + "strings" + "time" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// The values of the seat's states `source`, `merges`, `asks`, `owed` and `turns`, in the seat's types: each is stamped +// with Version when encoded, read strictly (an unknown field refused) and its version checked when decoded, and its rules +// held on both sides, so the gate that writes them and the readers (the forge bridge, the teller, the assigner, the view) +// agree. What the schema cannot say is written here once (ADR 0625 ยง4). + +// Version is the layout version of the seat's values. +const Version = 1 + +// MergeState is the state of a pull request in a merge. +type MergeState string + +// The states a merge's pull request is in. +const ( + MergeMerging MergeState = "merging" + MergeMerged MergeState = "merged" + MergeRefused MergeState = "refused" +) + +// SourceMainKey is the one key of the state source. +const SourceMainKey = "main" + +func encodeValue(v any, check error, stamp func()) ([]byte, error) { + if check != nil { + return nil, check + } + stamp() + return json.Marshal(v) +} + +// ApprovedKey is the key, in a merge's approved heads, of a pull request: `@`. +func ApprovedKey(repository string, number int) string { + return repository + "@" + strconv.Itoa(number) +} + +// TurnsKey is the key, in the state turns, of a repository: `.`, lower-case and escaped. +func TurnsKey(repository string) (string, error) { + repository = strings.ToLower(repository) + part, err := escapeRepository(repository) + if err != nil { + return "", err + } + return strings.Replace(part, "/", ".", 1), nil +} + +// ParseTurnsKey is the inverse of TurnsKey; a key that does not name a lower-case / is refused. +func ParseTurnsKey(key string) (string, error) { + owner, name, ok := strings.Cut(key, ".") + if !ok || owner == "" || name == "" || strings.Contains(name, ".") { + return "", fmt.Errorf("meshdeliveryclient: %q is not a turns key, .", key) + } + repository, err := unescapeRepository(owner + "/" + name) + if err != nil { + return "", fmt.Errorf("meshdeliveryclient: %q is not a turns key: %w", key, err) + } + if again, err := TurnsKey(repository); err != nil || again != key { + return "", fmt.Errorf("meshdeliveryclient: %q is not a turns key: it does not name a lowercase /", key) + } + return repository, nil +} + +// ----- source + +// CheckSource refuses a source naming no repository, a repository with no name or an empty head. +func CheckSource(s seat.SourceByMainValue) error { + if len(s.Heads) == 0 { + return fmt.Errorf("meshdeliveryclient: a source names no repository") + } + for repository, head := range s.Heads { + if repository == "" { + return fmt.Errorf("meshdeliveryclient: a source names a repository with no name") + } + if head == "" { + return fmt.Errorf("meshdeliveryclient: the source's head of %s is empty", repository) + } + } + return nil +} + +// EncodeSource is s as the value of the key main. +func EncodeSource(s seat.SourceByMainValue) ([]byte, error) { + return encodeValue(&s, CheckSource(s), func() { s.V = Version }) +} + +// DecodeSource reads the value of the key main. +func DecodeSource(data []byte) (seat.SourceByMainValue, error) { + if err := keys.CheckVersion(data); err != nil { + return seat.SourceByMainValue{}, err + } + s, err := seat.DecodeSourceByMainValue(data) + if err != nil { + return s, fmt.Errorf("meshdeliveryclient: a source does not fit its shape: %w", err) + } + return s, CheckSource(s) +} + +// SameSource says whether two sources are the same. +func SameSource(a, b seat.SourceByMainValue) bool { return reflect.DeepEqual(a, b) } + +// ----- merge + +// CheckMerge refuses a merge naming no group, ask or pull request, a pull request outside the approved heads or at +// another head than the Approve was for, and a state that lacks what it needs: merged its commit, refused the forge's words. +func CheckMerge(m seat.MergesByGroupValue) error { + if m.Group == "" { + return fmt.Errorf("meshdeliveryclient: a merge names no group") + } + if m.Ask == "" { + return fmt.Errorf("meshdeliveryclient: a merge names no ask") + } + if len(m.Pulls) == 0 { + return fmt.Errorf("meshdeliveryclient: a merge names no pull request") + } + for key, sha := range m.Approved { + if sha == "" { + return fmt.Errorf("meshdeliveryclient: the approved head of %s is empty", key) + } + } + for _, p := range m.Pulls { + who := fmt.Sprintf("%s#%d", p.Repository, p.Number) + if len(m.Approved) > 0 { + sha, ok := m.Approved[ApprovedKey(p.Repository, int(p.Number))] + if !ok { + return fmt.Errorf("meshdeliveryclient: the pull request %s is handed to the merge but is not among the approved heads", who) + } + if sha != p.Head { + return fmt.Errorf("meshdeliveryclient: the pull request %s is handed at head %q but the Approve was for %q", who, p.Head, sha) + } + } + switch MergeState(p.State) { + case MergeMerging: + case MergeMerged: + if p.Commit == "" { + return fmt.Errorf("meshdeliveryclient: the merged pull request %s has no merge commit", who) + } + case MergeRefused: + if p.Words == "" { + return fmt.Errorf("meshdeliveryclient: the refused pull request %s has no words of the forge", who) + } + default: + return fmt.Errorf("meshdeliveryclient: the state %q of %s is not merging, merged or refused", p.State, who) + } + } + return nil +} + +// EncodeMerge is m as the value of a group's key. +func EncodeMerge(m seat.MergesByGroupValue) ([]byte, error) { + return encodeValue(&m, CheckMerge(m), func() { m.V = Version }) +} + +// DecodeMerge reads the value of a group's key. +func DecodeMerge(data []byte) (seat.MergesByGroupValue, error) { + if err := keys.CheckVersion(data); err != nil { + return seat.MergesByGroupValue{}, err + } + m, err := seat.DecodeMergesByGroupValue(data) + if err != nil { + return m, fmt.Errorf("meshdeliveryclient: a merge does not fit its shape: %w", err) + } + return m, CheckMerge(m) +} + +// SameMerge says whether two merges are the same. +func SameMerge(a, b seat.MergesByGroupValue) bool { return reflect.DeepEqual(a, b) } + +// ----- ask + +// HeadsOf are an ask's heads as Heads (for HeadsHash and MergeAskKey). +func HeadsOf(items []seat.AsksByMergeGroupHeadsValueHeadsItem) []Head { + out := make([]Head, len(items)) + for i, h := range items { + out[i] = Head{Repository: h.Repository, Number: int(h.Number), Head: h.Head} + } + return out +} + +// CheckAsk refuses an ask with no group or heads, a negative count, or an ask that is no object. +func CheckAsk(a seat.AsksByMergeGroupHeadsValue) error { + if a.Asking < 0 { + return fmt.Errorf("meshdeliveryclient: the ask's asking %d is negative", a.Asking) + } + if _, err := MergeAskKey(a.Group, HeadsOf(a.Heads)); err != nil { + return fmt.Errorf("meshdeliveryclient: an ask needs its group and at least one pull request's head: %w", err) + } + raw, err := json.Marshal(a.Ask) + var fields map[string]json.RawMessage + if err != nil || len(bytes.TrimSpace(raw)) == 0 || json.Unmarshal(raw, &fields) != nil || fields == nil { + return fmt.Errorf("meshdeliveryclient: the value carries no router's ask") + } + return nil +} + +// EncodeAsk is a as the value of a merge ask's key; a.Ask may be a json.RawMessage. +func EncodeAsk(a seat.AsksByMergeGroupHeadsValue) ([]byte, error) { + return encodeValue(&a, CheckAsk(a), func() { a.V = Version }) +} + +// DecodeAsk reads the value of a merge ask's key; its Ask is the router's ask as a decoded object. +func DecodeAsk(data []byte) (seat.AsksByMergeGroupHeadsValue, error) { + if err := keys.CheckVersion(data); err != nil { + return seat.AsksByMergeGroupHeadsValue{}, err + } + a, err := seat.DecodeAsksByMergeGroupHeadsValue(data) + if err != nil { + return a, fmt.Errorf("meshdeliveryclient: an ask does not fit its shape: %w", err) + } + return a, CheckAsk(a) +} + +// SameAsk says whether two asks are the same (the router's ask by its content). +func SameAsk(a, b seat.AsksByMergeGroupHeadsValue) bool { return reflect.DeepEqual(a, b) } + +// ----- owed + +// EncodeOwed is o as the value of an owed key, after CheckOwed. +func EncodeOwed(o seat.OwedByRepositoryNumberHeadActValue) ([]byte, error) { + return encodeValue(&o, CheckOwed(o), func() { o.V = Version }) +} + +// DecodeOwed reads the value of an owed key. +func DecodeOwed(data []byte) (seat.OwedByRepositoryNumberHeadActValue, error) { + if err := keys.CheckVersion(data); err != nil { + return seat.OwedByRepositoryNumberHeadActValue{}, err + } + o, err := seat.DecodeOwedByRepositoryNumberHeadActValue(data) + if err != nil { + return o, fmt.Errorf("meshdeliveryclient: an owed value does not fit its shape: %w", err) + } + return o, CheckOwed(o) +} + +// SameOwed says whether two owed values are the same. +func SameOwed(a, b seat.OwedByRepositoryNumberHeadActValue) bool { return a == b } + +// ----- turns + +// CheckTurn refuses a turn whose repository is not lower-case, that names no delivery, or whose deliveries repeat a heads +// hash, lack their times, are out of the order of the Approve, or name a wait without a verdict key. +func CheckTurn(t seat.TurnsByRepositoryValue) error { + repository := strings.ToLower(t.Repository) + if repository != t.Repository { + return fmt.Errorf("meshdeliveryclient: the turn's repository %q is not lowercase", t.Repository) + } + if _, err := escapeRepository(repository); err != nil { + return err + } + if len(t.Waiting) == 0 { + return fmt.Errorf("meshdeliveryclient: the turn of %s names no delivery: its key is deleted instead", t.Repository) + } + seen := map[string]bool{} + for i, w := range t.Waiting { + if w.Group == "" { + return fmt.Errorf("meshdeliveryclient: a delivery in the turn of %s names no group", t.Repository) + } + if !hashOK.MatchString(w.Heads) { + return fmt.Errorf("meshdeliveryclient: the heads hash %q in the turn of %s is not 12 lowercase hex digits", w.Heads, t.Repository) + } + if seen[w.Heads] { + return fmt.Errorf("meshdeliveryclient: the turn of %s names the heads %s twice", t.Repository, w.Heads) + } + seen[w.Heads] = true + if w.Approved.IsZero() || w.Since.IsZero() { + return fmt.Errorf("meshdeliveryclient: the delivery %s in the turn of %s says no time of its Approve or of its wait", w.Group, t.Repository) + } + if i > 0 && w.Approved.Before(t.Waiting[i-1].Approved) { + return fmt.Errorf("meshdeliveryclient: the turn of %s is not in the order of the Approve: %s was approved before %s", t.Repository, w.Group, t.Waiting[i-1].Group) + } + if w.WaitsFor != "" { + if _, _, err := parseVerdictKey(w.WaitsFor); err != nil { + return fmt.Errorf("meshdeliveryclient: the delivery %s in the turn of %s waits for what is not a verdict key: %w", w.Group, t.Repository, err) + } + } else if (w.WaitsSince != nil && !w.WaitsSince.IsZero()) || w.Step != "" { + return fmt.Errorf("meshdeliveryclient: the delivery %s in the turn of %s says when and where a check runs, and names no check", w.Group, t.Repository) + } + } + return nil +} + +// parseVerdictKey is the checker's verdict key `.` (the checker's client has the same rule over its +// own seat; a client uses its own seat's contract alone). +func parseVerdictKey(key string) (group, heads string, err error) { + i := strings.LastIndex(key, ".") + if i < 0 || !hashOK.MatchString(key[i+1:]) || i == 0 || strings.Contains(key[:i], ".") { + return "", "", fmt.Errorf("%q is not a verdict key, .", key) + } + if group, err = keys.UnescapeName(key[:i]); err != nil { + return "", "", err + } + return group, key[i+1:], nil +} + +// TurnHolds says whether a delivery at these heads is not in the turn while the turn names others: the delivery's +// check or merge holds back for the one before it. +func TurnHolds(t seat.TurnsByRepositoryValue, heads string) bool { + if len(t.Waiting) == 0 { + return false + } + for _, w := range t.Waiting { + if w.Heads == heads { + return false + } + } + return true +} + +// EncodeTurn is t as the value of a repository's key. +func EncodeTurn(t seat.TurnsByRepositoryValue) ([]byte, error) { + if err := CheckTurn(t); err != nil { + return nil, err + } + t.V = Version + return json.Marshal(t) +} + +// DecodeTurn reads the value of a repository's key. +func DecodeTurn(data []byte) (seat.TurnsByRepositoryValue, error) { + if err := keys.CheckVersion(data); err != nil { + return seat.TurnsByRepositoryValue{}, err + } + t, err := seat.DecodeTurnsByRepositoryValue(data) + if err != nil { + return t, fmt.Errorf("meshdeliveryclient: a turn does not fit its shape: %w", err) + } + return t, CheckTurn(t) +} + +// SameTurn says whether two turns are the same, a time by the instant it names. +func SameTurn(a, b seat.TurnsByRepositoryValue) bool { + if len(a.Waiting) != len(b.Waiting) || a.Repository != b.Repository || a.V != b.V { + return false + } + for i := range a.Waiting { + x, y := a.Waiting[i], b.Waiting[i] + if !x.Approved.Equal(y.Approved) || !x.Since.Equal(y.Since) || !sameTime(x.WaitsSince, y.WaitsSince) { + return false + } + x.Approved, y.Approved, x.Since, y.Since, x.WaitsSince, y.WaitsSince = time.Time{}, time.Time{}, time.Time{}, time.Time{}, nil, nil + if !reflect.DeepEqual(x, y) { + return false + } + } + return true +} + +func sameTime(a, b *time.Time) bool { + if a == nil || a.IsZero() { + return b == nil || b.IsZero() + } + return b != nil && a.Equal(*b) +} + +// Answer is the operator's answer to a merge ask as the gate acts on it: the option chosen, the warrant that proves who +// answered, and the words said. It is the teller's state `answers` under the ask's key (the teller's own record; this is +// its layout for the gate that reads it). +type Answer struct { + V int `json:"v"` + Option string `json:"option"` + Warrant string `json:"warrant"` + Words string `json:"words"` +} + +// CheckAnswer refuses an answer lacking its option, warrant or words. +func CheckAnswer(a Answer) error { + for _, f := range []struct{ name, value string }{{"option", a.Option}, {"warrant", a.Warrant}, {"words", a.Words}} { + if f.value == "" { + return fmt.Errorf("meshdeliveryclient: an answer has no %s", f.name) + } + } + return nil +} + +// EncodeAnswer is a as the value of an answer's key. +func EncodeAnswer(a Answer) ([]byte, error) { + return encodeValue(&a, CheckAnswer(a), func() { a.V = Version }) +} + +// DecodeAnswer reads the value of an answer's key. +func DecodeAnswer(data []byte) (Answer, error) { + if err := keys.CheckVersion(data); err != nil { + return Answer{}, err + } + var a Answer + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + if err := dec.Decode(&a); err != nil { + return Answer{}, fmt.Errorf("meshdeliveryclient: an answer does not fit its shape: %w", err) + } + return a, CheckAnswer(a) +} + +// SameAnswer says whether two answers are the same. +func SameAnswer(a, b Answer) bool { return a == b } diff --git a/contracts/go/clients/meshdeliveryclient/gate_test.go b/contracts/go/clients/meshdeliveryclient/gate_test.go new file mode 100644 index 00000000..4ec7b46e --- /dev/null +++ b/contracts/go/clients/meshdeliveryclient/gate_test.go @@ -0,0 +1,186 @@ +package meshdeliveryclient + +import ( + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + "time" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// sameJSON says whether two documents hold the same values (the generated types write their fields in alphabetical +// order, the old package's in declaration order: the bytes differ, the documents do not). +func sameJSON(t *testing.T, got []byte, want string) { + t.Helper() + var g, w any + if err := json.Unmarshal(got, &g); err != nil { + t.Fatalf("%s: %v", got, err) + } + if err := json.Unmarshal([]byte(want), &w); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(g, w) { + t.Errorf("got %s\nwant %s", got, want) + } +} + +// The goldens are the output of go/delivery's Encode* before it was deleted; each value must encode to the same +// document and decode back to what it was. +func TestGateValuesEncodeAsGoDeliveryDid(t *testing.T) { + ap := time.Date(2026, 11, 5, 10, 0, 0, 0, time.UTC) + wait := ap.Add(2 * time.Minute) + for _, c := range []struct { + name string + encode func() ([]byte, error) + decode func([]byte) (any, error) + value any + golden string + }{ + {"source", func() ([]byte, error) { + return EncodeSource(seat.SourceByMainValue{Group: "fix/1.2", Heads: map[string]string{"novox/a": "aaaa", "novox/b": "bbbb"}}) + }, func(b []byte) (any, error) { return DecodeSource(b) }, + seat.SourceByMainValue{V: 1, Group: "fix/1.2", Heads: map[string]string{"novox/a": "aaaa", "novox/b": "bbbb"}}, + `{"v":1,"group":"fix/1.2","heads":{"novox/a":"aaaa","novox/b":"bbbb"}}`}, + {"merge", func() ([]byte, error) { + return EncodeMerge(seat.MergesByGroupValue{Group: "g", Ask: "merge.g.0123456789ab", Approved: map[string]string{"novox/a@7": "h1"}, + Pulls: []seat.MergesByGroupValuePullsItem{{Repository: "novox/a", Number: 7, Head: "h1", State: "merged", Commit: "c1"}}}) + }, func(b []byte) (any, error) { return DecodeMerge(b) }, + seat.MergesByGroupValue{V: 1, Group: "g", Ask: "merge.g.0123456789ab", Approved: map[string]string{"novox/a@7": "h1"}, + Pulls: []seat.MergesByGroupValuePullsItem{{Repository: "novox/a", Number: 7, Head: "h1", State: "merged", Commit: "c1"}}}, + `{"v":1,"group":"g","ask":"merge.g.0123456789ab","approved":{"novox/a@7":"h1"},"pulls":[{"repository":"novox/a","number":7,"head":"h1","state":"merged","commit":"c1"}]}`}, + {"ask", func() ([]byte, error) { + return EncodeAsk(seat.AsksByMergeGroupHeadsValue{Group: "g", Asking: 2, Ask: json.RawMessage(`{"headline":"x"}`), + Heads: []seat.AsksByMergeGroupHeadsValueHeadsItem{{Repository: "novox/a", Number: 7, Head: "h1"}}}) + }, func(b []byte) (any, error) { return DecodeAsk(b) }, + seat.AsksByMergeGroupHeadsValue{V: 1, Group: "g", Asking: 2, Ask: map[string]any{"headline": "x"}, + Heads: []seat.AsksByMergeGroupHeadsValueHeadsItem{{Repository: "novox/a", Number: 7, Head: "h1"}}}, + `{"v":1,"group":"g","heads":[{"repository":"novox/a","number":7,"head":"h1"}],"ask":{"headline":"x"},"asking":2}`}, + {"owed", func() ([]byte, error) { + return EncodeOwed(seat.OwedByRepositoryNumberHeadActValue{Act: OwedStatus, Context: StatusContextCheck, Target: "t", State: "pending"}) + }, func(b []byte) (any, error) { return DecodeOwed(b) }, + seat.OwedByRepositoryNumberHeadActValue{V: 1, Act: OwedStatus, Context: StatusContextCheck, Target: "t", State: "pending"}, + `{"v":1,"act":"status","context":"mesh/check","target":"t","state":"pending"}`}, + {"turn", func() ([]byte, error) { + return EncodeTurn(seat.TurnsByRepositoryValue{Repository: "novox/a", Waiting: []seat.TurnsByRepositoryValueWaitingItem{{ + Group: "g", Heads: "0123456789ab", Ask: "k", Approved: ap, Since: ap.Add(time.Minute), WaitsFor: "g.0123456789ab", WaitsSince: &wait, Step: "s"}}}) + }, func(b []byte) (any, error) { return DecodeTurn(b) }, + seat.TurnsByRepositoryValue{V: 1, Repository: "novox/a", Waiting: []seat.TurnsByRepositoryValueWaitingItem{{ + Group: "g", Heads: "0123456789ab", Ask: "k", Approved: ap, Since: ap.Add(time.Minute), WaitsFor: "g.0123456789ab", WaitsSince: &wait, Step: "s"}}}, + `{"v":1,"repository":"novox/a","waiting":[{"group":"g","heads":"0123456789ab","ask":"k","approved":"2026-11-05T10:00:00Z","since":"2026-11-05T10:01:00Z","waits-for":"g.0123456789ab","waits-since":"2026-11-05T10:02:00Z","step":"s"}]}`}, + {"answer", func() ([]byte, error) { return EncodeAnswer(Answer{Option: "merge", Warrant: "w", Words: "ok"}) }, + func(b []byte) (any, error) { return DecodeAnswer(b) }, Answer{V: 1, Option: "merge", Warrant: "w", Words: "ok"}, + `{"v":1,"option":"merge","warrant":"w","words":"ok"}`}, + } { + raw, err := c.encode() + if err != nil { + t.Fatalf("%s: %v", c.name, err) + } + sameJSON(t, raw, c.golden) + back, err := c.decode([]byte(c.golden)) + if err != nil || !reflect.DeepEqual(back, c.value) { + t.Errorf("%s decoded %+v, %v; want %+v", c.name, back, err, c.value) + } + } +} + +func TestGateKeysAsGoDeliveryHadThem(t *testing.T) { + for in, want := range map[string]string{"novox/Mesh-sdk": "novox.mesh-sdk", "novox/mesh-sdk": "novox.mesh-sdk", "novox/mesh.x": "novox.mesh=2ex"} { + got, err := TurnsKey(in) + if err != nil || got != want { + t.Errorf("TurnsKey(%q) = %q, %v; want %q", in, got, err, want) + } + } + if r, err := ParseTurnsKey("novox.mesh=2ex"); err != nil || r != "novox/mesh.x" { + t.Errorf("%q %v", r, err) + } + for _, bad := range []string{"novox", "novox.Mesh", "a.b.c", ".x", "novox.mesh-sdk."} { + if _, err := ParseTurnsKey(bad); err == nil { + t.Errorf("%q parses as a turns key", bad) + } + } + if _, err := TurnsKey("novox"); err == nil { + t.Error("a repository with no slash keys") + } + if k := ApprovedKey("novox/a", 7); k != "novox/a@7" { + t.Error(k) + } + if SourceMainKey != seat.SourceByMainKey() { + t.Error("the source's key is the seat's") + } +} + +func TestGateValuesRefuseWhatTheirRulesRefuse(t *testing.T) { + ap := time.Date(2026, 11, 5, 10, 0, 0, 0, time.UTC) + pull := seat.MergesByGroupValuePullsItem{Repository: "novox/a", Number: 7, Head: "h1", State: "merging"} + merge := func(edit func(*seat.MergesByGroupValue)) error { + m := seat.MergesByGroupValue{Group: "g", Ask: "k", Pulls: []seat.MergesByGroupValuePullsItem{pull}} + edit(&m) + _, err := EncodeMerge(m) + return err + } + turn := func(edit func(*seat.TurnsByRepositoryValue)) error { + tv := seat.TurnsByRepositoryValue{Repository: "novox/a", Waiting: []seat.TurnsByRepositoryValueWaitingItem{{Group: "g", Heads: "0123456789ab", Approved: ap, Since: ap}}} + edit(&tv) + _, err := EncodeTurn(tv) + return err + } + for name, c := range map[string]struct { + err error + want string + }{ + "merge: no group": {merge(func(m *seat.MergesByGroupValue) { m.Group = "" }), "no group"}, + "merge: no pull": {merge(func(m *seat.MergesByGroupValue) { m.Pulls = nil }), "no pull request"}, + "merge: not approved": {merge(func(m *seat.MergesByGroupValue) { m.Approved = map[string]string{"novox/b@1": "x"} }), "not among the approved"}, + "merge: other head": {merge(func(m *seat.MergesByGroupValue) { m.Approved = map[string]string{"novox/a@7": "x"} }), "Approve was for"}, + "merge: merged no commit": {merge(func(m *seat.MergesByGroupValue) { m.Pulls[0].State = "merged" }), "no merge commit"}, + "merge: refused no words": {merge(func(m *seat.MergesByGroupValue) { m.Pulls[0].State = "refused" }), "no words"}, + "merge: unknown state": {merge(func(m *seat.MergesByGroupValue) { m.Pulls[0].State = "lost" }), "not merging, merged or refused"}, + "turn: upper case": {turn(func(v *seat.TurnsByRepositoryValue) { v.Repository = "Novox/a" }), "not lowercase"}, + "turn: empty": {turn(func(v *seat.TurnsByRepositoryValue) { v.Waiting = nil }), "names no delivery"}, + "turn: bad hash": {turn(func(v *seat.TurnsByRepositoryValue) { v.Waiting[0].Heads = "xyz" }), "12 lowercase hex"}, + "turn: no time": {turn(func(v *seat.TurnsByRepositoryValue) { v.Waiting[0].Since = time.Time{} }), "no time"}, + "turn: step with no wait": {turn(func(v *seat.TurnsByRepositoryValue) { v.Waiting[0].Step = "s" }), "names no check"}, + "turn: not a verdict key": {turn(func(v *seat.TurnsByRepositoryValue) { v.Waiting[0].WaitsFor = "nope" }), "not a verdict key"}, + } { + if c.err == nil || !strings.Contains(c.err.Error(), c.want) { + t.Errorf("%s: %v, want %q", name, c.err, c.want) + } + } + if _, err := EncodeSource(seat.SourceByMainValue{Group: "g"}); err == nil { + t.Error("an empty source encodes") + } + if _, err := EncodeAsk(seat.AsksByMergeGroupHeadsValue{Group: "g", Ask: "text", Heads: []seat.AsksByMergeGroupHeadsValueHeadsItem{{Repository: "novox/a", Number: 1, Head: "h"}}}); err == nil { + t.Error("an ask that is no object encodes") + } + if _, err := EncodeAnswer(Answer{Option: "merge"}); err == nil { + t.Error("an answer with no warrant encodes") + } + if _, err := DecodeSource([]byte(`{"v":2,"group":"g","heads":{"a":"b"}}`)); !errors.Is(err, keys.ErrUnknownVersion) { + t.Errorf("another version: %v", err) + } + if _, err := DecodeMerge([]byte(`{"v":1,"group":"g","ask":"k","pulls":[],"extra":1}`)); err == nil { + t.Error("an unknown field decodes") + } +} + +func TestTurnHoldsAndSameTurn(t *testing.T) { + ap := time.Date(2026, 11, 5, 10, 0, 0, 0, time.UTC) + tv := seat.TurnsByRepositoryValue{V: 1, Repository: "novox/a", Waiting: []seat.TurnsByRepositoryValueWaitingItem{{Group: "g", Heads: "0123456789ab", Approved: ap, Since: ap}}} + if TurnHolds(tv, "0123456789ab") || !TurnHolds(tv, "ba9876543210") || TurnHolds(seat.TurnsByRepositoryValue{}, "x") { + t.Error("a turn holds a delivery that is not in it while others wait") + } + other := tv + other.Waiting = []seat.TurnsByRepositoryValueWaitingItem{tv.Waiting[0]} + other.Waiting[0].Approved = ap.In(time.FixedZone("x", 3600)) + if !SameTurn(tv, other) { + t.Error("the same instant in another zone is another turn") + } + other.Waiting[0].Since = ap.Add(time.Second) + if SameTurn(tv, other) { + t.Error("another instant is the same turn") + } +} diff --git a/contracts/go/seats/meshdelivery/contract.go b/contracts/go/seats/meshdelivery/contract.go index c70a8734..1adc3e86 100644 --- a/contracts/go/seats/meshdelivery/contract.go +++ b/contracts/go/seats/meshdelivery/contract.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "strings" + "time" ) // Seat is the seat's name. @@ -433,16 +434,16 @@ type OwedByRepositoryNumberHeadActValue struct { // TurnsByRepositoryValueWaitingItem is a type of the contract. type TurnsByRepositoryValueWaitingItem struct { // when (RFC 3339 time) - Approved string `json:"approved"` - Ask string `json:"ask,omitempty"` - Group string `json:"group"` - Heads string `json:"heads"` + Approved time.Time `json:"approved"` + Ask string `json:"ask,omitempty"` + Group string `json:"group"` + Heads string `json:"heads"` // when (RFC 3339 time) - Since string `json:"since"` - Step string `json:"step,omitempty"` - WaitsFor string `json:"waits-for,omitempty"` + Since time.Time `json:"since"` + Step string `json:"step,omitempty"` + WaitsFor string `json:"waits-for,omitempty"` // when (RFC 3339 time) - WaitsSince string `json:"waits-since,omitempty"` + WaitsSince *time.Time `json:"waits-since,omitempty"` } // TurnsByRepositoryValue is one value of the state turns under the key . diff --git a/contracts/seats/mesh-delivery/seat.json b/contracts/seats/mesh-delivery/seat.json index 8365118a..dd617cc4 100644 --- a/contracts/seats/mesh-delivery/seat.json +++ b/contracts/seats/mesh-delivery/seat.json @@ -529,10 +529,12 @@ }, "approved": { "type": "string", + "format": "date-time", "description": "when (RFC 3339 time)" }, "since": { "type": "string", + "format": "date-time", "description": "when (RFC 3339 time)" }, "waits-for": { @@ -540,6 +542,7 @@ }, "waits-since": { "type": "string", + "format": "date-time", "description": "when (RFC 3339 time)" }, "step": {