Merge pull request 'Delivery layouts for ADR 0548: merge turns, carried verdicts and the siblings a verdict read (issue 568)' (#88) from feat/548-one-check into main
This commit was merged in pull request #88.
This commit is contained in:
@@ -115,6 +115,10 @@ type Composition struct {
|
|||||||
Words string `json:"words,omitempty"`
|
Words string `json:"words,omitempty"`
|
||||||
SDK string `json:"sdk,omitempty"`
|
SDK string `json:"sdk,omitempty"`
|
||||||
Machines []string `json:"machines,omitempty"`
|
Machines []string `json:"machines,omitempty"`
|
||||||
|
// FPSet is the candidate's fingerprint set (Candidate.FPSet) the composition judged: a carry keeps a composition that
|
||||||
|
// passed while the candidate's set is the same, and composes again when a fingerprint changed (novox/hq ADR 0548 §5).
|
||||||
|
// Absent in a value written before it, which a carry composes again.
|
||||||
|
FPSet string `json:"fpset,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Took is the time of each part of a check in integer milliseconds (ADR 0447 clause 3): waiting in the queue, cloning,
|
// Took is the time of each part of a check in integer milliseconds (ADR 0447 clause 3): waiting in the queue, cloning,
|
||||||
@@ -166,6 +170,14 @@ type Verdict struct {
|
|||||||
Composition *Composition `json:"composition,omitempty"`
|
Composition *Composition `json:"composition,omitempty"`
|
||||||
// OverBound names the levels whose running went over their bound; it does not change the State (ADR 0453 clause 9).
|
// OverBound names the levels whose running went over their bound; it does not change the State (ADR 0453 clause 9).
|
||||||
OverBound []string `json:"over-bound,omitempty"`
|
OverBound []string `json:"over-bound,omitempty"`
|
||||||
|
// Beside is the commit of each sibling repository the check read beside the repositories it merged (ADR 0484 §4), by
|
||||||
|
// repository: a move of one puts the verdict out of date as a trunk move does, and it is carried (novox/hq ADR 0548 §6).
|
||||||
|
// A repository is in Trunk or here, never both. Absent in a value written before it.
|
||||||
|
Beside map[string]string `json:"beside,omitempty"`
|
||||||
|
// CarriedFrom names, of a carried verdict, the trunk and sibling commits it was judged on before the moves it was
|
||||||
|
// carried over, by repository (ADR 0548 §5): the verdict was rewritten for Trunk and Beside by running only the packages
|
||||||
|
// whose digest the moves changed. Absent from a verdict judged whole, and from one still running.
|
||||||
|
CarriedFrom map[string]string `json:"carried-from,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Composition) check() error {
|
func (c *Composition) check() error {
|
||||||
@@ -203,6 +215,33 @@ func (v Verdict) check() error {
|
|||||||
return fmt.Errorf("delivery: the verdict's trunk head of %s is empty", repository)
|
return fmt.Errorf("delivery: the verdict's trunk head of %s is empty", repository)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for repository, commit := range v.Beside {
|
||||||
|
if commit == "" {
|
||||||
|
return fmt.Errorf("delivery: the verdict's sibling commit of %s is empty", repository)
|
||||||
|
}
|
||||||
|
if _, ok := v.Trunk[repository]; ok {
|
||||||
|
return fmt.Errorf("delivery: the verdict names %s as a trunk it merged and as a sibling it read", repository)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(v.CarriedFrom) > 0 {
|
||||||
|
if v.State == VerdictRunning {
|
||||||
|
return fmt.Errorf("delivery: a running verdict is carried from nothing: carried-from is written with its end")
|
||||||
|
}
|
||||||
|
moved := false
|
||||||
|
for repository, commit := range v.CarriedFrom {
|
||||||
|
now, inTrunk := v.Trunk[repository]
|
||||||
|
if !inTrunk {
|
||||||
|
now = v.Beside[repository]
|
||||||
|
}
|
||||||
|
if commit == "" || now == "" {
|
||||||
|
return fmt.Errorf("delivery: the verdict's carried-from names %s, which it judged on no commit of", repository)
|
||||||
|
}
|
||||||
|
moved = moved || commit != now
|
||||||
|
}
|
||||||
|
if !moved {
|
||||||
|
return fmt.Errorf("delivery: the verdict's carried-from names the commits it is judged on: nothing moved, so nothing was carried")
|
||||||
|
}
|
||||||
|
}
|
||||||
for level, r := range v.Levels {
|
for level, r := range v.Levels {
|
||||||
if err := checkLevel(level); err != nil {
|
if err := checkLevel(level); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -29,7 +29,12 @@ func verdictSamples() map[string]Verdict {
|
|||||||
failTest.Levels = map[string]LevelResult{LevelUnit: {Run: 1}}
|
failTest.Levels = map[string]LevelResult{LevelUnit: {Run: 1}}
|
||||||
failMachine := passVerdict()
|
failMachine := passVerdict()
|
||||||
failMachine.State, failMachine.Failed = VerdictFail, FailedFirst{Machine: "ace", Words: "path /etc/x is owned by two modules"}
|
failMachine.State, failMachine.Failed = VerdictFail, FailedFirst{Machine: "ace", Words: "path /etc/x is owned by two modules"}
|
||||||
return map[string]Verdict{"running": running, "pass": passVerdict(), "fail by a test": failTest, "fail by a machine": failMachine}
|
carried := passVerdict()
|
||||||
|
carried.Beside = map[string]string{"novox/mesh-catalog": "cccc"}
|
||||||
|
carried.CarriedFrom = map[string]string{"novox/mesh-sdk": "9999", "novox/mesh-control": "bbbb", "novox/mesh-catalog": "cccc"}
|
||||||
|
carried.Composition = &Composition{State: CompositionPass, SDK: carried.SDK, Machines: []string{"ace"}, FPSet: "fps1:00"}
|
||||||
|
return map[string]Verdict{"running": running, "pass": passVerdict(), "fail by a test": failTest, "fail by a machine": failMachine,
|
||||||
|
"carried": carried}
|
||||||
}
|
}
|
||||||
|
|
||||||
func keptSample() Kept {
|
func keptSample() Kept {
|
||||||
@@ -248,7 +253,14 @@ func TestVerdictCarriesSkippedCompositionAndOverBound(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for _, field := range []string{`"skipped":4`, `"composition":{"state":"pass"`, `"over-bound":["integration"]`} {
|
v.Beside, v.CarriedFrom = map[string]string{"novox/mesh-catalog": "cccc"}, map[string]string{"novox/mesh-catalog": "cbcb"}
|
||||||
|
v.Composition.FPSet = "fps1:ab"
|
||||||
|
data, err = EncodeVerdict(v)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, field := range []string{`"skipped":4`, `"composition":{"state":"pass"`, `"over-bound":["integration"]`, `"beside":{"novox/mesh-catalog":"cccc"}`,
|
||||||
|
`"carried-from":{"novox/mesh-catalog":"cbcb"}`, `"fpset":"fps1:ab"`} {
|
||||||
if !strings.Contains(string(data), field) {
|
if !strings.Contains(string(data), field) {
|
||||||
t.Errorf("the value lacks %s: %s", field, data)
|
t.Errorf("the value lacks %s: %s", field, data)
|
||||||
}
|
}
|
||||||
@@ -295,6 +307,13 @@ func TestVerdictNewFieldsChecked(t *testing.T) {
|
|||||||
"a not-run composition naming machines": {func(v *Verdict) { v.Composition = &Composition{State: CompositionNotRun, Machines: []string{"ace"}} }, []string{"composition", "not run"}},
|
"a not-run composition naming machines": {func(v *Verdict) { v.Composition = &Composition{State: CompositionNotRun, Machines: []string{"ace"}} }, []string{"composition", "not run"}},
|
||||||
"an unknown over-bound level": {func(v *Verdict) { v.OverBound = []string{"e2e"} }, []string{"e2e"}},
|
"an unknown over-bound level": {func(v *Verdict) { v.OverBound = []string{"e2e"} }, []string{"e2e"}},
|
||||||
"an over-bound level named twice": {func(v *Verdict) { v.OverBound = []string{LevelUnit, LevelUnit} }, []string{"over-bound", "unit"}},
|
"an over-bound level named twice": {func(v *Verdict) { v.OverBound = []string{LevelUnit, LevelUnit} }, []string{"over-bound", "unit"}},
|
||||||
|
// ADR 0548 §5, §6: the siblings read and the commits a verdict was carried from.
|
||||||
|
"a sibling with no commit": {func(v *Verdict) { v.Beside = map[string]string{"novox/mesh-catalog": ""} }, []string{"sibling", "mesh-catalog"}},
|
||||||
|
"a trunk named as a sibling": {func(v *Verdict) { v.Beside = map[string]string{"novox/mesh-sdk": "aaaa"} }, []string{"trunk", "sibling", "mesh-sdk"}},
|
||||||
|
"carried from an unjudged repo": {func(v *Verdict) { v.CarriedFrom = map[string]string{"novox/other": "1111"} }, []string{"carried-from", "novox/other"}},
|
||||||
|
"carried from an empty commit": {func(v *Verdict) { v.CarriedFrom = map[string]string{"novox/mesh-sdk": ""} }, []string{"carried-from", "mesh-sdk"}},
|
||||||
|
"carried over no move": {func(v *Verdict) { v.CarriedFrom = map[string]string{"novox/mesh-sdk": "aaaa"} }, []string{"nothing moved"}},
|
||||||
|
"a running verdict carried": {func(v *Verdict) { v.State, v.CarriedFrom = VerdictRunning, map[string]string{"novox/mesh-sdk": "9999"} }, []string{"running", "carried"}},
|
||||||
}
|
}
|
||||||
for name, c := range cases {
|
for name, c := range cases {
|
||||||
v := passVerdict()
|
v := passVerdict()
|
||||||
@@ -319,7 +338,7 @@ func TestVerdictOldAndNewReadersAgree(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("the new reader of an old value: %v", err)
|
t.Fatalf("the new reader of an old value: %v", err)
|
||||||
}
|
}
|
||||||
if v.Composition != nil || v.OverBound != nil || v.Levels[LevelUnit].Skipped != 0 || v.Levels[LevelUnit].Run != 12 {
|
if v.Composition != nil || v.OverBound != nil || v.Beside != nil || v.CarriedFrom != nil || v.Levels[LevelUnit].Skipped != 0 || v.Levels[LevelUnit].Run != 12 {
|
||||||
t.Errorf("an old value grew fields: %+v", v)
|
t.Errorf("an old value grew fields: %+v", v)
|
||||||
}
|
}
|
||||||
again, err := EncodeVerdict(v)
|
again, err := EncodeVerdict(v)
|
||||||
@@ -330,6 +349,9 @@ func TestVerdictOldAndNewReadersAgree(t *testing.T) {
|
|||||||
n.Levels[LevelUnit] = LevelResult{Run: 12, Kept: 301, Skipped: 4}
|
n.Levels[LevelUnit] = LevelResult{Run: 12, Kept: 301, Skipped: 4}
|
||||||
n.Composition = &Composition{State: CompositionNotRun}
|
n.Composition = &Composition{State: CompositionNotRun}
|
||||||
n.OverBound = []string{LevelUnit}
|
n.OverBound = []string{LevelUnit}
|
||||||
|
n.Beside = map[string]string{"novox/mesh-catalog": "cccc"}
|
||||||
|
n.CarriedFrom = map[string]string{"novox/mesh-sdk": "9999"}
|
||||||
|
n.Composition.FPSet = "fps1:00"
|
||||||
data, err := EncodeVerdict(n)
|
data, err := EncodeVerdict(n)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -123,6 +123,15 @@ func sampleMerge() Merge {
|
|||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sampleTurn() Turn {
|
||||||
|
at := time.Date(2026, 10, 26, 9, 0, 0, 0, time.UTC)
|
||||||
|
return Turn{Repository: "novox/mesh-sdk", Waiting: []TurnWait{
|
||||||
|
{Group: "feat/x", Heads: mustHash(gateHeads()), Ask: "merge.feat/x." + mustHash(gateHeads()), Approved: at, Since: at,
|
||||||
|
WaitsFor: "feat/y.0123456789ab", WaitsSince: at.Add(-time.Minute), Step: "the check ask of novox/mesh-sdk#40"},
|
||||||
|
{Group: "fix/1.2", Heads: "0123456789ac", Approved: at.Add(time.Minute), Since: at.Add(time.Minute)},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
func sampleAsk() Ask {
|
func sampleAsk() Ask {
|
||||||
key, _ := MergeAskKey("feat/x", gateHeads())
|
key, _ := MergeAskKey("feat/x", gateHeads())
|
||||||
return Ask{Group: "feat/x", Heads: gateHeads(), Ask: asks.Ask{
|
return Ask{Group: "feat/x", Heads: gateHeads(), Ask: asks.Ask{
|
||||||
@@ -181,7 +190,24 @@ func TestGateValuesRoundTrip(t *testing.T) { // 1201/AC4
|
|||||||
t.Errorf("owed %s round trip: %+v %v", o.Act, got, err)
|
t.Errorf("owed %s round trip: %+v %v", o.Act, got, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
tu := sampleTurn()
|
||||||
|
data, err = EncodeTurn(tu)
|
||||||
|
hasV("turn", data, err)
|
||||||
|
tu.V = Version
|
||||||
|
if got, err := DecodeTurn(data); err != nil || !SameTurn(got, tu) {
|
||||||
|
t.Errorf("turn round trip: %+v %v", got, err)
|
||||||
|
}
|
||||||
|
for _, field := range []string{`"waits-for":"feat/y.0123456789ab"`, `"waits-since":`, `"approved":`, `"step":`} {
|
||||||
|
if !strings.Contains(string(data), field) {
|
||||||
|
t.Errorf("the turn lacks %s: %s", field, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ADR 0548 §3: a turn holds off every check but those of the heads that wait in it, whoever else waits there.
|
||||||
|
if !TurnHolds(tu, "fedcba987654") || TurnHolds(tu, tu.Waiting[0].Heads) || TurnHolds(tu, tu.Waiting[1].Heads) || TurnHolds(Turn{}, "fedcba987654") {
|
||||||
|
t.Error("TurnHolds: a turn holds other heads' checks and never the check of heads waiting in it")
|
||||||
|
}
|
||||||
for name, dec := range map[string]func([]byte) error{
|
for name, dec := range map[string]func([]byte) error{
|
||||||
|
"turn": func(b []byte) error { _, err := DecodeTurn(b); return err },
|
||||||
"merge": func(b []byte) error { _, err := DecodeMerge(b); return err },
|
"merge": func(b []byte) error { _, err := DecodeMerge(b); return err },
|
||||||
"ask": func(b []byte) error { _, err := DecodeAsk(b); return err },
|
"ask": func(b []byte) error { _, err := DecodeAsk(b); return err },
|
||||||
"answer": func(b []byte) error { _, err := DecodeAnswer(b); return err },
|
"answer": func(b []byte) error { _, err := DecodeAnswer(b); return err },
|
||||||
@@ -265,6 +291,27 @@ func TestGateValuesRefused(t *testing.T) { // 1201/AC5
|
|||||||
}
|
}
|
||||||
_, err = EncodeOwed(Owed{Act: OwedNote, Text: "line"})
|
_, err = EncodeOwed(Owed{Act: OwedNote, Text: "line"})
|
||||||
wantErr("note without ref", err, "ref")
|
wantErr("note without ref", err, "ref")
|
||||||
|
|
||||||
|
for name, c := range map[string]struct {
|
||||||
|
edit func(*Turn)
|
||||||
|
words []string
|
||||||
|
}{
|
||||||
|
"no delivery": {func(t *Turn) { t.Waiting = nil }, []string{"no delivery", "deleted"}},
|
||||||
|
"a repository in capitals": {func(t *Turn) { t.Repository = "novox/Mesh-SDK" }, []string{"lowercase"}},
|
||||||
|
"no repository": {func(t *Turn) { t.Repository = "" }, []string{"repository"}},
|
||||||
|
"no group": {func(t *Turn) { t.Waiting[0].Group = "" }, []string{"group"}},
|
||||||
|
"a bad heads hash": {func(t *Turn) { t.Waiting[0].Heads = "xyz" }, []string{"xyz"}},
|
||||||
|
"the same heads twice": {func(t *Turn) { t.Waiting[1].Heads = t.Waiting[0].Heads }, []string{"twice"}},
|
||||||
|
"no Approve time": {func(t *Turn) { t.Waiting[1].Approved = time.Time{} }, []string{"Approve"}},
|
||||||
|
"out of the Approve's order": {func(t *Turn) { t.Waiting[1].Approved = t.Waiting[0].Approved.Add(-time.Hour) }, []string{"order"}},
|
||||||
|
"waits for no verdict key": {func(t *Turn) { t.Waiting[0].WaitsFor = "feat/y" }, []string{"verdict key"}},
|
||||||
|
"a step and no check": {func(t *Turn) { t.Waiting[1].Step = "x" }, []string{"names no check"}},
|
||||||
|
} {
|
||||||
|
tu := sampleTurn()
|
||||||
|
c.edit(&tu)
|
||||||
|
_, err = EncodeTurn(tu)
|
||||||
|
wantErr("turn with "+name, err, c.words...)
|
||||||
|
}
|
||||||
_, err = EncodeOwed(Owed{Act: OwedStatus, State: "success"})
|
_, err = EncodeOwed(Owed{Act: OwedStatus, State: "success"})
|
||||||
wantErr("status without context", err, "context")
|
wantErr("status without context", err, "context")
|
||||||
}
|
}
|
||||||
@@ -297,7 +344,8 @@ func TestGateSame(t *testing.T) { // 1201/AC6
|
|||||||
func TestGateBuckets(t *testing.T) {
|
func TestGateBuckets(t *testing.T) {
|
||||||
if MergesBucket != "mesh-delivery-gate_merges" || AsksBucket != "mesh-delivery-gate_asks" ||
|
if MergesBucket != "mesh-delivery-gate_merges" || AsksBucket != "mesh-delivery-gate_asks" ||
|
||||||
OwedBucket != "mesh-delivery-gate_owed" || AnswersBucket != "mesh-delivery-teller_answers" ||
|
OwedBucket != "mesh-delivery-gate_owed" || AnswersBucket != "mesh-delivery-teller_answers" ||
|
||||||
MergesHistory != 10 || AsksHistory != 5 || OwedHistory != 5 || AnswersHistory != 5 {
|
MergesHistory != 10 || AsksHistory != 5 || OwedHistory != 5 || AnswersHistory != 5 ||
|
||||||
|
TurnsBucket != "mesh-delivery-gate_turns" || TurnsHistory != 5 {
|
||||||
t.Error("a bucket or history differs from the decided layout")
|
t.Error("a bucket or history differs from the decided layout")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -370,6 +418,21 @@ func TestKeysEscapeAndRoundTrip(t *testing.T) { // 1323/AC1
|
|||||||
if r, n, h, a, err := ParseOwedKey(ok); err != nil || r != repo || n != 3 || h != "aaaa" || a != "view" {
|
if r, n, h, a, err := ParseOwedKey(ok); err != nil || r != repo || n != 3 || h != "aaaa" || a != "view" {
|
||||||
t.Errorf("owed round trip %q = %q %d %q %q %v", repo, r, n, h, a, err)
|
t.Errorf("owed round trip %q = %q %d %q %q %v", repo, r, n, h, a, err)
|
||||||
}
|
}
|
||||||
|
// ADR 0548 §3: a repository's turn key is <owner>.<name>, lowercase, each part escaped.
|
||||||
|
tk, err := TurnsKey(repo)
|
||||||
|
if err != nil || strings.Count(tk, ".") != 1 {
|
||||||
|
t.Errorf("turns key for repository %q = %q, %v", repo, tk, err)
|
||||||
|
} else if r, err := ParseTurnsKey(tk); err != nil || r != strings.ToLower(repo) {
|
||||||
|
t.Errorf("turns round trip %q = %q, %v", repo, r, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if k, err := TurnsKey("novox/Mesh-SDK"); err != nil || k != "novox.mesh-sdk" {
|
||||||
|
t.Errorf("turns key = %q, %v; want novox.mesh-sdk", k, err)
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"", "novox", "novox.", ".x", "a.b.c", "Novox.mesh-sdk", "novox.a=zz"} {
|
||||||
|
if _, err := ParseTurnsKey(bad); err == nil {
|
||||||
|
t.Errorf("parse turns key %q accepted", bad)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if EscapeName("a.b") != "a=2eb" || EscapeName("a=b") != "a=3db" || EscapeName("a+b") != "a=2bb" {
|
if EscapeName("a.b") != "a=2eb" || EscapeName("a=b") != "a=3db" || EscapeName("a+b") != "a=2bb" {
|
||||||
t.Errorf("escapes: %q %q %q", EscapeName("a.b"), EscapeName("a=b"), EscapeName("a+b"))
|
t.Errorf("escapes: %q %q %q", EscapeName("a.b"), EscapeName("a=b"), EscapeName("a+b"))
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package delivery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// **The merge turns** (novox/hq ADR 0548 clauses 3 and 4). An approved merge holds a turn on each repository it goes into:
|
||||||
|
// before it reads the checker's verdicts for the merge, the gate writes one key per repository in its bucket turns, naming
|
||||||
|
// the deliveries that wait in the order of their Approve, the verdict key each waits for and since when. The gate is the
|
||||||
|
// key's one writer; the checker is granted to read the bucket, reads it absent as empty, and claims no check of a repository
|
||||||
|
// a turn names, except the check of the waiting delivery's own heads (TurnHolds). The key goes when the last delivery in it
|
||||||
|
// merged or left.
|
||||||
|
const (
|
||||||
|
// TurnsBucket is the gate's bucket of merge turns.
|
||||||
|
TurnsBucket = GateModule + "_turns"
|
||||||
|
// TurnsHistory is the revisions the turns bucket keeps per key.
|
||||||
|
TurnsHistory = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
// TurnsKey is the key, in TurnsBucket, of a repository's turn: `<owner>.<name>`, lowercase, each part escaped (EscapeName),
|
||||||
|
// so the key names the repository the checker's verdicts name as a trunk whatever its case.
|
||||||
|
func TurnsKey(repository string) (string, error) {
|
||||||
|
repository = strings.ToLower(repository)
|
||||||
|
if _, err := escapeRepository(repository); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
owner, name, _ := strings.Cut(repository, "/")
|
||||||
|
return EscapeName(owner) + "." + EscapeName(name), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseTurnsKey is the inverse of TurnsKey: the repository, lowercase.
|
||||||
|
func ParseTurnsKey(key string) (string, error) {
|
||||||
|
owner, name, ok := strings.Cut(key, ".")
|
||||||
|
if !ok || owner == "" || name == "" || strings.Contains(name, ".") {
|
||||||
|
return "", fmt.Errorf("delivery: %q is not a turns key, <owner>.<name>", key)
|
||||||
|
}
|
||||||
|
o, err := UnescapeName(owner)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("delivery: %q is not a turns key: %w", key, err)
|
||||||
|
}
|
||||||
|
n, err := UnescapeName(name)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("delivery: %q is not a turns key: %w", key, err)
|
||||||
|
}
|
||||||
|
repository := o + "/" + n
|
||||||
|
if again, err := TurnsKey(repository); err != nil || again != key {
|
||||||
|
return "", fmt.Errorf("delivery: %q is not a turns key: it does not name a lowercase <owner>/<name>", key)
|
||||||
|
}
|
||||||
|
return repository, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turn is the value of a turns key: the repository and the approved deliveries that wait to merge into it, in the order of
|
||||||
|
// their Approve. A turn with no delivery is no value: its key is deleted.
|
||||||
|
type Turn struct {
|
||||||
|
V int `json:"v"`
|
||||||
|
Repository string `json:"repository"`
|
||||||
|
Waiting []TurnWait `json:"waiting"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TurnWait is one approved delivery in a turn: its group (a lone pull request's branch), the hash of the heads the Approve
|
||||||
|
// covers (the checker's verdict key's hash, HeadsHash over its members), the Approve's ask key, when it was approved and since
|
||||||
|
// when it waits; and, while a check of another delivery runs on the repository, that check's verdict key, when the gate saw
|
||||||
|
// it start, and the step the checker says it is at.
|
||||||
|
type TurnWait struct {
|
||||||
|
Group string `json:"group"`
|
||||||
|
Heads string `json:"heads"`
|
||||||
|
Ask string `json:"ask,omitempty"`
|
||||||
|
Approved time.Time `json:"approved"`
|
||||||
|
Since time.Time `json:"since"`
|
||||||
|
// WaitsFor is the verdict key (`<group>.<heads hash>`) of the running check the merge waits for; empty when none runs.
|
||||||
|
WaitsFor string `json:"waits-for,omitempty"`
|
||||||
|
WaitsSince time.Time `json:"waits-since,omitzero"`
|
||||||
|
Step string `json:"step,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t Turn) check() error {
|
||||||
|
repository := strings.ToLower(t.Repository)
|
||||||
|
if repository != t.Repository {
|
||||||
|
return fmt.Errorf("delivery: 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("delivery: 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("delivery: a delivery in the turn of %s names no group", t.Repository)
|
||||||
|
}
|
||||||
|
if !isHex12(w.Heads) {
|
||||||
|
return fmt.Errorf("delivery: 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("delivery: 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("delivery: 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("delivery: 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 := ParseCheckerVerdictKey(w.WaitsFor); err != nil {
|
||||||
|
return fmt.Errorf("delivery: 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.IsZero() || w.Step != "" {
|
||||||
|
return fmt.Errorf("delivery: 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// TurnHolds says whether a turn holds off the check of the heads: it names deliveries, and none of them of these heads. A
|
||||||
|
// turn never holds the check of heads that wait in it (the carry a waiting merge needs): two approved merges in one turn whose
|
||||||
|
// trunk moved would otherwise each hold the other's carry, and neither would ever merge (review of ADR 0548's build, round 1).
|
||||||
|
func TurnHolds(t Turn, 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 JSON at Version; it refuses a turn with no delivery, a repository that is not lowercase <owner>/<name>,
|
||||||
|
// a delivery without its group, heads hash or times, the same heads twice, an order other than the Approve's, and a check
|
||||||
|
// waited for that is not a verdict key.
|
||||||
|
func EncodeTurn(t Turn) ([]byte, error) {
|
||||||
|
if err := t.check(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
t.V = Version
|
||||||
|
return json.Marshal(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeTurn reads a turn; it refuses an unknown version (ErrUnknownVersion) and what EncodeTurn refuses.
|
||||||
|
func DecodeTurn(data []byte) (Turn, error) {
|
||||||
|
var t Turn
|
||||||
|
if err := decode(data, &t); err != nil {
|
||||||
|
return Turn{}, err
|
||||||
|
}
|
||||||
|
if err := t.check(); err != nil {
|
||||||
|
return Turn{}, err
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SameTurn says whether two turns are the same value, so the gate writes only when they differ.
|
||||||
|
func SameTurn(a, b Turn) 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) || !x.WaitsSince.Equal(y.WaitsSince) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
x.Approved, y.Approved, x.Since, y.Since, x.WaitsSince, y.WaitsSince = time.Time{}, time.Time{}, time.Time{}, time.Time{}, time.Time{}, time.Time{}
|
||||||
|
if !reflect.DeepEqual(x, y) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user