diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go index 694da81e7..618c43b08 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go @@ -10,6 +10,7 @@ package main import ( "encoding/json" + "errors" "fmt" "os" "strconv" @@ -345,24 +346,20 @@ func tools(h *Holder, l *listening) []stdio.Tool { Input: map[string]any{"type": "object", "properties": map[string]any{ "branch": str("the watched branch, escaped as a key name is; absent: the list of the branches the gate keeps a source revision of")}}, Run: func(a map[string]any) (any, error) { return h.SourceRevision(strArg(a, "branch")), nil }}, - {Name: seat + "checks", - Description: "What the mesh's checks said of a pull request's head or of one commit: each of the commit's " + - "statuses (mesh/check, mesh/delivery, …) with its state, description and when it " + - "was set; the merge check's verdict as the checker's mesh/check line says it, and when it was heard; and whether the branch's protection would let it " + - "merge, every required status being success.", + {Name: seat + "recovery", + Description: "Name a merge a recovery (design 47 G §81): the delivery record that holds the commit is marked with who named it " + + "(the caller the runtime proved), why and the commit, and recovery-merged is said for every channel.", Input: map[string]any{"type": "object", "properties": map[string]any{"repository": str("owner/repository"), - "number": str("a pull request's number: its head is read"), - "commit": str("a commit's sha, or the start of one, instead of a pull request")}, - "required": []string{"repository"}}, + "commit": str("the merge commit"), "why": str("why the merge is a recovery")}, + "required": []string{"repository", "commit", "why"}}, Run: func(a map[string]any) (any, error) { - if err := need(a, "repository"); err != nil { + if err := need(a, "repository", "commit", "why"); err != nil { return nil, err } - number, err := intArg(a, "number") - if err != nil { - return nil, err + if h.Records == nil { + return nil, errors.New("this gate keeps no delivery records") } - return h.Checks(strArg(a, "repository"), number, strArg(a, "commit")) + return h.Records.Recovery(strArg(a, "repository"), strArg(a, "commit"), strArg(a, "why"), stdio.CallerOf(a)) }}, {Name: seat + "stalled", Description: "Every delivery held past its state's bound, with the transition the table lets healer H2 take. " + diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/manifest_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/manifest_test.go index d21a57d0c..d16a2bf9b 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/manifest_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/manifest_test.go @@ -76,8 +76,8 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) { t.Fatal("the manifest declares a seat; mesh-delivery is the mesh's own") } // The verbs the seat keeps: times, what-if, release, stop and retire-history went with the plans (ADR 0539 §17), and - // settle with the merge states (design 47 G §431). - if want := []string{"deliveries", "show", "groups", "source", "checks", "stalled"}; !reflect.DeepEqual(m.Claims[0].Serves, want) { + // settle and checks with the merge states (design 47 G §431); recovery is G's (§81, A-23). + if want := []string{"deliveries", "show", "groups", "source", "recovery", "stalled"}; !reflect.DeepEqual(m.Claims[0].Serves, want) { t.Fatalf("serves %v, wanted %v", m.Claims[0].Serves, want) } // The forge messages' layout is the bridge's published package forgemsg, taken by its series (ADR 0634 §3, design 32). @@ -285,7 +285,7 @@ func TestTheGateHoldsTheSeatAsTheSDKsTableOfRelease6HasIt(t *testing.T) { served[verb] = true } } - for _, removed := range []string{"settle"} { // design 47 G §431 + for _, removed := range []string{"settle", "checks"} { // design 47 G §431 if served[removed] { t.Errorf("the gate serves %s, which G removed with the merge states", removed) } @@ -293,7 +293,7 @@ func TestTheGateHoldsTheSeatAsTheSDKsTableOfRelease6HasIt(t *testing.T) { t.Errorf("the manifest serves %s, which G removed with the merge states", removed) } } - for _, verb := range []string{"deliveries", "show", "groups", "checks", "stalled"} { + for _, verb := range []string{"deliveries", "show", "groups", "stalled", "recovery"} { if !served[verb] { t.Errorf("the gate does not serve %s, which the seat keeps", verb) } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/record.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/record.go index 27e998d4e..9e5d214be 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/record.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/record.go @@ -98,6 +98,16 @@ type Record struct { // Acts are the acts owed the forge for the record's pull requests, by id, with their outcomes (owedended.go). Acts map[string]RecordAct `json:"acts,omitempty"` Ended *RecordEnded `json:"ended,omitempty"` + // Recovery marks the merge its author named a recovery by the seat's verb recovery (design 47 G §81, A-23). + Recovery *RecordRecovery `json:"recovery,omitempty"` +} + +// RecordRecovery is the field recovery of a record: who named the merge a recovery (the caller the runtime proved), why, +// and the merge commit. +type RecordRecovery struct { + By string `json:"by"` + Why string `json:"why"` + Commit string `json:"commit"` } // RecordGroup is the group's state as the record keeps it: what `merges` held for the group, and its members. diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/record_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/record_test.go index 066cb1d55..21f70d038 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/record_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/record_test.go @@ -789,6 +789,37 @@ func TestTheRecordsToolsReadThemAndEndOneByHand(t *testing.T) { t.Fatalf("a second repair did something: %+v", lines) } }) + t.Run("recovery marks the record that holds the commit through its ordinary update and says recovery-merged once (design 47 G A-23)", func(t *testing.T) { + rs, r, said := setup(t) + commit := strings.Repeat("1", 40) + got, err := rs.Recovery("novox/app", commit, "the live delivery was broken", "jochen") + if err != nil || !got.Recovery || got.By != "jochen" || got.Commit != commit || got.Repository != "novox/app" { + t.Fatalf("%+v %v", got, err) + } + held, err := rs.Read(r.Instance) + if err != nil || held.Recovery == nil || *held.Recovery != (RecordRecovery{By: "jochen", Why: "the live delivery was broken", Commit: commit}) { + t.Fatalf("the record is not marked: %+v %v", held, err) + } + subject := meshdelivery.SubjectRecoveryMerged + if _, err := rs.Recovery("novox/app", commit[:12], "the live delivery was broken", "jochen"); err != nil { + t.Fatal(err) + } + n := 0 + for _, s := range *said { + if strings.HasPrefix(s, subject+" ") { + n++ + } + } + if n != 1 { + t.Fatalf("recovery-merged said %d times: %v", n, *said) + } + if _, err := rs.Recovery("novox/other", commit, "why", "jochen"); err == nil { + t.Fatal("a commit no record holds was marked") + } + if _, err := rs.Recovery("novox/app", commit, " ", "jochen"); err == nil { + t.Fatal("a recovery with no why was taken") + } + }) t.Run("a removal asks the runtime's ledger removal of the instance, and one said twice changes nothing", func(t *testing.T) { was := ledgerCall t.Cleanup(func() { ledgerCall = was }) diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/recovery.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/recovery.go new file mode 100644 index 000000000..2c7764c25 --- /dev/null +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/recovery.go @@ -0,0 +1,46 @@ +package main + +import ( + "errors" + "fmt" + "strings" + + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery" +) + +// Recovery names a merge a recovery (design 47 G §81, A-23): the delivery record that holds the commit is marked through its +// ordinary update, with one field recovery holding who named it (the caller the runtime proved), why and the commit, and +// recovery-merged is said. Said twice for the same mark, it changes nothing and says nothing more. +func (rs *Records) Recovery(repository, commit, why, by string) (meshdelivery.RecoveryAnswer, error) { + repository, commit, why = strings.ToLower(strings.TrimSpace(repository)), strings.TrimSpace(commit), strings.TrimSpace(why) + if repository == "" || commit == "" || why == "" { + return meshdelivery.RecoveryAnswer{}, errors.New("a recovery names its repository, its merge commit and why") + } + kept, err := rs.Kept() + if err != nil { + return meshdelivery.RecoveryAnswer{}, fmt.Errorf("the delivery records cannot be read: %w", err) + } + for _, r := range kept { + for id, head := range r.Heads { + if !strings.HasPrefix(strings.ToLower(id), repository+"@") || !strings.HasPrefix(head, commit) { + continue + } + want := RecordRecovery{By: by, Why: why, Commit: head} + if r.Recovery != nil && *r.Recovery == want { + return meshdelivery.RecoveryAnswer{By: by, Commit: head, Recovery: true, Repository: repository}, nil + } + seq, err := rs.Ledger.Apply(r.Instance, OpTrace, map[string]any{"recovery": want}) + if err != nil { + return meshdelivery.RecoveryAnswer{}, err + } + r.Recovery, r.Sequence = &want, seq + now := rs.now() + if err := rs.say(meshdelivery.SubjectRecoveryMerged, meshdelivery.RecoveryMergedEvent{At: &now, By: by, Commit: head, + Repository: repository, V: 1, Why: why}, "recovery-merged."+r.Instance+"."+head); err != nil { + return meshdelivery.RecoveryAnswer{}, fmt.Errorf("the record %s is marked but recovery-merged is not said: %w", r.Instance, err) + } + return meshdelivery.RecoveryAnswer{By: by, Commit: head, Recovery: true, Repository: repository}, nil + } + } + return meshdelivery.RecoveryAnswer{}, fmt.Errorf("no delivery record holds %s at %s: the merge is taken in first", repository, commit) +} diff --git a/modules/mesh-delivery-gate/module.json b/modules/mesh-delivery-gate/module.json index ce584cab1..09398c700 100644 --- a/modules/mesh-delivery-gate/module.json +++ b/modules/mesh-delivery-gate/module.json @@ -11,7 +11,7 @@ "show", "groups", "source", - "checks", + "recovery", "stalled" ] } @@ -127,6 +127,9 @@ }, "words": { "type": "string" + }, + "recovery": { + "type": "object" } }, "operations": [ @@ -154,7 +157,8 @@ "name": "trace", "effect": "set-fields", "fields": [ - "trace" + "trace", + "recovery" ], "sequence": true },