diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/branchend.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/branchend.go new file mode 100644 index 00000000..b2a5aa3f --- /dev/null +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/branchend.go @@ -0,0 +1,142 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "strings" + "time" + + "github.com/nats-io/nats.go/jetstream" + + "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/health" + + "mesh-delivery-builder/internal/versions" +) + +// forgeStream is the forge bridge's stream `forge` as the bus names it, where the builder's durable consumer hears a +// branch's end (design 51 *Tagged versions*). +const forgeStream = "FORGE" + +// forgeBranchDeleted is the forge bridge's kind for a branch's end (delivery.ForgeBranchDeleted in the group's mesh-sdk). +const forgeBranchDeleted = "branch-deleted" + +// checkerAsksStream is the checker's family `asks` as the bus names it, and tagWantedSubject its event tag-wanted +// (design 51 *Tagged versions*, DLV-C3). +const ( + checkerAsksStream = "ASKS" + tagWantedSubject = "mesh.mod." + delivery.CheckerModule + ".event.tag-wanted" +) + +// forgeEnd is the part of a forge message the builder reads. +type forgeEnd struct { + Kind string `json:"kind"` + Repository string `json:"repository"` + Branch string `json:"branch"` + Action string `json:"action"` +} + +// ended says whether the message ends a branch: merged, a pull closed, or branch-deleted. +func (f forgeEnd) ended() bool { + switch f.Kind { + case delivery.ForgeMerged, forgeBranchDeleted: + return f.Branch != "" + case delivery.ForgePull: + return f.Action == delivery.PullClosed && f.Branch != "" + } + return false +} + +// branchEnds hears the forge bridge's stream: every message wakes a floor whose bridge_tags was silent, and a branch's +// end removes its tagged versions, acknowledged only once all are removed. +type branchEnds struct { + acts versions.Acts + findings *findings + tagAsks func() jetstream.KeyValue + wake func(ctx context.Context) + logf func(string, ...any) + now func() time.Time +} + +func (h *branchEnds) heard(ctx context.Context, m jetstream.Msg) { + if h.wake != nil { + h.wake(ctx) + } + var f forgeEnd + if err := json.Unmarshal(m.Data(), &f); err != nil || !f.ended() { + h.ack(m) + return + } + end, err := h.acts.BranchEnded(ctx, f.Repository, f.Branch) + if err != nil { + h.logf("the end of %s %s: the versions cannot be read, heard again: %v", f.Repository, f.Branch, err) + h.again(m) + return + } + id := f.Repository + "/" + f.Branch + if end.Done { + h.findings.clear(ctx, versions.RemovalNotDone, health.ScopeModule, id) + if kv := h.tagAsks(); kv != nil { + if err := kv.Delete(ctx, versions.TagAskKey(f.Repository, f.Branch)); err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) { + h.logf("the tag ask of %s %s stays in %s: %v", f.Repository, f.Branch, versions.TagAsksState, err) + } + } + h.ack(m) + return + } + since := h.now() + if md, err := m.Metadata(); err == nil { + since = md.Timestamp + } + if h.now().Sub(since) >= versions.RemovalFindingAfter { + h.findings.raise(ctx, finding{kind: versions.RemovalNotDone, scope: health.ScopeModule, id: id, + summary: end.Finding(f.Repository, f.Branch, since), + headline: "A branch's tagged versions are not removed", + explanation: "The branch ended, and the registry seats did not say removed for every version tagged for it within 15 minutes.", + resolved: "Every seat said removed.", + needs: "Look at the seats named: " + strings.Join(end.Seats(), ", ")}) + } + h.logf("the end of %s %s: remove asked of %d tagged versions, heard again in a minute", f.Repository, f.Branch, len(end.Left)) + h.again(m) +} + +func (h *branchEnds) ack(m jetstream.Msg) { + if err := m.Ack(); err != nil { + h.logf("acknowledging %s: %v", m.Subject(), err) + } +} + +// again leaves the message for the bus to give again in a minute: the retry is the bus's redelivery, not a timer here. +func (h *branchEnds) again(m jetstream.Msg) { + if err := m.NakWithDelay(versions.RemoveAgainAfter); err != nil { + h.logf("leaving %s for the bus to give again: %v", m.Subject(), err) + } +} + +// tagWanted takes the checker's tag-wanted in: written to tag-asks as builder_tag writes it, answered by its watch. +func tagWanted(ctx context.Context, kv jetstream.KeyValue, m jetstream.Msg, by string, now time.Time, logf func(string, ...any)) { + var t versions.TagWanted + err := json.Unmarshal(m.Data(), &t) + var key string + var value []byte + if err == nil { + key, value, err = versions.TagWantedAsk(t, by, now) + } + if err != nil { + logf("%s holds what is no tag-wanted, left: %v", m.Subject(), err) + _ = m.Ack() + return + } + if kv == nil { + logf("tag-wanted of %s %s is heard again: %s is not on the bus yet", t.Repository, t.Branch, versions.TagAsksState) + _ = m.NakWithDelay(versions.RemoveAgainAfter) + return + } + if _, err := kv.Put(ctx, key, value); err != nil { + logf("tag-wanted of %s %s is heard again: %v", t.Repository, t.Branch, err) + _ = m.Nak() + return + } + _ = m.Ack() +} diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/findings.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/findings.go new file mode 100644 index 00000000..4b1d4536 --- /dev/null +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/findings.go @@ -0,0 +1,82 @@ +package main + +import ( + "context" + "errors" + "time" + + "github.com/nats-io/nats.go/jetstream" + + "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/health" + "git.novox.be/novox/mesh-sdk/go/process" +) + +// findingsBucket is the builder's findings (novox/hq ADR 0564 ยง2): what it sees wrong now, written at its raise and +// deleted at its clear, which the condition keeper raises and clears. +var findingsBucket = health.FindingsBucket(delivery.BuilderModule) + +// findings writes the builder's findings into its state, once bound; until then each is said in the log only. +type findings struct { + kv *process.AwaitedBucket + logf func(string, ...any) + now func() time.Time +} + +// finding is one of the builder's findings: its kind, what it is about, and its words. +type finding struct { + kind, scope, id string + summary, headline, explanation, resolved, needs string + severity health.Severity +} + +func (f *findings) bucket() jetstream.KeyValue { + if f == nil || f.kv == nil { + return nil + } + return f.kv.KV() +} + +// raise writes fd; it is written again only when its words changed, so the keeper's condition moves only then. +func (f *findings) raise(ctx context.Context, fd finding) { + key := health.FindingKey(fd.scope, fd.id, fd.kind) + kv := f.bucket() + if kv == nil { + f.logf("finding %s (not written, %s is not on the bus yet): %s", key, findingsBucket, fd.summary) + return + } + if e, err := kv.Get(ctx, key); err == nil { + if old, err := health.DecodeFinding(e.Value()); err == nil && old.Summary == fd.summary { + return + } + } + at := f.now().UTC() + sev := fd.severity + if sev == "" { + sev = health.Warning + } + value, err := health.EncodeFinding(health.Finding{Key: key, Kind: fd.kind, Subject: health.Subject{Scope: fd.scope, ID: fd.id}, + Severity: sev, Summary: fd.summary, Headline: fd.headline, Explanation: fd.explanation, Resolved: fd.resolved, Needs: fd.needs, + Evidence: []health.Evidence{{At: at, Said: fd.summary}}, Source: "builder." + fd.kind, Observer: delivery.BuilderModule, At: at}) + if err == nil { + _, err = kv.Put(ctx, key, value) + } + if err != nil { + f.logf("the finding %s cannot be written: %v", key, err) + } +} + +// clear deletes the finding of kind about scope and id; one not there is nothing to clear. +func (f *findings) clear(ctx context.Context, kind, scope, id string) { + kv := f.bucket() + if kv == nil { + return + } + key := health.FindingKey(scope, id, kind) + if _, err := kv.Get(ctx, key); errors.Is(err, jetstream.ErrKeyNotFound) { + return + } + if err := kv.Delete(ctx, key); err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) { + f.logf("the finding %s cannot be cleared: %v", key, err) + } +} diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go index 0ef44baa..2cf8ea8f 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go @@ -292,7 +292,8 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro }) // A tag ask or a branch's removal by hand (builder_tag, builder_untag) is written to tag-asks and answered there // (design 51 *Tagged versions*); the checker's tag-wanted and the branch's end join it on their consumers (DLV-B6). - process.AwaitBucket(ctx, js, conn, versions.TagAsksBucket, "builder_tag and builder_untag are not answered", logger.Printf).Ready(func(kv jetstream.KeyValue) { + tagAsksKV := process.AwaitBucket(ctx, js, conn, versions.TagAsksBucket, "builder_tag and builder_untag are not answered", logger.Printf) + tagAsksKV.Ready(func(kv jetstream.KeyValue) { each := func(e jetstream.KeyValueEntry) { tagAsked(ctx, acts, kv, e, logger.Printf) } if tw, err := settle(ctx, kv, each); err != nil { process.NotWatched(logger.Printf, versions.TagAsksBucket, err, "builder_tag and builder_untag are not answered until the builder starts again") @@ -310,6 +311,34 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro go func() { errs <- follow(ctx, qw, eachRead) }() } }) + // A branch's end removes its tagged versions (design 51 *Tagged versions*, DLV-B6): the forge bridge's merged, a pull's + // closed or branch-deleted on the builder's durable consumer of its stream; each forge message also wakes a floor whose + // bridge_tags was silent. The checker's tag-wanted is kept in tag-asks. A consumer not there yet is said. + found := &findings{kv: process.AwaitBucket(ctx, js, conn, findingsBucket, "the builder's findings are said in its log only", logger.Printf), logf: logger.Printf, now: time.Now} + ends := &branchEnds{acts: acts, findings: found, tagAsks: tagAsksKV.KV, logf: logger.Printf, now: time.Now, + wake: func(ctx context.Context) { + (&floorWake{b: b, waits: floorWaits, logf: logger.Printf}).wake(ctx, versions.WakeForge, "", "") + }} + for _, c := range []struct { + stream, what string + hear func(jetstream.Msg) + }{ + {forgeStream, "a branch's end", func(m jetstream.Msg) { ends.heard(ctx, m) }}, + {checkerAsksStream, "the checker's tag-wanted", func(m jetstream.Msg) { + tagWanted(ctx, tagAsksKV.KV(), m, delivery.CheckerModule, time.Now(), logger.Printf) + }}, + } { + cons, err := cred.EventConsumer(ctx, js, c.stream) + if err != nil { + logger.Printf("the consumer %s_%s on %s does not exist yet, so %s is not heard until the builder starts again: %v", cred.Node, cred.Module, c.stream, c.what, err) + continue + } + cc, err := process.HearEvents(cons, c.hear, logger.Printf) + if err != nil { + return err + } + defer cc.Stop() + } logger.Printf("started on %s", cred.Node) go func() { errs <- follow(ctx, w, each) }() select { diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/refused_test.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/refused_test.go index 01bdd7a0..43a26a25 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/refused_test.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/refused_test.go @@ -27,6 +27,7 @@ import ( // issue 774). func TestARefusedBucketIsSaidAndThePartGoesOn(t *testing.T) { t.Run("a floor's wake is heard on the events consumer (design 51 The floor)", aFloorsWakeIsHeard) + t.Run("a branch's end is acknowledged once its tagged versions are removed (design 51 Tagged versions)", aBranchsEndIsHeard) set := &buildrun.ToolchainSet{} var logs bytes.Buffer logger := log.New(&logs, "", 0) @@ -85,12 +86,20 @@ type wakeMsg struct { data []byte acked bool naked bool + delayed time.Duration } func (m *wakeMsg) Subject() string { return m.subject } func (m *wakeMsg) Data() []byte { return m.data } func (m *wakeMsg) Ack() error { m.acked = true; return nil } func (m *wakeMsg) Nak() error { m.naked = true; return nil } +func (m *wakeMsg) NakWithDelay(d time.Duration) error { + m.delayed = d + return nil +} +func (m *wakeMsg) Metadata() (*jetstream.MsgMetadata, error) { + return nil, errors.New("no metadata") +} func (m *wakeMsg) Headers() nats.Header { return nil } @@ -144,3 +153,44 @@ func (noVersions) Update(context.Context, string, []byte, uint64) (uint64, error return 0, errors.New("read only") } func (noVersions) KeysOf(context.Context, string) ([]string, error) { return nil, nil } + +func aBranchsEndIsHeard(t *testing.T) { + for _, c := range []struct { + f forgeEnd + want bool + }{ + {forgeEnd{Kind: delivery.ForgeMerged, Repository: "o/r", Branch: "feat/x"}, true}, + {forgeEnd{Kind: forgeBranchDeleted, Repository: "o/r", Branch: "feat/x"}, true}, + {forgeEnd{Kind: delivery.ForgePull, Repository: "o/r", Branch: "feat/x", Action: delivery.PullClosed}, true}, + {forgeEnd{Kind: delivery.ForgePull, Repository: "o/r", Branch: "feat/x", Action: delivery.PullOpened}, false}, + {forgeEnd{Kind: delivery.ForgeMerged, Repository: "o/r"}, false}, + } { + if got := c.f.ended(); got != c.want { + t.Errorf("%+v ended = %v, want %v", c.f, got, c.want) + } + } + var logs bytes.Buffer + woken := 0 + h := &branchEnds{acts: versions.Acts{Numberer: &versions.Numberer{Versions: versions.Versions{Store: noVersions{}}}}, findings: &findings{logf: t.Logf}, + tagAsks: func() jetstream.KeyValue { return nil }, wake: func(context.Context) { woken++ }, logf: log.New(&logs, "", 0).Printf, now: time.Now} + open := &wakeMsg{subject: "mesh.mod.mesh-delivery-forge-bridge.event.forge.o.r.pull", data: []byte(`{"kind":"pull","repository":"o/r","branch":"feat/x","action":"opened"}`)} + h.heard(context.Background(), open) + merged := &wakeMsg{subject: "mesh.mod.mesh-delivery-forge-bridge.event.forge.o.r.merged", data: []byte(`{"kind":"merged","repository":"o/r","branch":"feat/x"}`)} + h.heard(context.Background(), merged) + if !open.acked || !merged.acked || woken != 2 { + t.Errorf("an open pull acked %v, a merge with nothing tagged acked %v, floors woken %d times", open.acked, merged.acked, woken) + } + unreadable := &wakeMsg{subject: merged.subject, data: merged.data} + h.acts.Numberer.Versions.Store = failingVersions{} + h.heard(context.Background(), unreadable) + if unreadable.acked || unreadable.delayed != versions.RemoveAgainAfter { + t.Errorf("a branch's end the versions cannot answer: acked %v, given again after %v", unreadable.acked, unreadable.delayed) + } +} + +// failingVersions is a state versions that cannot be read. +type failingVersions struct{ noVersions } + +func (failingVersions) KeysOf(context.Context, string) ([]string, error) { + return nil, errors.New("down") +} diff --git a/modules/mesh-delivery-builder/internal/versions/branchend.go b/modules/mesh-delivery-builder/internal/versions/branchend.go new file mode 100644 index 00000000..c2be5eff --- /dev/null +++ b/modules/mesh-delivery-builder/internal/versions/branchend.go @@ -0,0 +1,89 @@ +package versions + +import ( + "context" + "fmt" + "sort" + "strings" + "time" +) + +// A branch's end removes its tagged versions (design 51 *Tagged versions*, DLV-B6): on the forge bridge's `merged`, a +// pull's `closed` or `branch-deleted`, heard on the builder's durable consumer of the stream `forge`, the builder calls +// each seat's `remove` and acknowledges the message only once every seat said `removed` (Heard marks the entry); until +// then the bus gives it again each minute (RemoveAgainAfter) and, past RemovalFindingAfter, the builder's finding names +// the seats still owing. +const ( + RemoveAgainAfter = time.Minute + RemovalFindingAfter = 15 * time.Minute + // RemovalNotDone is the finding's kind. + RemovalNotDone = "tag-removal-not-done" +) + +// BranchEnd is what one hearing of a branch's end did: the versions still not removed (name@version to its seat, with +// the error of a remove that failed), and whether all are gone so the message is acknowledged. +type BranchEnd struct { + Left map[string]string + Done bool +} + +// Seats are the seats still owing a removal, sorted, each once. +func (b BranchEnd) Seats() []string { + seen := map[string]bool{} + var out []string + for _, s := range b.Left { + seat, _, _ := strings.Cut(s, ":") + if !seen[seat] { + seen[seat] = true + out = append(out, seat) + } + } + sort.Strings(out) + return out +} + +// Finding is the removal finding's words: what is left, since when. +func (b BranchEnd) Finding(repository, branch string, since time.Time) string { + var at []string + for v, s := range b.Left { + at = append(at, v+" on "+s) + } + sort.Strings(at) + return fmt.Sprintf("the tagged versions of %s %s are not all removed since its end at %s: %s; the builder asks remove again each minute", + repository, branch, since.UTC().Format(time.RFC3339), strings.Join(at, ", ")) +} + +// BranchEnded removes repository's tagged versions of branch: every one not yet removed is asked of its seat again. +// A store that cannot be read is the error: nothing is acknowledged then. +func (a Acts) BranchEnded(ctx context.Context, repository, branch string) (BranchEnd, error) { + all, err := a.Numberer.Versions.All(ctx) + if err != nil { + return BranchEnd{}, err + } + left := map[string]string{} + for _, e := range Untagged(all, repository, branch) { + at := e.Name + "@" + e.Version + left[at] = e.Seat + if a.Remove == nil { + continue + } + if err := a.Remove(ctx, e.Seat, e); err != nil { + left[at] = e.Seat + ": " + err.Error() + } + } + return BranchEnd{Left: left, Done: len(left) == 0}, nil +} + +// TagWanted is the body of the checker's `tag-wanted` (design 51 *Tagged versions*): a group member's head to number +// tagged versions for. +type TagWanted struct { + Repository string `json:"repository"` + Branch string `json:"branch"` + Head string `json:"head"` + Tag string `json:"tag,omitempty"` +} + +// TagWantedAsk is the tag-asks value a tag-wanted becomes, as builder_tag writes it by hand: by the checker. +func TagWantedAsk(t TagWanted, by string, at time.Time) (string, []byte, error) { + return NewTagAsk(t.Repository, t.Branch, t.Head, "the checker's tag-wanted for a group member's check", by, at) +} diff --git a/modules/mesh-delivery-builder/internal/versions/versions_test.go b/modules/mesh-delivery-builder/internal/versions/versions_test.go index 34870e74..3c8ad538 100644 --- a/modules/mesh-delivery-builder/internal/versions/versions_test.go +++ b/modules/mesh-delivery-builder/internal/versions/versions_test.go @@ -584,5 +584,22 @@ func TestTheBuildersVersions(t *testing.T) { if k := acts.TagAsked(context.Background(), a, at); k.State != TagAsked || k.Answer == "" { t.Errorf("a tag ask is not kept: %+v", k) } + // DLV-B6: a branch's end asks remove of every tagged version not removed, and is done once each seat said removed. + removed = nil + end, err := acts.BranchEnded(context.Background(), "o/r", "feat/x") + if err != nil || end.Done || len(removed) != 2 || !reflect.DeepEqual(end.Seats(), []string{"", SeatNPM}) || !strings.Contains(end.Finding("o/r", "feat/x", at), "n3@0.1.0-feat-x.1 on npm-package-registry: down") { + t.Fatalf("BranchEnded = %+v %v, removed %v", end, err, removed) + } + for _, v := range [][2]string{{"n", "0.1.2-feat-x.1"}, {"n3", "0.1.0-feat-x.1"}} { + if _, err := acts.Numberer.Heard(context.Background(), SeatNPM, HeardRemoved, "rm", Outcome{Name: v[0], Version: v[1], Reason: "tag-gone"}); err != nil { + t.Fatal(err) + } + } + if end, _ := acts.BranchEnded(context.Background(), "o/r", "feat/x"); !end.Done { + t.Errorf("every seat said removed, and the branch's end is not done: %+v", end) + } + if k, v, err := TagWantedAsk(TagWanted{Repository: "o/r", Branch: "feat/x", Head: "abc"}, "mesh-delivery-checker", at); err != nil || k != TagAskKey("o/r", "feat/x") || !strings.Contains(string(v), `"by":"mesh-delivery-checker"`) { + t.Errorf("TagWantedAsk = %q %s %v", k, v, err) + } }) } diff --git a/modules/mesh-delivery-builder/manifest_test.go b/modules/mesh-delivery-builder/manifest_test.go index f0ec38cc..5e5b03e6 100644 --- a/modules/mesh-delivery-builder/manifest_test.go +++ b/modules/mesh-delivery-builder/manifest_test.go @@ -93,7 +93,7 @@ func TestManifestDeclarations(t *testing.T) { // 1607/AC1 if m.Module != "mesh-delivery-builder" { t.Errorf("module = %q", m.Module) } - if want := []state{{Name: "builds", History: 5}, {Name: "floor-waits", History: 1}, {Name: "hearing", History: 1}, {Name: "instances", History: 1}, {Name: "queues", History: 1}, {Name: "reasks", History: 1}, {Name: "tag-asks", History: 1}, {Name: "versions", History: 1}}; !reflect.DeepEqual(m.State, want) { // delivery.BuilderBuildsHistory + if want := []state{{Name: "builds", History: 5}, {Name: "findings", History: 5}, {Name: "floor-waits", History: 1}, {Name: "hearing", History: 1}, {Name: "instances", History: 1}, {Name: "queues", History: 1}, {Name: "reasks", History: 1}, {Name: "tag-asks", History: 1}, {Name: "versions", History: 1}}; !reflect.DeepEqual(m.State, want) { // delivery.BuilderBuildsHistory t.Errorf("state = %+v, want %+v", m.State, want) } // 1887/A4c: the builder chooses no node, so it reads neither presence nor have, invokes no build module's tool and @@ -122,7 +122,7 @@ func TestManifestDeclarations(t *testing.T) { // 1607/AC1 if want := []string{"container-image-registry.published", "container-image-registry.refused", "container-image-registry.removed", "go-package-registry.published", "go-package-registry.refused", "go-package-registry.removed", "mesh-artifact-sweep.artifacts-gone", "mesh-build-go.toolchain.reported", "mesh-build-node.toolchain.reported", - "mesh-conditions.condition-cleared", "mesh-delivery-indexer.manifest-accepted", + "mesh-conditions.condition-cleared", "mesh-delivery-checker.tag-wanted", "mesh-delivery-forge-bridge.forge.**", "mesh-delivery-indexer.manifest-accepted", "npm-package-registry.published", "npm-package-registry.refused", "npm-package-registry.removed", "oci-artifact-registry.published", "oci-artifact-registry.refused", "oci-artifact-registry.removed"}; !reflect.DeepEqual(sorted(m.Consumes), want) { t.Errorf("consumes = %v, want %v", m.Consumes, want) diff --git a/modules/mesh-delivery-builder/module.json b/modules/mesh-delivery-builder/module.json index 2f48305a..f7392a4e 100644 --- a/modules/mesh-delivery-builder/module.json +++ b/modules/mesh-delivery-builder/module.json @@ -33,6 +33,8 @@ "mesh-build-go.toolchain.reported", "mesh-build-node.toolchain.reported", "mesh-conditions.condition-cleared", + "mesh-delivery-checker.tag-wanted", + "mesh-delivery-forge-bridge.forge.**", "mesh-delivery-indexer.manifest-accepted", "npm-package-registry.published", "npm-package-registry.refused", @@ -56,6 +58,7 @@ ], "state": [ { "name": "builds", "history": 5 }, + { "name": "findings", "history": 5 }, { "name": "floor-waits", "history": 1 }, { "name": "hearing", "history": 1 }, { "name": "instances", "history": 1 },