Give the gate's merges, asks and owed acts and the teller's answers one layout the parts read (issue 1201)
This commit is contained in:
@@ -23,7 +23,11 @@
|
||||
// and `modules.<module>` (a module's source fingerprint, or none and why), and the forge bridge's messages named
|
||||
// `forge.<owner>.<name>.<kind>` (source.go).
|
||||
//
|
||||
// Standard library only.
|
||||
// **Merges, asks, owed and answers** (issue 1201; ADR 0437 §6, §8): the gate's `merges.<group>`, its asks
|
||||
// `merge.<group>.<heads hash>` (the router's ask whole), its owed keys `<repository>.<number>.<head>.<act>` that the
|
||||
// forge bridge performs, and the teller's answer under the ask's own key (gate.go).
|
||||
//
|
||||
// Standard library only, and this module's own asks package.
|
||||
package delivery
|
||||
|
||||
import (
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// The gate's buckets and the teller's (ADR 0437 §6, §8). The gate writes merges (each pull request's expected
|
||||
// head, its state and each merge commit), its asks (`merge.<group>.<heads hash>`, the router's ask whole) and owed
|
||||
// (one key per act it owes the forge, which the forge bridge performs once per revision). The teller writes its
|
||||
// answer under the ask's own key in its bucket answers.
|
||||
const (
|
||||
MergesBucket = GateModule + "_merges"
|
||||
AsksBucket = GateModule + "_asks"
|
||||
OwedBucket = GateModule + "_owed"
|
||||
AnswersBucket = TellerModule + "_answers"
|
||||
MergesHistory = 10
|
||||
AsksHistory = 5
|
||||
OwedHistory = 5
|
||||
AnswersHistory = 5
|
||||
)
|
||||
|
||||
// AskKindMerge is the kind of the gate's ask for a group's merge, the first part of its key.
|
||||
const AskKindMerge = "merge"
|
||||
|
||||
// The acts the gate owes the forge, the last part of an owed key.
|
||||
const (
|
||||
OwedStatus = "status"
|
||||
OwedView = "view"
|
||||
OwedNote = "note"
|
||||
OwedComment = "comment"
|
||||
)
|
||||
|
||||
// The states 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"
|
||||
)
|
||||
|
||||
// The states a status the gate owes the forge may carry.
|
||||
var statusStates = []string{"pending", "success", "failure", "error", "warning"}
|
||||
|
||||
var hashOK = regexp.MustCompile(`^[0-9a-f]{12}$`)
|
||||
|
||||
// MergesKey is the key, in MergesBucket, of a group: `<group>`.
|
||||
func MergesKey(group string) (string, error) {
|
||||
if err := checkName("group", group); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return group, nil
|
||||
}
|
||||
|
||||
// MergeAskKey is the key, in AsksBucket, of the ask for merging a group at these heads: `merge.<group>.<heads
|
||||
// hash>`. The teller's answer has the same key in AnswersBucket (AnswerKey).
|
||||
func MergeAskKey(group string, heads []Head) (string, error) {
|
||||
if err := checkName("group", group); err != nil {
|
||||
return "", err
|
||||
}
|
||||
hash, err := HeadsHash(heads)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return AskKindMerge + "." + group + "." + hash, nil
|
||||
}
|
||||
|
||||
// ParseAskKey is the inverse of MergeAskKey: the kind, the group and the heads hash.
|
||||
func ParseAskKey(key string) (kind, group, hash string, err error) {
|
||||
parts := strings.Split(key, ".")
|
||||
if len(parts) != 3 || parts[0] != AskKindMerge {
|
||||
return "", "", "", fmt.Errorf("delivery: %q is not an ask key, merge.<group>.<heads hash>", key)
|
||||
}
|
||||
if err := checkName("group", parts[1]); err != nil {
|
||||
return "", "", "", fmt.Errorf("delivery: %q is not an ask key: %w", key, err)
|
||||
}
|
||||
if !hashOK.MatchString(parts[2]) {
|
||||
return "", "", "", fmt.Errorf("delivery: %q is not an ask key: %q is not 12 hex digits", key, parts[2])
|
||||
}
|
||||
return parts[0], parts[1], parts[2], nil
|
||||
}
|
||||
|
||||
// AnswerKey is the key, in AnswersBucket, of the teller's answer to the ask at askKey: the same string.
|
||||
func AnswerKey(askKey string) string { return askKey }
|
||||
|
||||
// AskID is the id the teller gives the router for the ask at askKey. The router's ids refuse dots (asks.UsableID),
|
||||
// so a dot becomes `-` and a slash `_`; a key too long for an id is cut and ends in a digest of the whole key.
|
||||
// It cannot be read back: the teller keeps the key beside it.
|
||||
func AskID(askKey string) string {
|
||||
id := strings.NewReplacer(".", "-", "/", "_").Replace(askKey)
|
||||
const most = 64
|
||||
if len(id) > most {
|
||||
sum := sha256.Sum256([]byte(askKey))
|
||||
id = id[:most-13] + "-" + hex.EncodeToString(sum[:])[:12]
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func checkOwedAct(act string) (kind, context string, err error) {
|
||||
kind, context, hasContext := strings.Cut(act, "=")
|
||||
switch kind {
|
||||
case OwedStatus:
|
||||
if !hasContext {
|
||||
return "", "", fmt.Errorf("delivery: the owed act %q is a status without its context, status=<context>", act)
|
||||
}
|
||||
if err := checkName("status context", context); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return kind, context, nil
|
||||
case OwedView, OwedNote, OwedComment:
|
||||
if hasContext {
|
||||
return "", "", fmt.Errorf("delivery: the owed act %q takes no context", act)
|
||||
}
|
||||
return kind, "", nil
|
||||
}
|
||||
return "", "", fmt.Errorf("delivery: the owed act %q is not status=<context>, view, note or comment", act)
|
||||
}
|
||||
|
||||
// ParseOwedAct splits an owed act into its kind and, for a status, its context.
|
||||
func ParseOwedAct(act string) (kind, context string, err error) { return checkOwedAct(act) }
|
||||
|
||||
// OwedKey is the key, in OwedBucket, of an act the gate owes the forge on a pull request at a head:
|
||||
// `<repository>.<number>.<head>.<act>`, the act being `status=<context>` (mesh/merge-gate), `view`, `note` or
|
||||
// `comment`.
|
||||
func OwedKey(repository string, number int, head, act string) (string, error) {
|
||||
if err := checkRepository(repository); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if number <= 0 {
|
||||
return "", fmt.Errorf("delivery: an owed key needs the pull request number, not %d", number)
|
||||
}
|
||||
if err := checkName("head", head); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, _, err := checkOwedAct(act); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return repository + "." + strconv.Itoa(number) + "." + head + "." + act, nil
|
||||
}
|
||||
|
||||
// ParseOwedKey is the inverse of OwedKey; act is whole, as `status=<context>` for a status.
|
||||
func ParseOwedKey(key string) (repository string, number int, head, act string, err error) {
|
||||
parts := strings.Split(key, ".")
|
||||
if len(parts) != 4 {
|
||||
return "", 0, "", "", fmt.Errorf("delivery: %q is not an owed key, <repository>.<number>.<head>.<act>", key)
|
||||
}
|
||||
number, convErr := strconv.Atoi(parts[1])
|
||||
if convErr != nil {
|
||||
return "", 0, "", "", fmt.Errorf("delivery: %q is not an owed key: %q is not a pull request number", key, parts[1])
|
||||
}
|
||||
if _, err := OwedKey(parts[0], number, parts[2], parts[3]); err != nil {
|
||||
return "", 0, "", "", fmt.Errorf("delivery: %q is not an owed key: %w", key, err)
|
||||
}
|
||||
return parts[0], number, parts[2], parts[3], nil
|
||||
}
|
||||
|
||||
// Merge is the value of a merges key: the ask approved and, for each pull request, the head the approval expects
|
||||
// and how far its merge has come.
|
||||
type Merge struct {
|
||||
V int `json:"v"`
|
||||
Group string `json:"group"`
|
||||
Ask string `json:"ask"`
|
||||
Pulls []MergePull `json:"pulls"`
|
||||
}
|
||||
|
||||
// MergePull is one pull request of a merge: its expected head, its state, the merge commit once merged, the
|
||||
// forge's words when refused.
|
||||
type MergePull struct {
|
||||
Head
|
||||
State MergeState `json:"state"`
|
||||
Commit string `json:"commit,omitempty"`
|
||||
Words string `json:"words,omitempty"`
|
||||
}
|
||||
|
||||
func (m Merge) check() error {
|
||||
if err := checkName("group", m.Group); err != nil {
|
||||
return err
|
||||
}
|
||||
if m.Ask == "" {
|
||||
return fmt.Errorf("delivery: a merge names no ask")
|
||||
}
|
||||
if len(m.Pulls) == 0 {
|
||||
return fmt.Errorf("delivery: a merge names no pull request")
|
||||
}
|
||||
for _, p := range m.Pulls {
|
||||
who := fmt.Sprintf("%s#%d", p.Repository, p.Number)
|
||||
switch p.State {
|
||||
case MergeMerging:
|
||||
case MergeMerged:
|
||||
if p.Commit == "" {
|
||||
return fmt.Errorf("delivery: the merged pull request %s has no merge commit", who)
|
||||
}
|
||||
case MergeRefused:
|
||||
if p.Words == "" {
|
||||
return fmt.Errorf("delivery: the refused pull request %s has no words of the forge", who)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("delivery: the state %q of %s is not merging, merged or refused", p.State, who)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ask is the value of an asks key: the router's ask whole as the gate composes it, so the teller publishes it
|
||||
// unchanged, and the group and heads it was composed for.
|
||||
type Ask struct {
|
||||
V int `json:"v"`
|
||||
Group string `json:"group"`
|
||||
Heads []Head `json:"heads"`
|
||||
Ask asks.Ask `json:"ask"`
|
||||
}
|
||||
|
||||
func (a Ask) check() error {
|
||||
key, err := MergeAskKey(a.Group, a.Heads)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delivery: an ask needs its group and at least one pull request's head: %w", err)
|
||||
}
|
||||
if want := AskID(key); a.Ask.ID != want {
|
||||
return fmt.Errorf("delivery: the ask's id %q is not %q, the id its group and heads give", a.Ask.ID, want)
|
||||
}
|
||||
if len(a.Ask.Options) == 0 {
|
||||
return fmt.Errorf("delivery: the ask has no option")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Answer is the teller's answer to an ask: the option chosen, the warrant's id and its words.
|
||||
type Answer struct {
|
||||
V int `json:"v"`
|
||||
Option string `json:"option"`
|
||||
Warrant string `json:"warrant"`
|
||||
Words string `json:"words"`
|
||||
}
|
||||
|
||||
func (a Answer) check() error {
|
||||
for _, f := range []struct{ name, value string }{{"option", a.Option}, {"warrant", a.Warrant}, {"words", a.Words}} {
|
||||
if f.value == "" {
|
||||
return fmt.Errorf("delivery: an answer has no %s", f.name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Owed is the value of an owed key: what the forge bridge performs. A status needs Context and State, a view and a
|
||||
// comment need Text, a note needs Text and Ref (the notes ref, mesh-plan).
|
||||
type Owed struct {
|
||||
V int `json:"v"`
|
||||
Act string `json:"act"`
|
||||
Context string `json:"context,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Ref string `json:"ref,omitempty"`
|
||||
}
|
||||
|
||||
func (o Owed) check() error {
|
||||
switch o.Act {
|
||||
case OwedStatus:
|
||||
if o.Context == "" {
|
||||
return fmt.Errorf("delivery: an owed status has no context")
|
||||
}
|
||||
for _, s := range statusStates {
|
||||
if o.State == s {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("delivery: the owed status state %q is not %s", o.State, strings.Join(statusStates, ", "))
|
||||
case OwedView, OwedComment:
|
||||
if o.Text == "" {
|
||||
return fmt.Errorf("delivery: an owed %s has no text", o.Act)
|
||||
}
|
||||
case OwedNote:
|
||||
if o.Text == "" {
|
||||
return fmt.Errorf("delivery: an owed note has no text")
|
||||
}
|
||||
if o.Ref == "" {
|
||||
return fmt.Errorf("delivery: an owed note has no ref")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("delivery: the owed act %q is not status, view, note or comment", o.Act)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncodeMerge is m as JSON at Version; it refuses a merged pull request without its merge commit, a refused one
|
||||
// without the forge's words and a state outside the list.
|
||||
func EncodeMerge(m Merge) ([]byte, error) {
|
||||
if err := m.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.V = Version
|
||||
return json.Marshal(m)
|
||||
}
|
||||
|
||||
// DecodeMerge reads a merge; it refuses an unknown version (ErrUnknownVersion) and what EncodeMerge refuses.
|
||||
func DecodeMerge(data []byte) (Merge, error) {
|
||||
var m Merge
|
||||
if err := decode(data, &m); err != nil {
|
||||
return Merge{}, err
|
||||
}
|
||||
if err := m.check(); err != nil {
|
||||
return Merge{}, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// EncodeAsk is a as JSON at Version; it refuses an ask whose id is not the one its group and heads give, or with
|
||||
// no option.
|
||||
func EncodeAsk(a Ask) ([]byte, error) {
|
||||
if err := a.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.V = Version
|
||||
return json.Marshal(a)
|
||||
}
|
||||
|
||||
// DecodeAsk reads an ask; it refuses an unknown version (ErrUnknownVersion) and what EncodeAsk refuses.
|
||||
func DecodeAsk(data []byte) (Ask, error) {
|
||||
var a Ask
|
||||
if err := decode(data, &a); err != nil {
|
||||
return Ask{}, err
|
||||
}
|
||||
if err := a.check(); err != nil {
|
||||
return Ask{}, err
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// EncodeAnswer is a as JSON at Version; it refuses an answer missing its option, warrant or words.
|
||||
func EncodeAnswer(a Answer) ([]byte, error) {
|
||||
if err := a.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.V = Version
|
||||
return json.Marshal(a)
|
||||
}
|
||||
|
||||
// DecodeAnswer reads an answer; it refuses an unknown version (ErrUnknownVersion) and what EncodeAnswer refuses.
|
||||
func DecodeAnswer(data []byte) (Answer, error) {
|
||||
var a Answer
|
||||
if err := decode(data, &a); err != nil {
|
||||
return Answer{}, err
|
||||
}
|
||||
if err := a.check(); err != nil {
|
||||
return Answer{}, err
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// EncodeOwed is o as JSON at Version; it refuses an act missing what it needs.
|
||||
func EncodeOwed(o Owed) ([]byte, error) {
|
||||
if err := o.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
o.V = Version
|
||||
return json.Marshal(o)
|
||||
}
|
||||
|
||||
// DecodeOwed reads an owed act; it refuses an unknown version (ErrUnknownVersion) and what EncodeOwed refuses.
|
||||
func DecodeOwed(data []byte) (Owed, error) {
|
||||
var o Owed
|
||||
if err := decode(data, &o); err != nil {
|
||||
return Owed{}, err
|
||||
}
|
||||
if err := o.check(); err != nil {
|
||||
return Owed{}, err
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
|
||||
// SameMerge says whether two merges are the same value, so a writer writes only when they differ.
|
||||
func SameMerge(a, b Merge) bool { return reflect.DeepEqual(a, b) }
|
||||
|
||||
// SameAsk says whether two asks are the same value.
|
||||
func SameAsk(a, b Ask) bool { return reflect.DeepEqual(a, b) }
|
||||
|
||||
// SameAnswer says whether two answers are the same value.
|
||||
func SameAnswer(a, b Answer) bool { return a == b }
|
||||
|
||||
// SameOwed says whether two owed acts are the same value.
|
||||
func SameOwed(a, b Owed) bool { return a == b }
|
||||
@@ -0,0 +1,297 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
func gateHeads() []Head {
|
||||
return []Head{
|
||||
{Repository: "novox/mesh-sdk", Number: 36, Head: "aaaa"},
|
||||
{Repository: "novox/mesh-control", Number: 7, Head: "bbbb"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeKeys(t *testing.T) { // 1201/AC1
|
||||
if k, err := MergesKey("feat/x"); err != nil || k != "feat/x" {
|
||||
t.Fatalf("merges key = %q, %v", k, err)
|
||||
}
|
||||
if _, err := MergesKey("a.b"); err == nil {
|
||||
t.Error("a group with a dot accepted")
|
||||
}
|
||||
hh, _ := HeadsHash(gateHeads())
|
||||
key, err := MergeAskKey("feat/x", gateHeads())
|
||||
if err != nil || key != "merge.feat/x."+hh {
|
||||
t.Fatalf("ask key = %q, %v", key, err)
|
||||
}
|
||||
kind, group, hash, err := ParseAskKey(key)
|
||||
if err != nil || kind != AskKindMerge || group != "feat/x" || hash != hh {
|
||||
t.Fatalf("parse ask key = %q %q %q %v", kind, group, hash, err)
|
||||
}
|
||||
if AnswerKey(key) != key {
|
||||
t.Error("the answer's key is not the ask's key")
|
||||
}
|
||||
if _, err := MergeAskKey("feat/x", nil); err == nil {
|
||||
t.Error("an ask key without heads accepted")
|
||||
}
|
||||
if _, err := MergeAskKey("", gateHeads()); err == nil {
|
||||
t.Error("an ask key without a group accepted")
|
||||
}
|
||||
for _, bad := range []string{"", "merge", "merge.g", "merge.g.h.x", "other.g.0123456789ab", "merge..0123456789ab", "merge.g.nothex"} {
|
||||
if _, _, _, err := ParseAskKey(bad); err == nil {
|
||||
t.Errorf("parse ask key %q accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAskID(t *testing.T) { // 1201/AC2
|
||||
k1, _ := MergeAskKey("feat/x", gateHeads())
|
||||
k2, _ := MergeAskKey("feat/y", gateHeads())
|
||||
k3, _ := MergeAskKey("feat/x", gateHeads()[:1])
|
||||
long, _ := MergeAskKey("feat/"+strings.Repeat("long-", 20), gateHeads())
|
||||
long2, _ := MergeAskKey("feat/"+strings.Repeat("long-", 20)+"2", gateHeads())
|
||||
seen := map[string]string{}
|
||||
for _, k := range []string{k1, k2, k3, long, long2} {
|
||||
id := AskID(k)
|
||||
if strings.Contains(id, ".") {
|
||||
t.Errorf("id %q holds a dot", id)
|
||||
}
|
||||
if !asks.UsableID(id) {
|
||||
t.Errorf("id %q is not usable by the router", id)
|
||||
}
|
||||
if other, dup := seen[id]; dup {
|
||||
t.Errorf("keys %q and %q give the id %q", k, other, id)
|
||||
}
|
||||
seen[id] = k
|
||||
}
|
||||
if AskID(k1) != AskID(k1) {
|
||||
t.Error("the id is not stable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwedKey(t *testing.T) { // 1201/AC3
|
||||
for _, act := range []string{"status=mesh/merge-gate", "view", "note", "comment"} {
|
||||
key, err := OwedKey("novox/mesh-sdk", 36, "aaaa", act)
|
||||
if err != nil || key != "novox/mesh-sdk.36.aaaa."+act {
|
||||
t.Fatalf("owed key for %s = %q, %v", act, key, err)
|
||||
}
|
||||
repo, number, head, gotAct, err := ParseOwedKey(key)
|
||||
if err != nil || repo != "novox/mesh-sdk" || number != 36 || head != "aaaa" || gotAct != act {
|
||||
t.Fatalf("parse %q = %q %d %q %q %v", key, repo, number, head, gotAct, err)
|
||||
}
|
||||
}
|
||||
kind, context, err := ParseOwedAct("status=mesh/merge-gate")
|
||||
if err != nil || kind != OwedStatus || context != "mesh/merge-gate" {
|
||||
t.Errorf("owed act = %q %q %v", kind, context, err)
|
||||
}
|
||||
for _, bad := range []string{"", "merge", "status", "status=", "view=x", "status=a.b", "statuses=x"} {
|
||||
if _, err := OwedKey("novox/mesh-sdk", 36, "aaaa", bad); err == nil {
|
||||
t.Errorf("owed act %q accepted", bad)
|
||||
}
|
||||
}
|
||||
if _, err := OwedKey("nope", 36, "aaaa", "view"); err == nil {
|
||||
t.Error("a bad repository accepted")
|
||||
}
|
||||
if _, err := OwedKey("novox/mesh-sdk", 0, "aaaa", "view"); err == nil {
|
||||
t.Error("pull request number 0 accepted")
|
||||
}
|
||||
if _, err := OwedKey("novox/mesh-sdk", 36, "", "view"); err == nil {
|
||||
t.Error("an empty head accepted")
|
||||
}
|
||||
for _, bad := range []string{"", "novox/mesh-sdk.36.aaaa", "novox/mesh-sdk.x.aaaa.view", "novox/mesh-sdk.36.aaaa.merge", "a.b.36.aaaa.view"} {
|
||||
if _, _, _, _, err := ParseOwedKey(bad); err == nil {
|
||||
t.Errorf("parse owed key %q accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sampleMerge() Merge {
|
||||
return Merge{Group: "feat/x", Ask: "merge.feat/x.0123456789ab", Pulls: []MergePull{
|
||||
{Head: gateHeads()[0], State: MergeMerged, Commit: "cccc"},
|
||||
{Head: gateHeads()[1], State: MergeRefused, Words: "the forge says no"},
|
||||
{Head: Head{Repository: "novox/other", Number: 1, Head: "dddd"}, State: MergeMerging},
|
||||
}}
|
||||
}
|
||||
|
||||
func sampleAsk() Ask {
|
||||
key, _ := MergeAskKey("feat/x", gateHeads())
|
||||
return Ask{Group: "feat/x", Heads: gateHeads(), Ask: asks.Ask{
|
||||
ID: AskID(key), Headline: "Merge into main: group feat/x", Explanation: "The group passed its checks.",
|
||||
Who: asks.Operator, Expires: time.Date(2026, 10, 20, 8, 0, 0, 0, time.UTC), OnExpiry: "nothing is merged",
|
||||
Options: []asks.Option{{ID: "approve", Label: "Approve", Does: "merges", Level: asks.Approve, Binds: "sha256:00"}},
|
||||
}}
|
||||
}
|
||||
|
||||
func sampleOwed() []Owed {
|
||||
return []Owed{
|
||||
{Act: OwedStatus, Context: "mesh/merge-gate", State: "success"},
|
||||
{Act: OwedView, Text: "the delivery reaches ace"},
|
||||
{Act: OwedNote, Text: "plan: mesh-mail to ace", Ref: "mesh-plan"},
|
||||
{Act: OwedComment, Text: "merged"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestGateValuesRoundTrip(t *testing.T) { // 1201/AC4
|
||||
hasV := func(name string, data []byte, err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
var probe map[string]any
|
||||
if json.Unmarshal(data, &probe) != nil || probe["v"] != float64(1) {
|
||||
t.Fatalf("%s: %s does not carry v:1", name, data)
|
||||
}
|
||||
}
|
||||
m := sampleMerge()
|
||||
data, err := EncodeMerge(m)
|
||||
hasV("merge", data, err)
|
||||
m.V = Version
|
||||
if got, err := DecodeMerge(data); err != nil || !SameMerge(got, m) {
|
||||
t.Errorf("merge round trip: %+v %v", got, err)
|
||||
}
|
||||
a := sampleAsk()
|
||||
data, err = EncodeAsk(a)
|
||||
hasV("ask", data, err)
|
||||
a.V = Version
|
||||
if got, err := DecodeAsk(data); err != nil || !SameAsk(got, a) {
|
||||
t.Errorf("ask round trip: %+v %v", got, err)
|
||||
}
|
||||
an := Answer{Option: "approve", Warrant: "w-1", Words: "approved on telegram"}
|
||||
data, err = EncodeAnswer(an)
|
||||
hasV("answer", data, err)
|
||||
an.V = Version
|
||||
if got, err := DecodeAnswer(data); err != nil || !SameAnswer(got, an) {
|
||||
t.Errorf("answer round trip: %+v %v", got, err)
|
||||
}
|
||||
for _, o := range sampleOwed() {
|
||||
data, err = EncodeOwed(o)
|
||||
hasV("owed "+o.Act, data, err)
|
||||
o.V = Version
|
||||
if got, err := DecodeOwed(data); err != nil || !SameOwed(got, o) {
|
||||
t.Errorf("owed %s round trip: %+v %v", o.Act, got, err)
|
||||
}
|
||||
}
|
||||
for name, dec := range map[string]func([]byte) error{
|
||||
"merge": func(b []byte) error { _, err := DecodeMerge(b); return err },
|
||||
"ask": func(b []byte) error { _, err := DecodeAsk(b); return err },
|
||||
"answer": func(b []byte) error { _, err := DecodeAnswer(b); return err },
|
||||
"owed": func(b []byte) error { _, err := DecodeOwed(b); return err },
|
||||
} {
|
||||
for _, in := range []string{`{"group":"g"}`, `{"v":2}`} {
|
||||
if err := dec([]byte(in)); !errors.Is(err, ErrUnknownVersion) {
|
||||
t.Errorf("%s decode of %s = %v, want ErrUnknownVersion", name, in, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGateValuesRefused(t *testing.T) { // 1201/AC5
|
||||
wantErr := func(name string, err error, words ...string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Errorf("%s accepted", name)
|
||||
return
|
||||
}
|
||||
for _, w := range words {
|
||||
if !strings.Contains(err.Error(), w) {
|
||||
t.Errorf("%s: error %q does not name %q", name, err, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
m := sampleMerge()
|
||||
m.Pulls[0].Commit = ""
|
||||
_, err := EncodeMerge(m)
|
||||
wantErr("merged without a merge commit", err, "commit")
|
||||
m = sampleMerge()
|
||||
m.Pulls[1].Words = ""
|
||||
_, err = EncodeMerge(m)
|
||||
wantErr("refused without words", err, "words")
|
||||
m = sampleMerge()
|
||||
m.Pulls[2].State = "done"
|
||||
_, err = EncodeMerge(m)
|
||||
wantErr("unknown state", err, "done")
|
||||
m = sampleMerge()
|
||||
m.Pulls = nil
|
||||
_, err = EncodeMerge(m)
|
||||
wantErr("merge without pulls", err, "pull")
|
||||
m = sampleMerge()
|
||||
m.Ask = ""
|
||||
_, err = EncodeMerge(m)
|
||||
wantErr("merge without its ask", err, "ask")
|
||||
|
||||
a := sampleAsk()
|
||||
a.Ask.ID = "something-else"
|
||||
_, err = EncodeAsk(a)
|
||||
wantErr("ask with another id", err, "something-else", AskID("merge.feat/x."+mustHash(gateHeads())))
|
||||
a = sampleAsk()
|
||||
a.Ask.Options = nil
|
||||
_, err = EncodeAsk(a)
|
||||
wantErr("ask without options", err, "option")
|
||||
a = sampleAsk()
|
||||
a.Heads = nil
|
||||
_, err = EncodeAsk(a)
|
||||
wantErr("ask without heads", err, "pull request")
|
||||
|
||||
for name, an := range map[string]Answer{
|
||||
"option": {Warrant: "w", Words: "x"},
|
||||
"warrant": {Option: "approve", Words: "x"},
|
||||
"words": {Option: "approve", Warrant: "w"},
|
||||
} {
|
||||
_, err = EncodeAnswer(an)
|
||||
wantErr("answer without "+name, err, name)
|
||||
}
|
||||
|
||||
for name, o := range map[string]Owed{
|
||||
"context": {Act: OwedStatus, State: "success"},
|
||||
"state": {Act: OwedStatus, Context: "mesh/merge-gate", State: "green"},
|
||||
"text": {Act: OwedView},
|
||||
"ref": {Act: OwedNote, Text: "line"},
|
||||
"comment": {Act: OwedComment},
|
||||
"act": {Act: "merge"},
|
||||
} {
|
||||
_, err = EncodeOwed(o)
|
||||
wantErr("owed missing "+name, err)
|
||||
}
|
||||
_, err = EncodeOwed(Owed{Act: OwedNote, Text: "line"})
|
||||
wantErr("note without ref", err, "ref")
|
||||
_, err = EncodeOwed(Owed{Act: OwedStatus, State: "success"})
|
||||
wantErr("status without context", err, "context")
|
||||
}
|
||||
|
||||
func mustHash(h []Head) string { s, _ := HeadsHash(h); return s }
|
||||
|
||||
func TestGateSame(t *testing.T) { // 1201/AC6
|
||||
if !SameMerge(sampleMerge(), sampleMerge()) || !SameAsk(sampleAsk(), sampleAsk()) {
|
||||
t.Error("equal values differ")
|
||||
}
|
||||
m := sampleMerge()
|
||||
m.Pulls[0].Commit = "other"
|
||||
if SameMerge(m, sampleMerge()) {
|
||||
t.Error("different merges are the same")
|
||||
}
|
||||
a := sampleAsk()
|
||||
a.Ask.Headline = "other"
|
||||
if SameAsk(a, sampleAsk()) {
|
||||
t.Error("different asks are the same")
|
||||
}
|
||||
if !SameAnswer(Answer{Option: "a", Warrant: "w", Words: "x"}, Answer{Option: "a", Warrant: "w", Words: "x"}) ||
|
||||
SameAnswer(Answer{Option: "a", Warrant: "w", Words: "x"}, Answer{Option: "b", Warrant: "w", Words: "x"}) {
|
||||
t.Error("SameAnswer wrong")
|
||||
}
|
||||
if !SameOwed(sampleOwed()[0], sampleOwed()[0]) || SameOwed(sampleOwed()[0], sampleOwed()[1]) {
|
||||
t.Error("SameOwed wrong")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGateBuckets(t *testing.T) {
|
||||
if MergesBucket != "mesh-delivery-gate_merges" || AsksBucket != "mesh-delivery-gate_asks" ||
|
||||
OwedBucket != "mesh-delivery-gate_owed" || AnswersBucket != "mesh-delivery-teller_answers" ||
|
||||
MergesHistory != 10 || AsksHistory != 5 || OwedHistory != 5 || AnswersHistory != 5 {
|
||||
t.Error("a bucket or history differs from the decided layout")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user