Merge branch 'feat/47-G-build' into feat/47-G-build-assigner
This commit is contained in:
@@ -142,6 +142,37 @@ func TestGateKeysAsGoDeliveryHadThem(t *testing.T) {
|
||||
t.Errorf("%q reads as a switched act's id", other)
|
||||
}
|
||||
}
|
||||
// A switched act's id carries its state, key and revision, and reads back; any other id is not one.
|
||||
for _, c := range []struct {
|
||||
state, key string
|
||||
rev uint64
|
||||
id string
|
||||
}{
|
||||
{SwitchedOwed, "novox.app.7.0123abcd.status=mesh/delivery", 4, "switched.owed.4.novox.app.7.0123abcd.status=mesh/delivery"},
|
||||
{SwitchedMerges, "release-g", 12, "switched.merges.12.release-g"},
|
||||
} {
|
||||
id, err := SwitchedActID(c.state, c.key, c.rev)
|
||||
if err != nil || id != c.id {
|
||||
t.Errorf("SwitchedActID(%q, %q, %d) = %q, %v; want %q", c.state, c.key, c.rev, id, err, c.id)
|
||||
}
|
||||
if s, k, r, ok := ParseSwitchedActID(id); !ok || s != c.state || k != c.key || r != c.rev {
|
||||
t.Errorf("ParseSwitchedActID(%q) = %q %q %d %v", id, s, k, r, ok)
|
||||
}
|
||||
}
|
||||
for _, bad := range []struct {
|
||||
state, key string
|
||||
rev uint64
|
||||
}{{"turns", "k", 1}, {SwitchedOwed, "", 1}, {SwitchedOwed, "a@b", 1}, {SwitchedMerges, "k", 0}} {
|
||||
if id, err := SwitchedActID(bad.state, bad.key, bad.rev); err == nil {
|
||||
t.Errorf("SwitchedActID(%+v) = %q, no error", bad, id)
|
||||
}
|
||||
}
|
||||
for _, other := range []string{"novox.app.7.0123abcd.view@1700000000", "switched.turns.1.k", "switched.owed.x.k",
|
||||
"switched.owed.0.k", "switched.owed.3", "switched.owed.3.k@1"} {
|
||||
if _, _, _, ok := ParseSwitchedActID(other); ok {
|
||||
t.Errorf("%q reads as a switched act's id", other)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGateValuesRefuseWhatTheirRulesRefuse(t *testing.T) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Command mesh-delivery-forge-bridge-tools serves the forge bridge's tools (novox/hq ADR 0633 §4, issue 1585; design 51):
|
||||
// the runtime launches it for a call. bridge_tags reads a package's git tags from the forge through the forge module's
|
||||
// gitea_api, for the builder's floor. bridge_groups reads the delivery groups the gate holds and what the bridge kept of their ends;
|
||||
// the runtime launches it for a call. bridge_tags reads a package's git tags through the seat git's verb tags, for the
|
||||
// builder's floor. bridge_groups reads the delivery groups the gate holds and what the bridge kept of their ends;
|
||||
// bridge_group_end_again writes an ask into the bridge's state end-asks and waits for the bridge's answer there, since the
|
||||
// bridge is the other program and serves no request subject (ADR 0437 §4).
|
||||
package main
|
||||
@@ -9,17 +9,13 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient"
|
||||
seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/trunk"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
@@ -28,9 +24,6 @@ import (
|
||||
"mesh-delivery-forge-bridge/internal/groups"
|
||||
)
|
||||
|
||||
// gateMergesState is the gate's state merges, which the tools read (reads: mesh-delivery-gate.merges in the manifest).
|
||||
const gateMergesState = "mesh-delivery-gate.merges"
|
||||
|
||||
// Tool names.
|
||||
const (
|
||||
GroupsTool = "bridge_groups"
|
||||
@@ -42,54 +35,49 @@ const (
|
||||
// asker writes an end-asks value and waits for the bridge's answer to it.
|
||||
type asker func(key string, value []byte) (groups.EndAsk, error)
|
||||
|
||||
// Tools are the bridge's tools: groupsRead reads the groups the gate holds with what the bridge kept of them.
|
||||
func Tools(ask asker, now func() time.Time, groupsRead func() ([]groups.Row, []string, error), merged func(repository, merge string, number int) (json.RawMessage, bool, error), api bridge.API) []stdio.Tool {
|
||||
// Tools are the bridge's tools: groupsRead reads the seat mesh-delivery's groups with what the bridge kept of them.
|
||||
func Tools(ask asker, now func() time.Time, groupsRead func() ([]groups.Row, []string, error), merged func(repository, merge string, number int) (json.RawMessage, error), forge bridge.Caller) []stdio.Tool {
|
||||
str := func(args map[string]any, k string) string { s, _ := args[k].(string); return strings.TrimSpace(s) }
|
||||
return []stdio.Tool{
|
||||
{Name: bridge.TagsTool,
|
||||
Description: "A package's git tags on the forge, for the builder's floor (design 51): every tag of repository whose name starts " +
|
||||
"with prefix (go/v for a Go package in the go directory, v for one at the root), sorted. Read-only; read from the forge " +
|
||||
"through the forge module's gitea_api, at most " + fmt.Sprint(bridge.TagsMaxPages*bridge.TagsPageSize) + " tags (more says so).",
|
||||
"with prefix (go/v for a Go package in the go directory, v for one at the root), sorted. Read-only; read through the seat " +
|
||||
"git's verb tags, at most " + fmt.Sprint(bridge.TagsMaxPages) + " pages (more says so).",
|
||||
Input: map[string]any{"type": "object", "required": []string{"repository"}, "properties": map[string]any{
|
||||
"repository": map[string]any{"type": "string", "description": "<owner>/<name>"},
|
||||
"prefix": map[string]any{"type": "string", "description": "only tags starting with it"}}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
return bridge.Tags(api, str(args, "repository"), str(args, "prefix"))
|
||||
return bridge.Tags(context.Background(), forge, str(args, "repository"), str(args, "prefix"))
|
||||
}},
|
||||
{Name: MergedTool,
|
||||
Description: "The merged message of one merge, asked again (issue 1585): the message the bridge wrote on its stream FORGE when the " +
|
||||
"pull request was merged into the trunk, built the same way from the forge (the merge commit's trees and the manifests " +
|
||||
"and lock files of its module directories) and from the gate's merges state (the pull request's number, head and " +
|
||||
"group). For the indexer, which asks it when it takes in a held merge it does not hold in memory (a restart, a new " +
|
||||
"and lock files of its module directories), the pull request's number, branch and head from the seat git's verb pull, and " +
|
||||
"for a delivery group's member its group and members from the seat mesh-delivery's verb groups. For the indexer, which asks it when it takes in a held merge it does not hold in memory (a restart, a new " +
|
||||
"build), and the ask-again a lost message lacked. About 0.5 MB for a repository of many modules. Answers {message} " +
|
||||
"with the forge message whole, or why it cannot: neither the gate nor the forge gives a merged pull request with that commit, or the forge did not answer. " +
|
||||
"A merge made on the forge (a recovery merge, the operator's own) has no merge in the gate: the bridge reads that pull request from the forge, by number when given, and says made_on_forge.",
|
||||
"with the forge message whole, or why it cannot: the forge gives no merged pull request with that commit, or a seat did not answer. " +
|
||||
"A merge made on the forge (a recovery merge, the operator's own) is read the same way.",
|
||||
Input: map[string]any{"type": "object", "required": []string{"repository", "merge"}, "properties": map[string]any{
|
||||
"repository": map[string]any{"type": "string", "description": "the repository, owner/name, as the forge names it"},
|
||||
"merge": map[string]any{"type": "string", "description": "the merge commit, 40 hex digits"},
|
||||
"number": map[string]any{"type": "integer", "description": "the pull request's number, when known: for a merge made on the forge the bridge reads that pull request and checks its merge commit"}}},
|
||||
"number": map[string]any{"type": "integer", "description": "the pull request's number, when known: the bridge reads that pull request and checks its merge commit"}}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
number, _ := args["number"].(float64)
|
||||
raw, onForge, err := merged(str(args, "repository"), str(args, "merge"), int(number))
|
||||
raw, err := merged(str(args, "repository"), str(args, "merge"), int(number))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]any{"message": raw}
|
||||
if onForge {
|
||||
out["made_on_forge"] = true
|
||||
out["note"] = "the merge was made on the forge, not through the gate: its pull request was read from the forge"
|
||||
fmt.Fprintf(os.Stderr, "[mesh-delivery-forge-bridge-tools] merged message of %s at %s answered from the forge: the merge was made on the forge, not through the gate\n", str(args, "repository"), str(args, "merge"))
|
||||
}
|
||||
return out, nil
|
||||
return map[string]any{"message": raw}, nil
|
||||
}},
|
||||
{Name: GroupsTool,
|
||||
Description: "The delivery groups the gate holds (more than one member), each with its members' merge state and whether each " +
|
||||
"merged message is written on the forge stream, and whether the group's end (forge.group.merged) is written: ended, " +
|
||||
"or open with why (gathered, or merged and its end not written yet). An open group with every member merged is " +
|
||||
Description: "The delivery groups the seat mesh-delivery's verb groups answers (more than one member), each with its members' delivery " +
|
||||
"state and whether each merged message is written on the forge stream (with the merge commit it named), and whether the " +
|
||||
"group's end (forge.group.merged) is written: ended, or open with why (gathered, or every message written and its end " +
|
||||
"not written yet). An open group with every member's message written is " +
|
||||
"what bridge_group_end_again says the end of. Each group names its last end ask (who, why, when) and the bridge's " +
|
||||
"answer to it, from the state end-asks. A group's name narrows it to that group; open lists only open ones.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{
|
||||
"group": map[string]any{"type": "string", "description": "only this group, by the name the gate keys it"},
|
||||
"group": map[string]any{"type": "string", "description": "only this group, by its id (its branch)"},
|
||||
"open": map[string]any{"type": "boolean", "description": "only the groups whose end is not written"}}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
rows, unread, err := groupsRead()
|
||||
@@ -110,11 +98,11 @@ func Tools(ask asker, now func() time.Time, groupsRead func() ([]groups.Row, []s
|
||||
{Name: EndAgainTool,
|
||||
Description: "Say a delivery group's end (forge.group.merged) again, by hand, for a group whose end the indexer did not take " +
|
||||
"in (a group bridge_groups names as merged with its end not written, or one the indexer waits for). The bridge " +
|
||||
"writes it again from the gate's merges, with the time appended to the message id so the stream does not drop it as " +
|
||||
"writes it again from the seat mesh-delivery's groups, with the time appended to the message id so the stream does not drop it as " +
|
||||
"a duplicate; the indexer takes an end for a group it already took in without changing anything. Refused while " +
|
||||
"a member is not merged or its merged message is not written. Answers whether it was written.",
|
||||
"a member's merged message is not written. Answers whether it was written.",
|
||||
Input: map[string]any{"type": "object", "required": []string{"group"}, "properties": map[string]any{
|
||||
"group": map[string]any{"type": "string", "description": "the group, by the name the gate keys it"},
|
||||
"group": map[string]any{"type": "string", "description": "the group, by its id (its branch)"},
|
||||
"why": map[string]any{"type": "string", "description": "why its end is said again, in plain words"}}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
key, value, err := groups.NewEndAsk(str(args, "group"), str(args, "why"), stdio.CallerOf(args), now())
|
||||
@@ -131,87 +119,50 @@ func Tools(ask asker, now func() time.Time, groupsRead func() ([]groups.Row, []s
|
||||
}
|
||||
|
||||
// MergedOf is the merged message of the merge of repository at commit merge, as the bridge writes it: the pull request's
|
||||
// number, head and group from the gate's merges (raw, by key), the rest from the forge through trees and files
|
||||
// (bridge.MergedMessage), and, for a member of a delivery group, the group and its members as the process sets them.
|
||||
//
|
||||
// When the gate holds no merge with that commit, the merge was made on the forge (a recovery merge, the operator's own):
|
||||
// the pull request is read from the forge through api (by number when given, else by the merge commit), the message is
|
||||
// built the same way, and madeOnForge is true, for the answer and its log line to say. It is a lone pull request: no
|
||||
// group is set.
|
||||
func MergedOf(raws map[string][]byte, repository, merge string, number int, trees bridge.Trees, files bridge.Files, api bridge.API) (out json.RawMessage, madeOnForge bool, err error) {
|
||||
// number, branch and head through the seat git's verb pull (by number when given, else by the merge commit; a merge made
|
||||
// on the forge is read the same way), the rest from the forge through trees and files (bridge.MergedMessage), and, for a
|
||||
// member of a delivery group the seat mesh-delivery names (of), the group and its members as the process sets them.
|
||||
func MergedOf(of []seat.Group, repository, merge string, number int, trees bridge.Trees, files bridge.Files, forge bridge.Caller) (json.RawMessage, error) {
|
||||
owner, repo, ok := strings.Cut(repository, "/")
|
||||
if !ok || owner == "" || repo == "" {
|
||||
return nil, false, fmt.Errorf("%q is not owner/name", repository)
|
||||
return nil, fmt.Errorf("%q is not owner/name", repository)
|
||||
}
|
||||
for _, key := range slices.Sorted(maps.Keys(raws)) {
|
||||
m, err := meshdeliveryclient.DecodeMerge(raws[key])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
at := slices.IndexFunc(m.Pulls, func(p seat.MergesByGroupValuePullsItem) bool {
|
||||
return trunk.SameRepository(p.Repository, repository) && p.Commit == merge
|
||||
})
|
||||
if at < 0 {
|
||||
continue
|
||||
}
|
||||
p := m.Pulls[at]
|
||||
msg, err := bridge.MergedMessage(owner, repo, int(p.Number), m.Group, p.Head, merge, trees, files)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("the merged message of %s at %s cannot be built: %w", repository, merge, err)
|
||||
}
|
||||
f, err := forgemsg.DecodeForge(msg.Data)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if _, approved := m.Approved[meshdeliveryclient.ApprovedKey(f.Repository, f.Number)]; approved && len(m.Approved) >= 2 {
|
||||
f.Group, f.Members = m.Group, groups.MembersOf(m)
|
||||
}
|
||||
out, err = forgemsg.EncodeForge(f)
|
||||
return out, false, err
|
||||
}
|
||||
if api == nil {
|
||||
return nil, false, fmt.Errorf("the gate's merges hold no merge of %s with the commit %s, so its pull request is not known", repository, merge)
|
||||
}
|
||||
fp, err := bridge.ForgeMade(api, owner, repo, number, merge)
|
||||
fp, err := bridge.ForgeMade(context.Background(), forge, owner, repo, number, merge)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("the gate's merges hold no merge of %s with the commit %s, and the forge does not give it as a merged pull request either: %w", repository, merge, err)
|
||||
return nil, fmt.Errorf("the forge gives no merged pull request of %s with the commit %s: %w", repository, merge, err)
|
||||
}
|
||||
msg, err := bridge.MergedMessage(owner, repo, fp.Number, fp.Branch, fp.Head, merge, trees, files)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("the merged message of %s at %s (a merge made on the forge) cannot be built: %w", repository, merge, err)
|
||||
return nil, fmt.Errorf("the merged message of %s at %s cannot be built: %w", repository, merge, err)
|
||||
}
|
||||
return msg.Data, true, nil
|
||||
f, err := forgemsg.DecodeForge(msg.Data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if g, ok := groups.Named(of, f.Branch, f.Repository, f.Number); ok {
|
||||
f.Group, f.Members = g.ID, groups.MembersOf(g)
|
||||
}
|
||||
return forgemsg.EncodeForge(f)
|
||||
}
|
||||
|
||||
// read is the groups the gate holds with what the bridge kept of them: their ends in performed, and the last ask of
|
||||
// each in end-asks with the bridge's answer.
|
||||
func read(merges, performed, asks stdio.KeptState) ([]groups.Row, []string, error) {
|
||||
keys, err := merges.Keys()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("the gate's merges cannot be listed: %w", err)
|
||||
}
|
||||
raws := map[string][]byte{}
|
||||
for _, k := range keys {
|
||||
e, err := merges.Get(k)
|
||||
if err != nil || e == nil {
|
||||
continue
|
||||
}
|
||||
raws[k] = e.Value
|
||||
}
|
||||
// read is the groups the seat mesh-delivery answers with what the bridge kept of them: the merged messages written and
|
||||
// their ends in performed, and the last ask of each in end-asks with the bridge's answer.
|
||||
func read(of []seat.Group, performed, asks stdio.KeptState) ([]groups.Row, []string, error) {
|
||||
pkeys, err := performed.Keys()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("the bridge's performed state cannot be listed: %w", err)
|
||||
}
|
||||
published, ended := map[string]bool{}, map[string]uint64{}
|
||||
published, ended := map[string]string{}, map[string]string{}
|
||||
for _, k := range pkeys {
|
||||
into := ended
|
||||
switch {
|
||||
case strings.HasPrefix(k, "published."):
|
||||
published[k] = true
|
||||
case strings.HasPrefix(k, "groups."):
|
||||
if e, err := performed.Get(k); err == nil && e != nil {
|
||||
rev, _ := strconv.ParseUint(strings.TrimSpace(string(e.Value)), 10, 64)
|
||||
ended[strings.TrimPrefix(k, "groups.")] = rev
|
||||
}
|
||||
into = published
|
||||
case !strings.HasPrefix(k, "groups."):
|
||||
continue
|
||||
}
|
||||
if e, err := performed.Get(k); err == nil && e != nil {
|
||||
into[k] = strings.TrimSpace(string(e.Value))
|
||||
}
|
||||
}
|
||||
akeys, err := asks.Keys()
|
||||
@@ -224,28 +175,39 @@ func read(merges, performed, asks stdio.KeptState) ([]groups.Row, []string, erro
|
||||
askRaws[k] = e.Value
|
||||
}
|
||||
}
|
||||
rows, unread := groups.Rows(raws, published, ended, askRaws)
|
||||
rows, unread := groups.Rows(of, published, ended, askRaws)
|
||||
return rows, unread, nil
|
||||
}
|
||||
|
||||
// askEnd writes the end-asks value at key and waits, on a watch of the key, at most bound for the bridge's answer.
|
||||
// askEnd writes the end-asks value at key and waits at most bound for the bridge's answer.
|
||||
func askEnd(s stdio.KeptState, bound time.Duration) asker {
|
||||
wait := askState(s, groups.EndAsksBucket, bound, func(v []byte) bool { a, err := groups.DecodeEndAsk(v); return err == nil && a.Done() })
|
||||
return func(key string, value []byte) (groups.EndAsk, error) {
|
||||
answered := make(chan groups.EndAsk, 1)
|
||||
v, err := wait(key, value)
|
||||
if err != nil {
|
||||
return groups.EndAsk{}, err
|
||||
}
|
||||
return groups.DecodeEndAsk(v)
|
||||
}
|
||||
}
|
||||
|
||||
// askState writes an ask's value at key of the state s (its bucket named bucket) and waits, on a watch of the key, at most
|
||||
// bound for the bridge's answer, a later value that done says is answered.
|
||||
func askState(s stdio.KeptState, bucket string, bound time.Duration, done func([]byte) bool) func(key string, value []byte) ([]byte, error) {
|
||||
return func(key string, value []byte) ([]byte, error) {
|
||||
answered := make(chan []byte, 1)
|
||||
var rev uint64
|
||||
var mu sync.Mutex
|
||||
take := func(revision uint64, v []byte) {
|
||||
mu.Lock()
|
||||
mine := rev
|
||||
mu.Unlock()
|
||||
if mine == 0 || revision <= mine {
|
||||
if mine == 0 || revision <= mine || !done(v) {
|
||||
return
|
||||
}
|
||||
if a, err := groups.DecodeEndAsk(v); err == nil && a.Done() {
|
||||
select {
|
||||
case answered <- a:
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case answered <- v:
|
||||
default:
|
||||
}
|
||||
}
|
||||
if err := s.Watch(key, func(c stdio.StateChange) error {
|
||||
@@ -254,11 +216,11 @@ func askEnd(s stdio.KeptState, bound time.Duration) asker {
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return groups.EndAsk{}, fmt.Errorf("watching %s.%s: %w", groups.EndAsksState, key, err)
|
||||
return nil, fmt.Errorf("watching %s.%s: %w", bucket, key, err)
|
||||
}
|
||||
r, err := s.Put(key, json.RawMessage(value))
|
||||
if err != nil {
|
||||
return groups.EndAsk{}, fmt.Errorf("the ask could not be written to %s: %w", groups.EndAsksBucket, err)
|
||||
return nil, fmt.Errorf("the ask could not be written to %s: %w", bucket, err)
|
||||
}
|
||||
mu.Lock()
|
||||
rev = r
|
||||
@@ -269,10 +231,10 @@ func askEnd(s stdio.KeptState, bound time.Duration) asker {
|
||||
wait, cancel := context.WithTimeout(context.Background(), bound) // a context's bound, no timer (ADR 0437 §8)
|
||||
defer cancel()
|
||||
select {
|
||||
case a := <-answered:
|
||||
return a, nil
|
||||
case v := <-answered:
|
||||
return v, nil
|
||||
case <-wait.Done():
|
||||
return groups.EndAsk{}, fmt.Errorf("the bridge did not answer within %s: its answer will be in %s.%s", bound, groups.EndAsksBucket, key)
|
||||
return nil, fmt.Errorf("the bridge did not answer within %s: its answer will be in %s.%s", bound, bucket, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -282,29 +244,33 @@ func askCaller(_ context.Context, key string, body any) (json.RawMessage, error)
|
||||
return stdio.Ask(key, body)
|
||||
}
|
||||
|
||||
// forgeAPI is one GET of the forge's API through the forge module's gitea_api, the path the bridge reads the forge by.
|
||||
func forgeAPI(path string) (json.RawMessage, error) {
|
||||
return stdio.Ask("gitea.gitea_api", map[string]any{"path": path, "method": "GET"})
|
||||
}
|
||||
|
||||
func main() {
|
||||
merges, performed, asks := stdio.State(gateMergesState), stdio.State(groups.PerformedState), stdio.State(groups.EndAsksState)
|
||||
performed, asks := stdio.State(groups.PerformedState), stdio.State(groups.EndAsksState)
|
||||
tools := Tools(askEnd(asks, 25*time.Second), time.Now,
|
||||
func() ([]groups.Row, []string, error) { return read(merges, performed, asks) },
|
||||
func(repository, merge string, number int) (json.RawMessage, bool, error) {
|
||||
keys, err := merges.Keys()
|
||||
func() ([]groups.Row, []string, error) {
|
||||
of, err := groups.Read(context.Background(), askCaller)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("the gate's merges cannot be listed: %w", err)
|
||||
}
|
||||
raws := map[string][]byte{}
|
||||
for _, k := range keys {
|
||||
if e, err := merges.Get(k); err == nil && e != nil {
|
||||
raws[k] = e.Value
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
return read(of, performed, asks)
|
||||
},
|
||||
func(repository, merge string, number int) (json.RawMessage, error) {
|
||||
ctx := context.Background()
|
||||
return MergedOf(raws, repository, merge, number, bridge.TreesVia(ctx, askCaller), bridge.FilesVia(ctx, askCaller), forgeAPI)
|
||||
}, forgeAPI)
|
||||
of, err := groups.Read(ctx, askCaller)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return MergedOf(of, repository, merge, number, bridge.TreesVia(ctx, askCaller), bridge.FilesVia(ctx, askCaller), askCaller)
|
||||
}, askCaller)
|
||||
owedAsks := stdio.State(groups.OwedAsksState)
|
||||
recorder := meshhandactsclient.Recorder{Ask: stdio.Ask}
|
||||
tools = append(tools, OwedTools(askState(owedAsks, groups.OwedAsksBucket, 25*time.Second, func(v []byte) bool {
|
||||
a, err := groups.DecodeOwedAsk(v)
|
||||
return err == nil && a.Done()
|
||||
}), func() ([]OwedRow, error) { return performedRows(performed) }, func(what, why string) error {
|
||||
_, err := recorder.Record(meshhandactsclient.Request{What: what, Why: why, Cause: "owed-dropped"})
|
||||
return err
|
||||
}, time.Now)...)
|
||||
if err := stdio.Serve("", tools); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[mesh-delivery-forge-bridge-tools] %v\n", err)
|
||||
os.Exit(1)
|
||||
|
||||
+127
-79
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
@@ -9,36 +10,34 @@ import (
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
git "git.novox.be/novox/mesh-delivery/contracts/go/seats/git"
|
||||
seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
|
||||
"mesh-delivery-forge-bridge/internal/bridge"
|
||||
|
||||
"mesh-delivery-forge-bridge/internal/gitfixture"
|
||||
"mesh-delivery-forge-bridge/internal/groups"
|
||||
)
|
||||
|
||||
// 1585/T18, T19 (ADR 0633 §4): bridge_groups names each group the gate holds with its members' state and whether its end is
|
||||
// written (open or ended, with why), narrowed by group and by open; bridge_group_end_again writes an ask naming the group,
|
||||
// who asked and why, and answers what the bridge answered.
|
||||
// 1585/T18, T19 (ADR 0633 §4), acceptance 49: bridge_groups names each group the seat mesh-delivery's verb groups answers
|
||||
// with its members' state and whether its end is written (open or ended, with why), narrowed by group and by open;
|
||||
// bridge_group_end_again writes an ask naming the group, who asked and why, and answers what the bridge answered.
|
||||
func TestTheBridgeToolsReadTheGroupsAndAskTheEndAgain(t *testing.T) {
|
||||
h1, h2 := strings.Repeat("1", 40), strings.Repeat("2", 40)
|
||||
c1, c2 := strings.Repeat("c", 40), strings.Repeat("d", 40)
|
||||
enc := func(group string, state2 meshdeliveryclient.MergeState, c string) []byte {
|
||||
v, err := meshdeliveryclient.EncodeMerge(seat.MergesByGroupValue{Group: group, Ask: "ask", Approved: map[string]string{"novox/a@1": h1, "novox/b@2": h2},
|
||||
Pulls: []seat.MergesByGroupValuePullsItem{{Repository: "novox/a", Number: 1, Head: h1, State: string(meshdeliveryclient.MergeMerged), Commit: c1},
|
||||
{Repository: "novox/b", Number: 2, Head: h2, State: string(state2), Commit: c}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return v
|
||||
grp := func(id string, a, b int, stateB string) seat.Group {
|
||||
return seat.Group{ID: id, State: "merging", Members: []seat.Member{{Repository: "novox/a", Number: int64(a), Head: h1, State: "merged"},
|
||||
{Repository: "novox/b", Number: int64(b), Head: h2, State: stateB}}}
|
||||
}
|
||||
lone, _ := meshdeliveryclient.EncodeMerge(seat.MergesByGroupValue{Group: "solo", Ask: "ask", Approved: map[string]string{"novox/a@5": h1},
|
||||
Pulls: []seat.MergesByGroupValuePullsItem{{Repository: "novox/a", Number: 5, Head: h1, State: string(meshdeliveryclient.MergeMerged), Commit: c1}}})
|
||||
merges := map[string][]byte{"ended": enc("ended", meshdeliveryclient.MergeMerged, c2), "ready": enc("ready", meshdeliveryclient.MergeMerged, c2),
|
||||
"gathered": enc("gathered", meshdeliveryclient.MergeMerging, ""), "solo": lone, "bad": []byte("{")}
|
||||
performed := map[string]bool{}
|
||||
for _, c := range []string{c1, c2} {
|
||||
performed[groups.PublishedKey("novox/a", 1, c)], performed[groups.PublishedKey("novox/b", 2, c)] = true, true
|
||||
of := []seat.Group{grp("ended", 1, 2, "merged"), grp("ready", 3, 4, "merged"), grp("gathered", 5, 6, "merging"),
|
||||
{ID: "solo", Members: []seat.Member{{Repository: "novox/a", Number: 7, Head: h1}}}, grp("", 8, 9, "merged")}
|
||||
published := map[string]string{}
|
||||
for _, n := range []int{1, 3, 5} {
|
||||
published[groups.PublishedKey("novox/a", n)] = c1
|
||||
}
|
||||
for _, n := range []int{2, 4} {
|
||||
published[groups.PublishedKey("novox/b", n)] = c2
|
||||
}
|
||||
// The state end-asks keeps the last ask of the group ready, answered, and one that does not read.
|
||||
answeredAsk, _ := json.Marshal(groups.EndAsk{V: 1, Group: "ready", Why: "lost", By: "lead@shanks", At: time.Date(2026, 11, 4, 22, 0, 0, 0, time.UTC),
|
||||
@@ -55,7 +54,7 @@ func TestTheBridgeToolsReadTheGroupsAndAskTheEndAgain(t *testing.T) {
|
||||
}
|
||||
tools := Tools(ask, func() time.Time { return time.Date(2026, 11, 4, 23, 0, 0, 0, time.UTC) },
|
||||
func() ([]groups.Row, []string, error) {
|
||||
rows, unread := groups.Rows(merges, performed, map[string]uint64{"ended": 7}, asks)
|
||||
rows, unread := groups.Rows(of, published, map[string]string{"groups.ended": c2}, asks)
|
||||
return rows, unread, nil
|
||||
}, nil, nil)
|
||||
call := func(name string, args map[string]any) any {
|
||||
@@ -84,22 +83,22 @@ func TestTheBridgeToolsReadTheGroupsAndAskTheEndAgain(t *testing.T) {
|
||||
for _, r := range out.Groups {
|
||||
m[r.Group] = r
|
||||
}
|
||||
if len(args) == 0 && strings.Join(out.Unread, " ") != "bad end-asks.gathered" {
|
||||
t.Errorf("unread = %v, want bad and end-asks.gathered", out.Unread)
|
||||
if len(args) == 0 && strings.Join(out.Unread, ", ") != "a group with no id, end-asks.gathered" {
|
||||
t.Errorf("unread = %v, want the group with no id and end-asks.gathered", out.Unread)
|
||||
}
|
||||
return m
|
||||
}
|
||||
all := rowsOf(map[string]any{})
|
||||
if len(all) != 3 || !all["ended"].Ended || all["ended"].EndedAt != 7 {
|
||||
if len(all) != 3 || !all["ended"].Ended || all["ended"].EndedWith != c2 {
|
||||
t.Fatalf("groups = %+v", all)
|
||||
}
|
||||
if r := all["ready"]; !r.Open || !strings.Contains(r.Words, "bridge_group_end_again") || !r.Members[1].Published {
|
||||
t.Errorf("ready = %+v, want open, merged and published, its end the thing to say", r)
|
||||
if r := all["ready"]; !r.Open || !strings.Contains(r.Words, "bridge_group_end_again") || !r.Members[1].Published || r.Members[1].Commit != c2 {
|
||||
t.Errorf("ready = %+v, want open and published with its merge commit, its end the thing to say", r)
|
||||
}
|
||||
if a := all["ready"].LastEndAsk; a == nil || a.By != "lead@shanks" || a.Why != "lost" || !a.Written || a.Answer == "" || all["ended"].LastEndAsk != nil {
|
||||
t.Errorf("ready's last end ask = %+v, ended's %+v: want ready's ask and its answer, none for ended", a, all["ended"].LastEndAsk)
|
||||
}
|
||||
if r := all["gathered"]; !r.Open || !strings.Contains(r.Words, "gathered") || r.Members[1].State != string(meshdeliveryclient.MergeMerging) {
|
||||
if r := all["gathered"]; !r.Open || !strings.Contains(r.Words, "gathered") || r.Members[1].State != "merging" || r.Members[1].Published {
|
||||
t.Errorf("gathered = %+v", r)
|
||||
}
|
||||
if open := rowsOf(map[string]any{"open": true}); len(open) != 2 || open["ended"].Group != "" {
|
||||
@@ -136,7 +135,7 @@ func TestTheBridgeToolsReadTheGroupsAndAskTheEndAgain(t *testing.T) {
|
||||
return nil, bridge.ErrNotFound
|
||||
}
|
||||
published := func(repo string, number int, head, branch, merge string) forgemsg.Forge {
|
||||
body, _ := json.Marshal(map[string]any{"owner": "novox", "repo": repo, "number": number, "head": branch, "head_sha": head, "base": "main", "merge_commit_sha": merge})
|
||||
body, _ := json.Marshal(git.PullMergedEvent{Repository: "novox/" + repo, Number: int64(number), Branch: branch, Head: head, Base: "main", MergeCommit: merge})
|
||||
msg, err := bridge.Convert(bridge.MergedSubject, body, trees, files)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -147,52 +146,47 @@ func TestTheBridgeToolsReadTheGroupsAndAskTheEndAgain(t *testing.T) {
|
||||
}
|
||||
return f
|
||||
}
|
||||
lone, _ := meshdeliveryclient.EncodeMerge(seat.MergesByGroupValue{Group: "solo", Ask: "a", Approved: map[string]string{"novox/a@5": h1},
|
||||
Pulls: []seat.MergesByGroupValuePullsItem{{Repository: "novox/a", Number: 5, Head: h1, State: string(meshdeliveryclient.MergeMerged), Commit: c1}}})
|
||||
group, _ := meshdeliveryclient.EncodeMerge(seat.MergesByGroupValue{Group: "feat/g", Ask: "a", Approved: map[string]string{"novox/a@6": h1, "novox/b@7": h2},
|
||||
Pulls: []seat.MergesByGroupValuePullsItem{{Repository: "novox/a", Number: 6, Head: h1, State: string(meshdeliveryclient.MergeMerged), Commit: c1},
|
||||
{Repository: "novox/b", Number: 7, Head: h2, State: string(meshdeliveryclient.MergeMerging)}}})
|
||||
// The seat mesh-delivery's groups: feat/g of novox/a#9 and novox/b#7; a lone pull request is in none.
|
||||
of := []seat.Group{{ID: "fix/x", Members: []seat.Member{{Repository: "novox/a", Number: 9, Head: h2}, {Repository: "novox/b", Number: 7, Head: h1}}}}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
raws map[string][]byte
|
||||
of []seat.Group
|
||||
repository string
|
||||
merge string
|
||||
number int
|
||||
api bridge.API
|
||||
onForge bool
|
||||
api bridge.Caller
|
||||
want forgemsg.Forge
|
||||
fail string
|
||||
}{
|
||||
{name: "a lone pull request", raws: map[string][]byte{"solo": lone}, repository: "novox/a", merge: c1, want: published("a", 5, h1, "solo", c1)},
|
||||
{name: "a member of a group", raws: map[string][]byte{"feat~g": group}, repository: "novox/a", merge: c1, want: func() forgemsg.Forge {
|
||||
f := published("a", 6, h1, "feat/g", c1)
|
||||
f.Group, f.Members = "feat/g", []meshdeliveryclient.Head{{Repository: "novox/a", Number: 6, Head: h1}, {Repository: "novox/b", Number: 7, Head: h2}}
|
||||
return f
|
||||
}()},
|
||||
{name: "a merge the gate does not hold", raws: map[string][]byte{"solo": lone, "bad": []byte("{")}, repository: "novox/a", merge: c2, fail: "hold no merge"},
|
||||
// Issue 1966: a merge made on the forge (a recovery merge) has no merge in the gate; the bridge reads the pull request
|
||||
// from the forge, by number or by the merge commit, and checks its merge commit.
|
||||
{name: "a merge made on the forge, by number", raws: map[string][]byte{"solo": lone}, repository: "novox/a", merge: c2, number: 9, onForge: true,
|
||||
api: forgeAnswers(t, "/repos/novox/a/pulls/9", true, "fix/x", h2, c2, "main"), want: published("a", 9, h2, "fix/x", c2)},
|
||||
{name: "a merge made on the forge, by the merge commit", raws: map[string][]byte{}, repository: "novox/a", merge: c2, onForge: true,
|
||||
api: forgeAnswers(t, "/repos/novox/a/commits/"+c2+"/pull", true, "fix/x", h2, c2, "main"), want: published("a", 9, h2, "fix/x", c2)},
|
||||
{name: "a forge pull request merged at another commit", raws: map[string][]byte{}, repository: "novox/a", merge: c2, number: 9,
|
||||
api: forgeAnswers(t, "/repos/novox/a/pulls/9", true, "fix/x", h2, c1, "main"), fail: "not at " + c2},
|
||||
{name: "a forge pull request not merged", raws: map[string][]byte{}, repository: "novox/a", merge: c2, number: 9,
|
||||
api: forgeAnswers(t, "/repos/novox/a/pulls/9", false, "fix/x", h2, "", "main"), fail: "not merged"},
|
||||
{name: "a forge pull request merged into another branch", raws: map[string][]byte{}, repository: "novox/a", merge: c2, number: 9,
|
||||
api: forgeAnswers(t, "/repos/novox/a/pulls/9", true, "fix/x", h2, c2, "dev"), fail: "not the trunk"},
|
||||
{name: "a forge pull request with no head branch", raws: map[string][]byte{}, repository: "novox/a", merge: c2, number: 9,
|
||||
api: forgeAnswers(t, "/repos/novox/a/pulls/9", true, "refs/pull/9/head", h2, c2, "main"), fail: "no head branch"},
|
||||
{name: "a forge answer that is the bare body, no wrapper", raws: map[string][]byte{}, repository: "novox/a", merge: c2, number: 9,
|
||||
api: func(string) (json.RawMessage, error) { return json.RawMessage(`{"number":9,"merged":true}`), nil }, fail: `{"result"`},
|
||||
{name: "a forge answer with no pull request number", raws: map[string][]byte{}, repository: "novox/a", merge: c2, number: 9,
|
||||
api: func(string) (json.RawMessage, error) { return json.RawMessage(`{"result":{"merged":true}}`), nil }, fail: "no number"},
|
||||
{name: "a forge that does not answer", raws: map[string][]byte{}, repository: "novox/a", merge: c2,
|
||||
api: func(string) (json.RawMessage, error) { return nil, errors.New("the forge is down") }, fail: "the forge is down"},
|
||||
{name: "a lone pull request, by number", repository: "novox/a", merge: c2, number: 9,
|
||||
api: forgeAnswers(t, 9, "", "merged", "fix/x", h2, c2, "main"), want: published("a", 9, h2, "fix/x", c2)},
|
||||
{name: "a lone pull request, by the merge commit", repository: "novox/a", merge: c2,
|
||||
api: forgeAnswers(t, 0, c2, "merged", "fix/x", h2, c2, "main"), want: published("a", 9, h2, "fix/x", c2)},
|
||||
{name: "a member of a group", of: of, repository: "novox/a", merge: c2,
|
||||
api: forgeAnswers(t, 0, c2, "merged", "fix/x", h2, c2, "main"), want: func() forgemsg.Forge {
|
||||
f := published("a", 9, h2, "fix/x", c2)
|
||||
f.Group, f.Members = "fix/x", []meshdeliveryclient.Head{{Repository: "novox/a", Number: 9, Head: h2}, {Repository: "novox/b", Number: 7, Head: h1}}
|
||||
return f
|
||||
}()},
|
||||
{name: "a forge pull request merged at another commit", repository: "novox/a", merge: c2, number: 9,
|
||||
api: forgeAnswers(t, 9, "", "merged", "fix/x", h2, c1, "main"), fail: "not at " + c2},
|
||||
{name: "a forge pull request not merged", repository: "novox/a", merge: c2, number: 9,
|
||||
api: forgeAnswers(t, 9, "", "open", "fix/x", h2, "", "main"), fail: "not merged"},
|
||||
{name: "a forge pull request merged into another branch", repository: "novox/a", merge: c2, number: 9,
|
||||
api: forgeAnswers(t, 9, "", "merged", "fix/x", h2, c2, "dev"), fail: "not the trunk"},
|
||||
{name: "a forge pull request with no head branch", repository: "novox/a", merge: c2, number: 9,
|
||||
api: forgeAnswers(t, 9, "", "merged", "", h2, c2, "main"), fail: "no head branch"},
|
||||
{name: "a seat answer that says an error", repository: "novox/a", merge: c2, number: 9,
|
||||
api: answering(gitfixture.Given(t, "pull answers an error, never none", "verb:pull.answer")), fail: "holds no pull request"},
|
||||
{name: "a seat answer with no pull request", repository: "novox/a", merge: c2, number: 9,
|
||||
api: answering(json.RawMessage(`{}`)), fail: "carries nothing"},
|
||||
{name: "a forge that does not answer", repository: "novox/a", merge: c2,
|
||||
api: func(context.Context, string, any) (json.RawMessage, error) {
|
||||
return nil, errors.New("the forge is down")
|
||||
}, fail: "the forge is down"},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
raw, onForge, err := MergedOf(c.raws, c.repository, c.merge, c.number, trees, files, c.api)
|
||||
raw, err := MergedOf(c.of, c.repository, c.merge, c.number, trees, files, c.api)
|
||||
if c.fail != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), c.fail) {
|
||||
t.Fatalf("err %v, want %q", err, c.fail)
|
||||
@@ -200,37 +194,91 @@ func TestTheBridgeToolsReadTheGroupsAndAskTheEndAgain(t *testing.T) {
|
||||
return
|
||||
}
|
||||
want, _ := forgemsg.EncodeForge(c.want)
|
||||
if err == nil && onForge != c.onForge {
|
||||
t.Fatalf("made on the forge %v, want %v", onForge, c.onForge)
|
||||
}
|
||||
if err != nil || !bytes.Equal(raw, want) {
|
||||
t.Fatalf("err %v, answer\n%s\nwant the published message\n%s", err, raw, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, _, err := MergedOf(map[string][]byte{"solo": lone}, "novox/a", c1, 0, func(string, string, string) (string, map[string]string, error) {
|
||||
if _, err := MergedOf(nil, "novox/a", c2, 9, func(string, string, string) (string, map[string]string, error) {
|
||||
return "", nil, errors.New("the forge does not answer")
|
||||
}, files, nil); err == nil || !strings.Contains(err.Error(), "does not answer") {
|
||||
}, files, forgeAnswers(t, 9, "", "merged", "fix/x", h2, c2, "main")); err == nil || !strings.Contains(err.Error(), "does not answer") {
|
||||
t.Errorf("a forge that does not answer: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Design 47 *What the gate owes the forge*: bridge_owed reads performed and asks the backlog; bridge_owed_again asks the
|
||||
// act again; bridge_owed_drop writes the hand-act log first and drops nothing it could not record.
|
||||
t.Run("the owed tools", func(t *testing.T) {
|
||||
at := time.Date(2026, 11, 9, 21, 0, 0, 0, time.UTC)
|
||||
var asked []groups.OwedAsk
|
||||
ask := func(key string, value []byte) ([]byte, error) {
|
||||
a, err := groups.DecodeOwedAsk(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
asked = append(asked, a)
|
||||
a.Answered, a.Outcome, a.Answer = at, forgemsg.OwedPerformed, "said as owed.ended: performed"
|
||||
if a.Kind == groups.OwedBacklog {
|
||||
a.Backlog = []groups.Backlog{{Stream: seat.StreamOwed, Waiting: 1}}
|
||||
}
|
||||
return json.Marshal(a)
|
||||
}
|
||||
rows := []OwedRow{{Of: seat.Seat, ID: "a", Outcome: forgemsg.OwedPerformed, At: at}, {Of: "mesh-delivery-checker", ID: "b", Outcome: forgemsg.OwedRefused, At: at}}
|
||||
var recorded []string
|
||||
recordErr := error(nil)
|
||||
record := func(what, why string) error { recorded = append(recorded, what+": "+why); return recordErr }
|
||||
tools := map[string]func(map[string]any) (any, error){}
|
||||
for _, tl := range OwedTools(ask, func() ([]OwedRow, error) { return rows, nil }, record, func() time.Time { return at }) {
|
||||
tools[tl.Name] = tl.Run
|
||||
}
|
||||
out, err := tools[OwedTool](map[string]any{"of": seat.Seat})
|
||||
if m, _ := out.(map[string]any); err != nil || len(m["performed"].([]OwedRow)) != 1 || len(m["backlog"].([]groups.Backlog)) != 1 {
|
||||
t.Errorf("bridge_owed: %v %v", out, err)
|
||||
}
|
||||
if out, err := tools[OwedAgainTool](map[string]any{"of": seat.Seat, "id": "a"}); err != nil || out.(map[string]any)["outcome"] != forgemsg.OwedPerformed ||
|
||||
asked[len(asked)-1].Kind != groups.OwedAgain {
|
||||
t.Errorf("bridge_owed_again: %v %v", out, err)
|
||||
}
|
||||
if _, err := tools[OwedDropTool](map[string]any{"of": seat.Seat, "id": "a"}); err == nil || len(recorded) != 0 {
|
||||
t.Errorf("a drop with no why: %v, recorded %v", err, recorded)
|
||||
}
|
||||
n := len(asked)
|
||||
recordErr = errors.New("mesh-hand-acts has no holder that answers")
|
||||
if _, err := tools[OwedDropTool](map[string]any{"of": seat.Seat, "id": "a", "why": "merged by hand"}); err == nil || len(asked) != n {
|
||||
t.Errorf("a drop whose hand act was not recorded: %v, asked %d", err, len(asked)-n)
|
||||
}
|
||||
recordErr = nil
|
||||
if _, err := tools[OwedDropTool](map[string]any{"of": seat.Seat, "id": "a", "why": "merged by hand"}); err != nil || asked[len(asked)-1].Why != "merged by hand" {
|
||||
t.Errorf("bridge_owed_drop: %v %+v", err, asked)
|
||||
}
|
||||
if _, _, err := groups.NewOwedAsk(groups.OwedAgain, "", "a", "", "", at); err == nil {
|
||||
t.Error("an ask naming no seat was taken")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// forgeAnswers is the forge's gitea_api for one GET path: that pull request as the forge says it; any other path is not found.
|
||||
func forgeAnswers(t *testing.T, path string, merged bool, branch, head, merge, base string) bridge.API {
|
||||
// forgeAnswers is the seat git's verb pull for one pull request, asked by number (when number is given) or by the merge
|
||||
// commit asked: the seat's own fixture with that pull request's state, branch, head, merge commit and base; any other
|
||||
// ask is refused as the seat refuses it.
|
||||
func forgeAnswers(t *testing.T, number int, byMerge, state, branch, head, merge, base string) bridge.Caller {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(map[string]any{"number": 9, "merged": merged, "merge_commit_sha": merge,
|
||||
"head": map[string]any{"ref": "refs/pull/9/head", "label": branch, "sha": head}, "base": map[string]any{"ref": base}})
|
||||
return func(p string) (json.RawMessage, error) {
|
||||
if p != path {
|
||||
return nil, errors.New("the forge has no " + p)
|
||||
var a git.PullAnswer
|
||||
if err := json.Unmarshal(gitfixture.Given(t, "pull answers", "verb:pull.answer"), &a); err != nil || a.Pull == nil {
|
||||
t.Fatalf("the seat's pull fixture: %v", err)
|
||||
}
|
||||
p := *a.Pull
|
||||
p.Repository, p.Number, p.State, p.Branch, p.Head, p.MergeCommit, p.Base = "novox/a", 9, state, branch, head, merge, base
|
||||
answer, _ := json.Marshal(git.PullAnswer{Pull: &p})
|
||||
return func(_ context.Context, key string, body any) (json.RawMessage, error) {
|
||||
in, ok := body.(git.PullInput)
|
||||
if key != "seat:git.pull" || !ok || in.Repository != "novox/a" || int(in.Number) != number || in.MergeCommit != byMerge {
|
||||
return json.RawMessage(`{"error":"the forge holds no such pull request"}`), nil
|
||||
}
|
||||
return wrapped(body), nil
|
||||
return answer, nil
|
||||
}
|
||||
}
|
||||
|
||||
// wrapped is gitea_api's answer for a body: {"result": <body>} (mesh-catalog modules/gitea/tools/index.ts).
|
||||
func wrapped(body []byte) json.RawMessage {
|
||||
b, _ := json.Marshal(map[string]json.RawMessage{"result": body})
|
||||
return b
|
||||
// answering is a seat that answers raw to every ask.
|
||||
func answering(raw json.RawMessage) bridge.Caller {
|
||||
return func(context.Context, string, any) (json.RawMessage, error) { return raw, nil }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
|
||||
"mesh-delivery-forge-bridge/internal/groups"
|
||||
)
|
||||
|
||||
// The tools of what the bridge performs of what is owed to the forge (novox/hq design 47 *What the gate owes the forge*).
|
||||
const (
|
||||
OwedTool = "bridge_owed"
|
||||
OwedAgainTool = "bridge_owed_again"
|
||||
OwedDropTool = "bridge_owed_drop"
|
||||
)
|
||||
|
||||
// OwedRow is one act the bridge performed, as the state performed keeps it: the outcome said as owed.ended.
|
||||
type OwedRow = forgemsg.OwedEnded
|
||||
|
||||
// performedRows reads every act the state performed holds; a key that is not an act (a group end's bookkeeping) is left.
|
||||
func performedRows(s stdio.KeptState) ([]OwedRow, error) {
|
||||
keys, err := s.Keys()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the bridge's performed state cannot be listed: %w", err)
|
||||
}
|
||||
var rows []OwedRow
|
||||
for _, k := range keys {
|
||||
e, err := s.Get(k)
|
||||
if err != nil || e == nil {
|
||||
continue
|
||||
}
|
||||
var p struct {
|
||||
Ended OwedRow `json:"ended"`
|
||||
}
|
||||
if json.Unmarshal(e.Value, &p) == nil && p.Ended.Of != "" {
|
||||
rows = append(rows, p.Ended)
|
||||
}
|
||||
}
|
||||
sort.Slice(rows, func(i, j int) bool { return rows[i].At.After(rows[j].At) })
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// OwedTools are bridge_owed, bridge_owed_again and bridge_owed_drop: ask writes an owed-asks value and waits for the
|
||||
// bridge's answer; performed reads the state performed; record writes a hand act in the seat mesh-hand-acts' log before
|
||||
// a drop is asked, so a drop is never made without its record.
|
||||
func OwedTools(ask func(key string, value []byte) ([]byte, error), performed func() ([]OwedRow, error), record func(what, why string) error, now func() time.Time) []stdio.Tool {
|
||||
str := func(args map[string]any, k string) string { s, _ := args[k].(string); return strings.TrimSpace(s) }
|
||||
act := map[string]any{
|
||||
"of": map[string]any{"type": "string", "description": "the seat that said the act: mesh-delivery (the gate) or mesh-delivery-checker"},
|
||||
"id": map[string]any{"type": "string", "description": "the act's id, as owed and owed.ended say it"}}
|
||||
asked := func(kind string, args map[string]any) (groups.OwedAsk, error) {
|
||||
key, value, err := groups.NewOwedAsk(kind, str(args, "of"), str(args, "id"), str(args, "why"), stdio.CallerOf(args), now())
|
||||
if err != nil {
|
||||
return groups.OwedAsk{}, err
|
||||
}
|
||||
v, err := ask(key, value)
|
||||
if err != nil {
|
||||
return groups.OwedAsk{}, err
|
||||
}
|
||||
return groups.DecodeOwedAsk(v)
|
||||
}
|
||||
withWhy := map[string]any{"why": map[string]any{"type": "string", "description": "why it is dropped, in plain words"}}
|
||||
for k, v := range act {
|
||||
withWhy[k] = v
|
||||
}
|
||||
return []stdio.Tool{
|
||||
{Name: OwedTool,
|
||||
Description: "What the forge bridge performed of what the gate and the checker owe the forge (design 47): each act it said as " +
|
||||
"owed.ended, newest first, with its seat, id, pull request, outcome (performed, refused, could not be performed, " +
|
||||
"undecodable, dropped), the forge's words and when, from its state performed (kept 7 days); and the backlog of its " +
|
||||
"durable on each seat's stream owed: the last event applied, the events waiting and held, the age of the oldest. " +
|
||||
"of or id narrow it. Read-only.",
|
||||
Input: map[string]any{"type": "object", "properties": act},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
rows, err := performed()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kept := []OwedRow{}
|
||||
for _, r := range rows {
|
||||
if (str(args, "of") == "" || r.Of == str(args, "of")) && (str(args, "id") == "" || r.ID == str(args, "id")) {
|
||||
kept = append(kept, r)
|
||||
}
|
||||
}
|
||||
out := map[string]any{"performed": kept}
|
||||
if a, err := asked(groups.OwedBacklog, args); err != nil {
|
||||
out["backlog-error"] = err.Error()
|
||||
} else {
|
||||
out["backlog"] = a.Backlog
|
||||
}
|
||||
return out, nil
|
||||
}},
|
||||
{Name: OwedAgainTool,
|
||||
Description: "Perform one act owed to the forge again now, by hand, whatever its last outcome (a refusal the forge no longer " +
|
||||
"makes, an act that could not be performed): the bridge calls the seat git's verb of the act under its id (safe to " +
|
||||
"repeat) and says its outcome as owed.ended. The act is the one the bridge kept, or, for the gate's, the seat " +
|
||||
"mesh-delivery's groups. Answers the outcome.",
|
||||
Input: map[string]any{"type": "object", "required": []string{"of", "id"}, "properties": act},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
a, err := asked(groups.OwedAgain, args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"of": a.Of, "id": a.ID, "outcome": a.Outcome, "answer": a.Answer}, nil
|
||||
}},
|
||||
{Name: OwedDropTool,
|
||||
Description: "Drop one act owed to the forge, by hand, with why: written first in the hand-act log of the seat mesh-hand-acts, " +
|
||||
"then said by the bridge as owed.ended with the outcome dropped, so its owner records it answered and stops saying " +
|
||||
"it again. Nothing is done at the forge. Answers the outcome said.",
|
||||
Input: map[string]any{"type": "object", "required": []string{"of", "id", "why"}, "properties": withWhy},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
if str(args, "why") == "" {
|
||||
return nil, fmt.Errorf("a drop says why")
|
||||
}
|
||||
if err := record(fmt.Sprintf("dropped the act %s that %s owes the forge", str(args, "id"), str(args, "of")), str(args, "why")); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a, err := asked(groups.OwedDrop, args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"of": a.Of, "id": a.ID, "outcome": a.Outcome, "answer": a.Answer}, nil
|
||||
}},
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ const callTimeout = 30 * time.Second
|
||||
// caller calls a module's tool by its manifest key (`<module>.<tool>`) with a JSON body and returns the tool's result.
|
||||
type caller func(ctx context.Context, key string, body any) (json.RawMessage, error)
|
||||
|
||||
// callerOn calls tools as the bridge's own principal. It copies mesh-sdk go/process/call.go (Call, callSubjects,
|
||||
// callerOn calls tools and seat verbs as the bridge's own principal. It copies mesh-sdk go/process/call.go (Call, callSubjects,
|
||||
// request; hand-written here until the SDK's helper is used, issue 1242): the request is the tool's arguments as
|
||||
// JSON, published on the caller-named subject `mesh.mod.<module>.call.<tool>.<user, dots as ~>`, or on the subject that
|
||||
// names no caller, `mesh.mod.<module>.tool.<tool>`, when nothing answers the first; the reply is
|
||||
@@ -49,22 +49,40 @@ func callerOn(nc *nats.Conn, user string) caller {
|
||||
return r.Result, nil
|
||||
}
|
||||
return func(ctx context.Context, key string, body any) (json.RawMessage, error) {
|
||||
module, tool, ok := strings.Cut(key, ".")
|
||||
if !ok || module == "" || tool == "" {
|
||||
return nil, fmt.Errorf("%q is not <module>.<tool>", key)
|
||||
called, fallback, err := callSubjects(key, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := request(ctx, "mesh.mod."+module+".call."+tool+"."+token, data)
|
||||
result, err := request(ctx, called, data)
|
||||
if errors.Is(err, nats.ErrNoResponders) {
|
||||
result, err = request(ctx, "mesh.mod."+module+".tool."+tool, data)
|
||||
result, err = request(ctx, fallback, data)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
|
||||
// callSubjects is the caller-named subject a key is called on and its plain fallback: a seat's verb
|
||||
// (`seat:<seat>.<verb>`) on mesh.seat.<seat>.call.<verb>.<token>, else a module's tool (`<module>.<tool>`) on
|
||||
// mesh.mod.<module>.call.<tool>.<token> (mesh-sdk go/process/call.go callSubjectsOn).
|
||||
func callSubjects(key, token string) (called, fallback string, err error) {
|
||||
if rest, isSeat := strings.CutPrefix(key, "seat:"); isSeat {
|
||||
seat, verb, ok := strings.Cut(rest, ".")
|
||||
if !ok || seat == "" || verb == "" {
|
||||
return "", "", fmt.Errorf("%q is not seat:<seat>.<verb>", key)
|
||||
}
|
||||
return "mesh.seat." + seat + ".call." + verb + "." + token, "mesh.seat." + seat + ".tool." + verb, nil
|
||||
}
|
||||
module, tool, ok := strings.Cut(key, ".")
|
||||
if !ok || module == "" || tool == "" {
|
||||
return "", "", fmt.Errorf("%q is not <module>.<tool>", key)
|
||||
}
|
||||
return "mesh.mod." + module + ".call." + tool + "." + token, "mesh.mod." + module + ".tool." + tool, nil
|
||||
}
|
||||
|
||||
// treesVia asks the forge for a commit's trees through call.
|
||||
func treesVia(ctx context.Context, call caller) bridge.Trees {
|
||||
return bridge.TreesVia(ctx, bridge.Caller(call))
|
||||
|
||||
@@ -3,142 +3,10 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"mesh-delivery-forge-bridge/internal/bridge"
|
||||
)
|
||||
|
||||
// performedBucket is the module's state bucket performed, `<module>_<name>` as heads is: for each key of the gate's
|
||||
// buckets the bridge has acted on, the revision it acted at (manifest state entry performed, novox/hq ADR 0437 §8).
|
||||
// Its keys are `owed.<owed key>` and `merges.<merges key>`; its value is the revision in decimal.
|
||||
const performedBucket = forgeBridgeModule + "_performed"
|
||||
|
||||
// kvRevisions is the bucket performed as the performer's revisions.
|
||||
type kvRevisions struct{ kv jetstream.KeyValue }
|
||||
|
||||
func (s kvRevisions) Get(ctx context.Context, key string) (uint64, bool, error) {
|
||||
e, err := s.kv.Get(ctx, key)
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return 0, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
rev, err := strconv.ParseUint(string(e.Value()), 10, 64)
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("%s holds %q, which is no revision: %w", key, e.Value(), err)
|
||||
}
|
||||
return rev, true, nil
|
||||
}
|
||||
|
||||
func (s kvRevisions) Put(ctx context.Context, key string, rev uint64) error {
|
||||
_, err := s.kv.PutString(ctx, key, strconv.FormatUint(rev, 10))
|
||||
return err
|
||||
}
|
||||
|
||||
// revisions is what the performer keeps of the revisions it acted at.
|
||||
type revisions interface {
|
||||
Get(ctx context.Context, key string) (rev uint64, found bool, err error)
|
||||
Put(ctx context.Context, key string, rev uint64) error
|
||||
}
|
||||
|
||||
// performer does on the forge what the gate's keys ask (novox/hq ADR 0437 §8): one act for each revision of an owed
|
||||
// key, one merge for each revision of a merges key. A refusal is written on FORGE and the act is not tried again until
|
||||
// the key has a new revision; the performer holds no timer and serves no request.
|
||||
type performer struct {
|
||||
call caller
|
||||
pub publisher
|
||||
done revisions
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
// seen says whether the revision of the key was acted on already. A revision that cannot be read is not seen, so the
|
||||
// act is done: a status set twice is the same status.
|
||||
func (p *performer) seen(ctx context.Context, doneKey string, rev uint64) bool {
|
||||
kept, found, err := p.done.Get(ctx, doneKey)
|
||||
if err != nil {
|
||||
p.logger.Printf("%s: the revision acted at could not be read, so the act is done again: %v", doneKey, err)
|
||||
return false
|
||||
}
|
||||
return found && kept >= rev
|
||||
}
|
||||
|
||||
func (p *performer) keep(ctx context.Context, doneKey string, rev uint64) {
|
||||
if err := p.done.Put(ctx, doneKey, rev); err != nil {
|
||||
p.logger.Printf("%s: revision %d was acted on but could not be kept, so a restart acts on it again: %v", doneKey, rev, err)
|
||||
}
|
||||
}
|
||||
|
||||
// refuse writes the refused message of an act on FORGE; it reports whether it was written. The message id makes a
|
||||
// second write of one revision's refusal the same message to the stream.
|
||||
func (p *performer) refuse(ctx context.Context, doneKey string, rev uint64, a bridge.Act, words string) bool {
|
||||
msg, err := bridge.RefusedMessage(a, words)
|
||||
if err != nil {
|
||||
p.logger.Printf("%s: the forge refused %s (%s) and the refusal cannot be said on FORGE: %v", doneKey, a.Name, words, err)
|
||||
return false
|
||||
}
|
||||
id := fmt.Sprintf("refused.%s.%d", doneKey, rev)
|
||||
if _, err := p.pub.Publish(ctx, forgeSubject(msg.Event), msg.Data, jetstream.WithMsgID(id)); err != nil {
|
||||
p.logger.Printf("%s: the forge refused %s (%s) and FORGE could not be written: %v", doneKey, a.Name, words, err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// owed performs the act an owed key of the gate asks at revision rev, once.
|
||||
func (p *performer) owed(ctx context.Context, key string, rev uint64, value []byte) {
|
||||
p.perform(ctx, "owed."+key, key, rev, value)
|
||||
}
|
||||
|
||||
// perform does the act of an owed key once for the revision, under the bookkeeping key doneKey.
|
||||
func (p *performer) perform(ctx context.Context, doneKey, key string, rev uint64, value []byte) {
|
||||
if p.seen(ctx, doneKey, rev) {
|
||||
return
|
||||
}
|
||||
a, err := bridge.OwedAct(key, value)
|
||||
if err != nil {
|
||||
p.logger.Printf("%s at revision %d is not an act the bridge can perform, and is left: %v", key, rev, err)
|
||||
p.keep(ctx, doneKey, rev)
|
||||
return
|
||||
}
|
||||
if _, err := p.call(ctx, a.Call.Tool, a.Call.Args); err != nil {
|
||||
p.logger.Printf("%s at revision %d: %s refused: %v", key, rev, a.Call.Tool, err)
|
||||
if !p.refuse(ctx, doneKey, rev, a, err.Error()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
p.keep(ctx, doneKey, rev)
|
||||
}
|
||||
|
||||
// merges performs the merges a merges key hands the bridge at revision rev, once: each pull request in the state
|
||||
// merging, in the order the value names them, stopping at the first refusal (novox/hq ADR 0441 §3).
|
||||
func (p *performer) merges(ctx context.Context, key string, rev uint64, value []byte) {
|
||||
doneKey := "merges." + key
|
||||
if p.seen(ctx, doneKey, rev) {
|
||||
return
|
||||
}
|
||||
acts, err := bridge.MergeActs(value)
|
||||
if err != nil {
|
||||
p.logger.Printf("%s at revision %d cannot be merged, and is left: %v", key, rev, err)
|
||||
p.keep(ctx, doneKey, rev)
|
||||
return
|
||||
}
|
||||
for _, a := range acts {
|
||||
if _, err := p.call(ctx, a.Call.Tool, a.Call.Args); err != nil {
|
||||
p.logger.Printf("%s at revision %d: the merge of %s#%d refused: %v", key, rev, a.Repository, a.Number, err)
|
||||
if !p.refuse(ctx, doneKey, rev, a, err.Error()) {
|
||||
return
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
p.keep(ctx, doneKey, rev)
|
||||
}
|
||||
|
||||
// watch hands each key of a bucket, with the revision it has now and then each new one, to react until ctx ends or the
|
||||
// watch fails. A deleted key is not an act.
|
||||
func watch(ctx context.Context, kv jetstream.KeyValue, react func(key string, rev uint64, value []byte)) error {
|
||||
|
||||
@@ -8,10 +8,15 @@ import (
|
||||
"log"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery"
|
||||
checker "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
|
||||
"mesh-delivery-forge-bridge/internal/gitfixture"
|
||||
"mesh-delivery-forge-bridge/internal/groups"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -19,195 +24,231 @@ const (
|
||||
headB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
)
|
||||
|
||||
type memRevisions map[string]uint64
|
||||
type memStrs map[string]string
|
||||
|
||||
func (m memRevisions) Get(_ context.Context, key string) (uint64, bool, error) {
|
||||
func (m memStrs) Get(_ context.Context, key string) (string, bool, error) {
|
||||
r, ok := m[key]
|
||||
return r, ok, nil
|
||||
}
|
||||
func (m memRevisions) Put(_ context.Context, key string, rev uint64) error { m[key] = rev; return nil }
|
||||
func (m memStrs) Put(_ context.Context, key, v string) error { m[key] = v; return nil }
|
||||
|
||||
type tcall struct {
|
||||
key string
|
||||
body string
|
||||
type memPerformed map[string][]byte
|
||||
|
||||
func (m memPerformed) Get(_ context.Context, key string) ([]byte, bool, error) {
|
||||
v, ok := m[key]
|
||||
return v, ok, nil
|
||||
}
|
||||
func (m memPerformed) Put(_ context.Context, key string, v []byte) error { m[key] = v; return nil }
|
||||
|
||||
// seatFake answers each seat verb from answers, by its key; one absent is not answered (an error, as a bound passed).
|
||||
type seatFake struct {
|
||||
calls []string
|
||||
answers map[string]json.RawMessage
|
||||
}
|
||||
|
||||
// forgeFake records the calls and answers each from refuse, a map from a tool to its refusal.
|
||||
type forgeFake struct {
|
||||
calls []tcall
|
||||
refuse map[string]string
|
||||
}
|
||||
|
||||
func (f *forgeFake) call(_ context.Context, key string, body any) (json.RawMessage, error) {
|
||||
b, _ := json.Marshal(body)
|
||||
f.calls = append(f.calls, tcall{key, string(b)})
|
||||
if w, ok := f.refuse[key]; ok {
|
||||
return nil, errors.New(w)
|
||||
func (f *seatFake) call(_ context.Context, key string, _ any) (json.RawMessage, error) {
|
||||
f.calls = append(f.calls, key)
|
||||
if a, ok := f.answers[key]; ok {
|
||||
return a, nil
|
||||
}
|
||||
return json.RawMessage(`{}`), nil
|
||||
return nil, errors.New("nats: timeout")
|
||||
}
|
||||
|
||||
func newPerformer(f *forgeFake) (*performer, *fakePub, memRevisions, *bytes.Buffer) {
|
||||
pub, done, logs := &fakePub{}, memRevisions{}, &bytes.Buffer{}
|
||||
return &performer{call: f.call, pub: pub, done: done, logger: log.New(logs, "", 0)}, pub, done, logs
|
||||
func newOwing(f *seatFake) (*owing, *fakePub, memPerformed) {
|
||||
pub, kept := &fakePub{}, memPerformed{}
|
||||
at := time.Date(2026, 11, 9, 21, 0, 0, 0, time.UTC)
|
||||
return &owing{call: f.call, pub: pub, performed: kept, now: func() time.Time { return at }, logger: log.New(&bytes.Buffer{}, "", 0)}, pub, kept
|
||||
}
|
||||
|
||||
// The values are the SDK's own encoding (mesh-sdk go/delivery EncodeOwed, EncodeMerge), the keys its OwedKey.
|
||||
func owedKV(t *testing.T, repository string, number int, act string, o seat.OwedByRepositoryNumberHeadActValue) (string, []byte) {
|
||||
t.Helper()
|
||||
key, err := meshdeliveryclient.OwedKey(repository, number, headA, act)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v, err := meshdeliveryclient.EncodeOwed(o)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return key, v
|
||||
}
|
||||
|
||||
func TestAnOwedStatusIsSetOnceForARevision(t *testing.T) { // 1341/AC1
|
||||
// Design 47 *What the gate owes the forge*: an owed act is the seat git's verb under its id, its outcome said as
|
||||
// owed.ended on the stream forge before it is kept in performed, an act it cannot read said undecodable; and at its start
|
||||
// the bridge performs from the seat's groups every act said and not answered.
|
||||
func TestWhatIsOwedIsPerformedSaidAndKept(t *testing.T) {
|
||||
t.Run("the names are the manifest's", theNamesAreTheManifests)
|
||||
f := &forgeFake{}
|
||||
p, _, _, _ := newPerformer(f)
|
||||
key, v := owedKV(t, "novox/mesh-delivery", 12, "status=mesh/merge-gate",
|
||||
seat.OwedByRepositoryNumberHeadActValue{Act: meshdeliveryclient.OwedStatus, Context: "mesh/merge-gate", State: "success", Text: "all green", Target: "https://forge.example/p/12"})
|
||||
p.owed(context.Background(), key, 7, v)
|
||||
if len(f.calls) != 1 || f.calls[0].key != "gitea.gitea_commit_status" ||
|
||||
f.calls[0].body != `{"context":"mesh/merge-gate","description":"all green","owner":"novox","repo":"mesh-delivery","sha":"`+headA+`","state":"success","target_url":"https://forge.example/p/12"}` {
|
||||
t.Fatalf("calls = %+v", f.calls)
|
||||
}
|
||||
p.owed(context.Background(), key, 7, v) // the same revision seen again (a restart's replay)
|
||||
if len(f.calls) != 1 {
|
||||
t.Errorf("the same revision called the tool again: %+v", f.calls)
|
||||
}
|
||||
p.owed(context.Background(), key, 8, v) // a new revision of the key is a new act
|
||||
if len(f.calls) != 2 {
|
||||
t.Errorf("a new revision was not performed: %+v", f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewNoteAndCommentEachCallTheirToolOnce(t *testing.T) { // 1341/AC2
|
||||
f := &forgeFake{}
|
||||
p, _, _, _ := newPerformer(f)
|
||||
gate := func(name string) []byte { return gitfixture.Seat(t, seat.Seat, "events", name, "event:owed")[0] }
|
||||
answer := func(verb, name string) json.RawMessage { return gitfixture.Given(t, name, "verb:"+verb+".answer") }
|
||||
for _, c := range []struct {
|
||||
act string
|
||||
o seat.OwedByRepositoryNumberHeadActValue
|
||||
name, of string
|
||||
data []byte
|
||||
answers map[string]json.RawMessage
|
||||
pubErr error
|
||||
call string
|
||||
outcome string
|
||||
kept bool
|
||||
err string
|
||||
}{
|
||||
{"view", seat.OwedByRepositoryNumberHeadActValue{Act: meshdeliveryclient.OwedView, Text: "the view"}},
|
||||
{"note", seat.OwedByRepositoryNumberHeadActValue{Act: meshdeliveryclient.OwedNote, Text: "a line", Ref: "mesh-plan"}},
|
||||
{"comment.1", seat.OwedByRepositoryNumberHeadActValue{Act: meshdeliveryclient.OwedComment, Text: "hello"}},
|
||||
{"a merge performed", seat.Seat, gate("owed is said, a merge"),
|
||||
map[string]json.RawMessage{"seat:git.merge-pull": answer("merge-pull", "merge-pull answers")}, nil,
|
||||
"seat:git.merge-pull", forgemsg.OwedPerformed, true, ""},
|
||||
{"a status refused", seat.Seat, gate("owed is said, a status"),
|
||||
map[string]json.RawMessage{"seat:git.status": answer("status", "status answers an error, never none")}, nil,
|
||||
"seat:git.status", forgemsg.OwedRefused, true, ""},
|
||||
{"the checker's status not answered", checker.Seat, gitfixture.Seat(t, checker.Seat, "events", "owed is said", "event:owed")[0],
|
||||
nil, nil, "seat:git.status", forgemsg.OwedCouldNotBePerformed, true, ""},
|
||||
{"an act it cannot read", seat.Seat, []byte(`{`), nil, nil, "", forgemsg.OwedUndecodable, false, ""},
|
||||
{"a seat that says no owed act", "mesh-wants", gate("owed is said, a merge"), nil, nil, "", forgemsg.OwedUndecodable, false, ""},
|
||||
{"the stream forge refusing the outcome", seat.Seat, gate("owed is said, a merge"),
|
||||
map[string]json.RawMessage{"seat:git.merge-pull": answer("merge-pull", "merge-pull answers")}, errors.New("no responders"),
|
||||
"seat:git.merge-pull", "", false, "could not be said"},
|
||||
} {
|
||||
key, v := owedKV(t, "novox/mesh-delivery", 12, c.act, c.o)
|
||||
p.owed(context.Background(), key, 1, v)
|
||||
p.owed(context.Background(), key, 1, v)
|
||||
f := &seatFake{answers: c.answers}
|
||||
o, pub, kept := newOwing(f)
|
||||
pub.err = c.pubErr
|
||||
_, err := o.heard(context.Background(), c.of, c.data)
|
||||
if c.err != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), c.err) || len(kept) != 0 {
|
||||
t.Errorf("%s: %v, kept %d; want an error naming %q and nothing kept", c.name, err, len(kept), c.err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil || len(pub.data) != 1 || pub.subjects[0] != forgeSubject(forgemsg.OwedEndedEvent) {
|
||||
t.Fatalf("%s: %v, said %v; want one owed.ended", c.name, err, pub.subjects)
|
||||
}
|
||||
e, err := forgemsg.DecodeOwedEnded(pub.data[0])
|
||||
if err != nil || e.Outcome != c.outcome || e.Of != c.of {
|
||||
t.Errorf("%s: said %+v %v, want %s of %s", c.name, e, err, c.outcome, c.of)
|
||||
}
|
||||
if (c.call == "") != (len(f.calls) == 0) || (c.call != "" && f.calls[0] != c.call) {
|
||||
t.Errorf("%s: called %v, want %q", c.name, f.calls, c.call)
|
||||
}
|
||||
if v, ok := kept[performedKey(c.of, e.ID)]; ok != c.kept {
|
||||
t.Errorf("%s: kept %v (%s), want %v", c.name, ok, v, c.kept)
|
||||
} else if ok {
|
||||
var p Performed
|
||||
if err := json.Unmarshal(v, &p); err != nil || p.Ended.Outcome != c.outcome || !json.Valid(p.Owed) {
|
||||
t.Errorf("%s: kept %s, want the outcome and the owed event", c.name, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
var tools []string
|
||||
for _, c := range f.calls {
|
||||
tools = append(tools, c.key)
|
||||
}
|
||||
if got := strings.Join(tools, " "); got != "gitea.gitea_delivery_view gitea.gitea_note_append gitea.gitea_add_comment" {
|
||||
t.Errorf("calls = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARefusedActIsWrittenOnForgeOnceAndNotRetried(t *testing.T) { // 1341/AC3
|
||||
f := &forgeFake{refuse: map[string]string{"gitea.gitea_add_comment": "the pull request is locked"}}
|
||||
p, pub, _, _ := newPerformer(f)
|
||||
key, v := owedKV(t, "novox/mesh-delivery", 12, "comment.1", seat.OwedByRepositoryNumberHeadActValue{Act: meshdeliveryclient.OwedComment, Text: "hello"})
|
||||
p.owed(context.Background(), key, 3, v)
|
||||
p.owed(context.Background(), key, 3, v)
|
||||
if len(f.calls) != 1 {
|
||||
t.Errorf("the refused act was tried %d times at one revision", len(f.calls))
|
||||
}
|
||||
if len(pub.subjects) != 1 || pub.subjects[0] != "mesh.stream.mesh-delivery-forge-bridge.event.forge.novox.mesh-delivery.refused" {
|
||||
t.Fatalf("published on %v", pub.subjects)
|
||||
}
|
||||
m, err := forgemsg.DecodeForge(pub.data[0])
|
||||
if err != nil || m.Kind != forgemsg.ForgeRefused || m.Act != "comment.1" || m.Number != 12 || m.Head != headA || m.Words != "the pull request is locked" {
|
||||
t.Errorf("message %+v err %v", m, err)
|
||||
}
|
||||
f.refuse = nil
|
||||
p.owed(context.Background(), key, 4, v) // only a new revision tries again
|
||||
if len(f.calls) != 2 {
|
||||
t.Errorf("a new revision was not tried: %d calls", len(f.calls))
|
||||
}
|
||||
}
|
||||
// Design 47 *The switch carries what is owed*: an act the gate switched from its old state is performed only when the old
|
||||
// bridge's own bookkeeping does not name its key at its revision or later; one it names is said performed, not done.
|
||||
t.Run("a switched act the old bridge performed", func(t *testing.T) {
|
||||
var owed map[string]any
|
||||
if err := json.Unmarshal(gate("owed is said, a merge"), &owed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, err := meshdeliveryclient.SwitchedActID(meshdeliveryclient.SwitchedMerges, "feat~x", 7)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owed["id"] = id
|
||||
data, _ := json.Marshal(owed)
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
kept string // the old bridge's merges.feat~x, "" for none
|
||||
called bool
|
||||
}{
|
||||
{"kept at its revision", "7", false},
|
||||
{"kept at a later revision", "9", false},
|
||||
{"kept at an earlier revision", "6", true},
|
||||
{"not kept", "", true},
|
||||
} {
|
||||
f := &seatFake{answers: map[string]json.RawMessage{"seat:git.merge-pull": answer("merge-pull", "merge-pull answers")}}
|
||||
o, pub, kept := newOwing(f)
|
||||
if c.kept != "" {
|
||||
kept["merges.feat~x"] = []byte(c.kept)
|
||||
}
|
||||
if _, err := o.heard(context.Background(), seat.Seat, data); err != nil || len(pub.data) != 1 {
|
||||
t.Fatalf("%s: %v, said %d", c.name, err, len(pub.data))
|
||||
}
|
||||
e, _ := forgemsg.DecodeOwedEnded(pub.data[0])
|
||||
if (len(f.calls) == 1) != c.called || e.Outcome != forgemsg.OwedPerformed || e.ID != id {
|
||||
t.Errorf("%s: called %v, said %+v; want called %v and performed", c.name, f.calls, e, c.called)
|
||||
}
|
||||
if _, ok := kept[performedKey(seat.Seat, id)]; !ok {
|
||||
t.Errorf("%s: the outcome is not kept under the act's id", c.name)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
func TestAnOwedValueThatCannotBeReadIsLoggedAndNotRetried(t *testing.T) {
|
||||
f := &forgeFake{}
|
||||
p, pub, done, logs := newPerformer(f)
|
||||
p.owed(context.Background(), "not-a-key", 2, []byte(`{}`))
|
||||
if len(f.calls) != 0 || len(pub.subjects) != 0 {
|
||||
t.Errorf("calls %v published %v", f.calls, pub.subjects)
|
||||
}
|
||||
if !strings.Contains(logs.String(), "not-a-key") {
|
||||
t.Errorf("log %q does not name the key", logs.String())
|
||||
}
|
||||
if rev, ok := done["owed.not-a-key"]; !ok || rev != 2 {
|
||||
t.Errorf("revision kept = %v %v", rev, ok)
|
||||
}
|
||||
}
|
||||
t.Run("heard from a seat's stream owed", func(t *testing.T) {
|
||||
f := &seatFake{answers: map[string]json.RawMessage{"seat:git.merge-pull": answer("merge-pull", "merge-pull answers")}}
|
||||
o, pub, _ := newOwing(f)
|
||||
m := &fakeMsg{subject: "mesh.seat.mesh-delivery.event.owed.novox/mesh-sdk.178", data: gate("owed is said, a merge")}
|
||||
o.hear(context.Background(), seat.Seat, m)
|
||||
again := &fakeMsg{subject: m.subject, data: m.data}
|
||||
o.hear(context.Background(), seat.Seat, again)
|
||||
if m.acked != 1 || again.acked != 1 || len(f.calls) != 1 || len(pub.data) != 1 {
|
||||
t.Errorf("an act said twice: acked %d and %d, called %v, said %d; want performed and said once, both acknowledged",
|
||||
m.acked, again.acked, f.calls, len(pub.data))
|
||||
}
|
||||
pub.err = errors.New("no responders")
|
||||
lost := &fakeMsg{subject: m.subject, data: gate("owed is said, a status")}
|
||||
o.hear(context.Background(), seat.Seat, lost)
|
||||
if lost.acked != 0 || len(lost.nak) != 1 {
|
||||
t.Errorf("an outcome not said: acked %d, nak %v; want it heard again", lost.acked, lost.nak)
|
||||
}
|
||||
})
|
||||
|
||||
func mergeKV(t *testing.T, m seat.MergesByGroupValue) []byte {
|
||||
t.Helper()
|
||||
v, err := meshdeliveryclient.EncodeMerge(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
t.Run("asked again, dropped, and its backlog read by hand", func(t *testing.T) {
|
||||
groupsAnswer := gitfixture.Seat(t, seat.Seat, "verbs", "groups answers a group with its members and acts", "verb:groups.answer")[0]
|
||||
f := &seatFake{answers: map[string]json.RawMessage{"seat:git.merge-pull": answer("merge-pull", "merge-pull answers"),
|
||||
"seat:mesh-delivery.groups": groupsAnswer, "seat:git.status": answer("status", "status answers")}}
|
||||
o, pub, kept := newOwing(f)
|
||||
merge := "merge.feat=47-G.0123456789ab.novox/mesh-sdk#178"
|
||||
if _, err := o.heard(context.Background(), seat.Seat, gate("owed is said, a merge")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backlog := func(context.Context) []groups.Backlog { return []groups.Backlog{{Stream: seat.StreamOwed, Waiting: 2}} }
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
ask groups.OwedAsk
|
||||
calls, said int
|
||||
outcome, answers string
|
||||
}{
|
||||
{"again, from performed", groups.OwedAsk{Kind: groups.OwedAgain, Of: seat.Seat, ID: merge}, 2, 2, forgemsg.OwedPerformed, "performed"},
|
||||
// The fixture's status is in the groups only: the bridge never heard it.
|
||||
{"again, from the groups", groups.OwedAsk{Kind: groups.OwedAgain, Of: seat.Seat, ID: "status.novox/mesh-sdk#178.aaaaaaaaaaaa.mesh/merge-gate"}, 4, 3, forgemsg.OwedPerformed, "performed"},
|
||||
{"dropped", groups.OwedAsk{Kind: groups.OwedDrop, Of: seat.Seat, ID: merge, Why: "merged by hand", By: "lead@shanks"}, 4, 4, forgemsg.OwedDropped, "merged by hand"},
|
||||
{"again, an act nobody holds", groups.OwedAsk{Kind: groups.OwedAgain, Of: checker.Seat, ID: "x"}, 4, 4, "", "checker says"},
|
||||
{"the backlog", groups.OwedAsk{Kind: groups.OwedBacklog}, 4, 4, "", "backlog"},
|
||||
} {
|
||||
a := o.answer(context.Background(), c.ask, backlog)
|
||||
if !a.Done() || a.Outcome != c.outcome || !strings.Contains(a.Answer, c.answers) || len(f.calls) != c.calls || len(pub.data) != c.said {
|
||||
t.Errorf("%s: %+v, called %v, said %d; want %q naming %q, %d calls, %d said", c.name, a, f.calls, len(pub.data), c.outcome, c.answers, c.calls, c.said)
|
||||
}
|
||||
if c.ask.Kind == groups.OwedBacklog && (len(a.Backlog) != 1 || a.Backlog[0].Waiting != 2) {
|
||||
t.Errorf("%s: %+v", c.name, a.Backlog)
|
||||
}
|
||||
}
|
||||
var p Performed
|
||||
if err := json.Unmarshal(kept[performedKey(seat.Seat, merge)], &p); err != nil || p.Ended.Outcome != forgemsg.OwedDropped {
|
||||
t.Errorf("performed after the drop: %+v %v", p, err)
|
||||
}
|
||||
})
|
||||
|
||||
func twoMembers(a, b meshdeliveryclient.MergeState) seat.MergesByGroupValue {
|
||||
return seat.MergesByGroupValue{Group: "g", Ask: "merge.g.0123456789ab",
|
||||
Approved: map[string]string{meshdeliveryclient.ApprovedKey("novox/mesh-sdk", 4): headB, meshdeliveryclient.ApprovedKey("novox/mesh-delivery", 12): headA},
|
||||
Pulls: []seat.MergesByGroupValuePullsItem{
|
||||
{Repository: "novox/mesh-delivery", Number: 12, Head: headA, State: string(a), Commit: map[bool]string{true: "c1"}[a == meshdeliveryclient.MergeMerged]},
|
||||
{Repository: "novox/mesh-sdk", Number: 4, Head: headB, State: string(b), Commit: map[bool]string{true: "c2"}[b == meshdeliveryclient.MergeMerged]},
|
||||
}}
|
||||
}
|
||||
|
||||
func TestAMergeRevisionMergesOnlyTheMemberHanded(t *testing.T) { // 1342/AC1
|
||||
f := &forgeFake{}
|
||||
p, _, _, _ := newPerformer(f)
|
||||
m := twoMembers(meshdeliveryclient.MergeMerging, meshdeliveryclient.MergeMerged)
|
||||
p.merges(context.Background(), "g", 5, mergeKV(t, m))
|
||||
want := `{"approved_heads":["novox/mesh-delivery@` + headA + `","novox/mesh-sdk@` + headB + `"],"head":"` + headA + `","number":12,"owner":"novox","repo":"mesh-delivery"}`
|
||||
if len(f.calls) != 1 || f.calls[0].key != "gitea.gitea_merge_pull_request" || f.calls[0].body != want {
|
||||
t.Fatalf("calls = %+v", f.calls)
|
||||
}
|
||||
p.merges(context.Background(), "g", 5, mergeKV(t, m))
|
||||
if len(f.calls) != 1 {
|
||||
t.Errorf("a revision merged twice: %+v", f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARefusedMergeIsWrittenOnForgeOnceAndNotRetried(t *testing.T) { // 1342/AC2
|
||||
f := &forgeFake{refuse: map[string]string{"gitea.gitea_merge_pull_request": "novox/mesh-delivery#12 is not merged: its head moved"}}
|
||||
p, pub, _, _ := newPerformer(f)
|
||||
v := mergeKV(t, twoMembers(meshdeliveryclient.MergeMerging, meshdeliveryclient.MergeMerged))
|
||||
p.merges(context.Background(), "g", 5, v)
|
||||
p.merges(context.Background(), "g", 5, v)
|
||||
if len(f.calls) != 1 || len(pub.data) != 1 {
|
||||
t.Fatalf("calls %d messages %d, want one each", len(f.calls), len(pub.data))
|
||||
}
|
||||
m, err := forgemsg.DecodeForge(pub.data[0])
|
||||
if err != nil || m.Kind != forgemsg.ForgeRefused || m.Repository != "novox/mesh-delivery" || m.Number != 12 || m.Head != headA ||
|
||||
m.Act != "merge" || m.Words != "novox/mesh-delivery#12 is not merged: its head moved" {
|
||||
t.Errorf("message %+v err %v", m, err)
|
||||
}
|
||||
f.refuse = nil
|
||||
p.merges(context.Background(), "g", 6, v)
|
||||
if len(f.calls) != 2 {
|
||||
t.Errorf("a new revision was not tried: %d calls", len(f.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMergeWithTwoHandedStopsAtTheFirstRefusal(t *testing.T) { // ADR 0441 §3
|
||||
f := &forgeFake{refuse: map[string]string{"gitea.gitea_merge_pull_request": "no"}}
|
||||
p, pub, _, _ := newPerformer(f)
|
||||
p.merges(context.Background(), "g", 1, mergeKV(t, twoMembers(meshdeliveryclient.MergeMerging, meshdeliveryclient.MergeMerging)))
|
||||
if len(f.calls) != 1 || len(pub.data) != 1 {
|
||||
t.Errorf("calls %d messages %d, want the first refusal to stop the pass", len(f.calls), len(pub.data))
|
||||
}
|
||||
t.Run("caught up from the groups", func(t *testing.T) {
|
||||
groups := gitfixture.Seat(t, seat.Seat, "verbs", "groups answers a group with its members and acts", "verb:groups.answer")[0]
|
||||
f := &seatFake{answers: map[string]json.RawMessage{"seat:mesh-delivery.groups": groups,
|
||||
"seat:git.status": answer("status", "status answers")}}
|
||||
o, pub, kept := newOwing(f)
|
||||
// The fixture's merge has its outcome recorded; its status is said and unanswered.
|
||||
if n, err := o.catchUp(context.Background()); err != nil || n != 1 || len(pub.data) != 1 || f.calls[1] != "seat:git.status" {
|
||||
t.Fatalf("catch-up: %d %v, called %v; want the one unanswered status performed", n, err, f.calls)
|
||||
}
|
||||
if n, err := o.catchUp(context.Background()); err != nil || n != 0 || len(f.calls) != 3 {
|
||||
t.Errorf("catch-up again: %d %v, called %v; want nothing performed that performed holds", n, err, f.calls)
|
||||
}
|
||||
if len(kept) != 1 {
|
||||
t.Errorf("kept %v, want the status", kept)
|
||||
}
|
||||
// The seat answers a group it does not know with no groups, never an error (W11.10): nothing is performed and
|
||||
// nothing fails; a groups the seat leaves unanswered is the catch-up's error.
|
||||
none := gitfixture.Seat(t, seat.Seat, "verbs", "groups answers a group it does not know with no groups", "verb:groups.answer")[0]
|
||||
for _, row := range []struct {
|
||||
answer json.RawMessage
|
||||
fails bool
|
||||
}{{none, false}, {nil, true}} {
|
||||
f := &seatFake{answers: map[string]json.RawMessage{}}
|
||||
if row.answer != nil {
|
||||
f.answers["seat:mesh-delivery.groups"] = row.answer
|
||||
}
|
||||
o, pub, _ := newOwing(f)
|
||||
n, err := o.catchUp(context.Background())
|
||||
if (err != nil) != row.fails || n != 0 || len(pub.data) != 0 {
|
||||
t.Errorf("catch-up from %s: %d performed, %v; want none performed, failing %v", row.answer, n, err, row.fails)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,11 +8,13 @@ import (
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery"
|
||||
|
||||
"mesh-delivery-forge-bridge/internal/groups"
|
||||
)
|
||||
|
||||
// endAsked answers one value of the state end-asks (the tool bridge_group_end_again, ADR 0633 §4): a value not yet answered
|
||||
// says the group's end again from the gate's merges, with the ask's time appended to the message id so the stream's duplicate
|
||||
// says the group's end again from the seat's groups, with the ask's time appended to the message id so the stream's duplicate
|
||||
// window does not drop it, and is written back by compare-and-set at its revision with whether it was written. A value
|
||||
// answered already (this bridge's own write) is left.
|
||||
func (g *grouper) endAsked(ctx context.Context, kv jetstream.KeyValue, key string, rev uint64, value []byte) {
|
||||
@@ -24,7 +26,7 @@ func (g *grouper) endAsked(ctx context.Context, kv jetstream.KeyValue, key strin
|
||||
if a.Done() {
|
||||
return
|
||||
}
|
||||
a = g.answerEnd(ctx, key, a, time.Now())
|
||||
a = g.answerEnd(ctx, a, time.Now())
|
||||
g.logger.Printf("the end of the group %q said again by %s (%s): %s", a.Group, a.By, a.Why, a.Answer)
|
||||
data, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
@@ -35,27 +37,31 @@ func (g *grouper) endAsked(ctx context.Context, kv jetstream.KeyValue, key strin
|
||||
}
|
||||
}
|
||||
|
||||
// readMerges reads one key of the gate's merges state.
|
||||
func (g *grouper) readMerges(ctx context.Context, key string) ([]byte, uint64, bool, error) {
|
||||
if g.merges == nil {
|
||||
return nil, 0, false, errors.New("the bridge does not read the gate's merges (its bucket was not there at the start)")
|
||||
}
|
||||
value, rev, found, err := g.merges(ctx, key)
|
||||
return value, rev, found, err
|
||||
}
|
||||
|
||||
// answerEnd says the group's end again for an ask and returns the ask with its answer.
|
||||
func (g *grouper) answerEnd(ctx context.Context, key string, a groups.EndAsk, at time.Time) groups.EndAsk {
|
||||
func (g *grouper) answerEnd(ctx context.Context, a groups.EndAsk, at time.Time) groups.EndAsk {
|
||||
a.Answered = at.UTC()
|
||||
switch merges, mrev, found, err := g.readMerges(ctx, key); {
|
||||
case err != nil:
|
||||
a.Answer = "the gate's merges " + key + " cannot be read: " + err.Error()
|
||||
case !found:
|
||||
a.Answer = "the gate holds no merge of the group " + a.Group
|
||||
case g.end(ctx, key, mrev, merges, groups.AgainStamp(a.At)):
|
||||
if g.groups == nil {
|
||||
a.Answer = "the bridge reads no groups"
|
||||
return a
|
||||
}
|
||||
of, err := g.groups(ctx)
|
||||
if err != nil {
|
||||
a.Answer = "the groups cannot be read: " + err.Error()
|
||||
return a
|
||||
}
|
||||
m, ok := seat.Group{}, false
|
||||
for _, c := range of {
|
||||
if c.ID == a.Group {
|
||||
m, ok = c, true
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case !ok || len(m.Members) < 2:
|
||||
a.Answer = "the seat " + seat.Seat + " holds no group " + a.Group + " of more than one member"
|
||||
case g.end(ctx, m, groups.AgainStamp(a.At)):
|
||||
a.Written, a.Answer = true, "the end of the group is written again"
|
||||
default:
|
||||
a.Answer = "the end is not written: a member is not merged or its merged message is not written, or FORGE refused it (the bridge's journal says which)"
|
||||
a.Answer = "the end is not written: a member's merged message is not written, or FORGE refused it (the bridge's journal says which)"
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
@@ -2,37 +2,40 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"slices"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/trunk"
|
||||
|
||||
groupkeys "mesh-delivery-forge-bridge/internal/groups"
|
||||
)
|
||||
|
||||
// A delivery group's merges reach the indexer together (novox/hq ADR 0633 §4): the bridge sets the group on each
|
||||
// member's merged message, from the gate's merges state it reads (ADR 0437 §8), and once every approved member is
|
||||
// merged and its merged message written on FORGE it says the group's end, forge.group.merged, once.
|
||||
// member's merged message, from the seat mesh-delivery's verb groups (design 47 *A group's merges are taken in together*;
|
||||
// it reads no state of the gate's), and once every member's merged message is written on FORGE it says the group's end,
|
||||
// forge.group.merged, once, after the last of them.
|
||||
//
|
||||
// What it keeps of it is in its state performed, beside the gate's keys it acted on: `published.<hash>` for each
|
||||
// member's merged message written (the hash is the first 16 hex digits of the sha256 of `<repository>#<number>@<merge>`),
|
||||
// and `groups.<merges key>` once the group's end is written, its value the revision of the gate's merges key it was
|
||||
// worked out from. A group whose end is not written yet is open (bridge_groups reads them).
|
||||
// What it keeps of it is in its state performed: `published.<hash>` for each member's merged message written, its value
|
||||
// the merge commit it named (the hash is the first 16 hex digits of the sha256 of `<repository>#<number>`), and
|
||||
// `groups.<group>` once the group's end is written, its value the last member's merge commit. A group whose end is not
|
||||
// written yet is open (bridge_groups reads them).
|
||||
|
||||
// merges reads one key of the gate's merges state.
|
||||
type mergesRead func(ctx context.Context, key string) (value []byte, rev uint64, found bool, err error)
|
||||
// groupsRead is every delivery group the seat answers.
|
||||
type groupsRead func(ctx context.Context) ([]seat.Group, error)
|
||||
|
||||
// strs is performed as string values by key: the grouper's `published.` and `groups.` keys.
|
||||
type strs interface {
|
||||
Get(ctx context.Context, key string) (value string, found bool, err error)
|
||||
Put(ctx context.Context, key, value string) error
|
||||
}
|
||||
|
||||
// grouper sets a member's group and says a group's end.
|
||||
type grouper struct {
|
||||
merges mergesRead
|
||||
groups groupsRead
|
||||
send func(ctx context.Context, subject string, data []byte, id string) error // writes on FORGE with the message id
|
||||
done revisions
|
||||
done strs
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
@@ -44,63 +47,40 @@ func sendVia(pub publisher) func(context.Context, string, []byte, string) error
|
||||
}
|
||||
}
|
||||
|
||||
func kvMerges(kv jetstream.KeyValue) mergesRead {
|
||||
return func(ctx context.Context, key string) ([]byte, uint64, bool, error) {
|
||||
e, err := kv.Get(ctx, key)
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return nil, 0, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, 0, false, err
|
||||
}
|
||||
return e.Value(), e.Revision(), true, nil
|
||||
}
|
||||
// groupsVia reads the groups through the seat mesh-delivery's verb groups.
|
||||
func groupsVia(call caller) groupsRead {
|
||||
return func(ctx context.Context) ([]seat.Group, error) { return groupkeys.Read(ctx, call) }
|
||||
}
|
||||
|
||||
// groupOf is the gate's merge of the group a merged message's branch names, when the message's pull request is one of
|
||||
// its approved members and the group has more than one: a lone pull request is no group.
|
||||
func (g *grouper) groupOf(ctx context.Context, f forgemsg.Forge) (seat.MergesByGroupValue, uint64, bool) {
|
||||
if g == nil || g.merges == nil || f.Branch == "" {
|
||||
return seat.MergesByGroupValue{}, 0, false
|
||||
// groupOf is the group a merged message's branch names, when the seat names the message's pull request among its
|
||||
// members and the group has more than one: a lone pull request is no group.
|
||||
func (g *grouper) groupOf(ctx context.Context, f forgemsg.Forge) (seat.Group, bool) {
|
||||
if g == nil || g.groups == nil || f.Branch == "" {
|
||||
return seat.Group{}, false
|
||||
}
|
||||
key, err := meshdeliveryclient.MergesKey(f.Branch)
|
||||
of, err := g.groups(ctx)
|
||||
if err != nil {
|
||||
return seat.MergesByGroupValue{}, 0, false
|
||||
g.logger.Printf("%s#%d: the groups cannot be read, so its merged message names no group: %v", f.Repository, f.Number, err)
|
||||
return seat.Group{}, false
|
||||
}
|
||||
value, rev, found, err := g.merges(ctx, key)
|
||||
if err != nil {
|
||||
g.logger.Printf("%s#%d: the gate's merges.%s cannot be read, so its merged message names no group: %v", f.Repository, f.Number, key, err)
|
||||
return seat.MergesByGroupValue{}, 0, false
|
||||
}
|
||||
if !found {
|
||||
return seat.MergesByGroupValue{}, 0, false
|
||||
}
|
||||
m, err := meshdeliveryclient.DecodeMerge(value)
|
||||
if err != nil {
|
||||
g.logger.Printf("%s#%d: the gate's merges.%s is not a merge, so its merged message names no group: %v", f.Repository, f.Number, key, err)
|
||||
return seat.MergesByGroupValue{}, 0, false
|
||||
}
|
||||
if _, ok := m.Approved[meshdeliveryclient.ApprovedKey(f.Repository, f.Number)]; !ok || len(m.Approved) < 2 {
|
||||
return seat.MergesByGroupValue{}, 0, false
|
||||
}
|
||||
return m, rev, true
|
||||
return groupkeys.Named(of, f.Branch, f.Repository, f.Number)
|
||||
}
|
||||
|
||||
// member sets the group and its approved members on a merged message's body (ADR 0633 §4); any other body, and a
|
||||
// merge of no group, is left as it is.
|
||||
// member sets the group and its members on a merged message's body (ADR 0633 §4); any other body, and a merge of no
|
||||
// group, is left as it is.
|
||||
func (g *grouper) member(ctx context.Context, data []byte) ([]byte, forgemsg.Forge, bool) {
|
||||
f, err := forgemsg.DecodeForge(data)
|
||||
if err != nil || f.Kind != forgemsg.ForgeMerged {
|
||||
return data, f, false
|
||||
}
|
||||
m, _, ok := g.groupOf(ctx, f)
|
||||
m, ok := g.groupOf(ctx, f)
|
||||
if !ok {
|
||||
return data, f, false
|
||||
}
|
||||
f.Group, f.Members = m.Group, groupkeys.MembersOf(m)
|
||||
f.Group, f.Members = m.ID, groupkeys.MembersOf(m)
|
||||
out, err := forgemsg.EncodeForge(f)
|
||||
if err != nil {
|
||||
g.logger.Printf("%s#%d: its merged message cannot name the group %q, written without: %v", f.Repository, f.Number, m.Group, err)
|
||||
g.logger.Printf("%s#%d: its merged message cannot name the group %q, written without: %v", f.Repository, f.Number, m.ID, err)
|
||||
return data, f, false
|
||||
}
|
||||
return out, f, true
|
||||
@@ -108,73 +88,55 @@ func (g *grouper) member(ctx context.Context, data []byte) ([]byte, forgemsg.For
|
||||
|
||||
// published keeps that a member's merged message is written, and says the group's end when it was the last.
|
||||
func (g *grouper) published(ctx context.Context, f forgemsg.Forge) {
|
||||
if err := g.done.Put(ctx, groupkeys.PublishedKey(f.Repository, f.Number, f.Merge), 1); err != nil {
|
||||
g.logger.Printf("%s#%d: that its merged message is written is not kept, so the group's end waits for the next act: %v", f.Repository, f.Number, err)
|
||||
if err := g.done.Put(ctx, groupkeys.PublishedKey(f.Repository, f.Number), f.Merge); err != nil {
|
||||
g.logger.Printf("%s#%d: that its merged message is written is not kept, so the group's end waits for bridge_group_end_again: %v", f.Repository, f.Number, err)
|
||||
return
|
||||
}
|
||||
key, err := meshdeliveryclient.MergesKey(f.Branch)
|
||||
if err != nil {
|
||||
return
|
||||
if m, ok := g.groupOf(ctx, f); ok {
|
||||
g.end(ctx, m, "")
|
||||
}
|
||||
value, rev, found, err := g.merges(ctx, key)
|
||||
if err != nil || !found {
|
||||
return
|
||||
}
|
||||
g.ended(ctx, key, rev, value)
|
||||
}
|
||||
|
||||
// ended says the group's end (forge.group.merged) once every approved member of the gate's merge is merged and its
|
||||
// merged message written, after the last of them; never on a refusal (ADR 0441 §3: a held group merges on from it).
|
||||
// It is written once per group: `groups.<key>` in performed says it was. again says it is written again by hand
|
||||
// end says the group's end (forge.group.merged) once every member's merged message is written, after the last of them;
|
||||
// never on a refusal (ADR 0441 §3: a held group merges on from it), since a refused member's message is never written. It
|
||||
// is written once per group: `groups.<group>` in performed says it was. again says it is written again by hand
|
||||
// (bridge_group_end_again), with the time appended to the message id, so the duplicate window does not drop it.
|
||||
func (g *grouper) ended(ctx context.Context, key string, rev uint64, value []byte) {
|
||||
g.end(ctx, key, rev, value, "")
|
||||
}
|
||||
|
||||
func (g *grouper) end(ctx context.Context, key string, rev uint64, value []byte, again string) bool {
|
||||
m, err := meshdeliveryclient.DecodeMerge(value)
|
||||
if err != nil || len(m.Approved) < 2 {
|
||||
func (g *grouper) end(ctx context.Context, m seat.Group, again string) bool {
|
||||
if len(m.Members) < 2 {
|
||||
return false
|
||||
}
|
||||
key, err := groupkeys.Key(m.ID)
|
||||
if err != nil {
|
||||
g.logger.Printf("the group %q cannot be kept, so its end is not said: %v", m.ID, err)
|
||||
return false
|
||||
}
|
||||
if again == "" {
|
||||
if _, seen, err := g.done.Get(ctx, groupkeys.Key(key)); err == nil && seen {
|
||||
if _, seen, err := g.done.Get(ctx, key); err == nil && seen {
|
||||
return false
|
||||
}
|
||||
}
|
||||
end := forgemsg.ForgeGroupEnd{Group: m.Group}
|
||||
for _, member := range groupkeys.MembersOf(m) {
|
||||
at := slices.IndexFunc(m.Pulls, func(p seat.MergesByGroupValuePullsItem) bool {
|
||||
return trunk.SameRepository(p.Repository, member.Repository) && int(p.Number) == member.Number
|
||||
})
|
||||
if at < 0 || m.Pulls[at].Commit == "" {
|
||||
return false // not every member is merged (a merge commit is written only for a merged pull): the group stays gathered
|
||||
}
|
||||
if _, ok, err := g.done.Get(ctx, groupkeys.PublishedKey(member.Repository, member.Number, m.Pulls[at].Commit)); err != nil || !ok {
|
||||
// The members in the group's order, which the gate merges them in (ADR 0441 §5), so the last commit names the end.
|
||||
end := forgemsg.ForgeGroupEnd{Group: m.ID}
|
||||
for _, member := range m.Members {
|
||||
merge, ok, err := g.done.Get(ctx, groupkeys.PublishedKey(member.Repository, int(member.Number)))
|
||||
if err != nil || !ok || merge == "" {
|
||||
return false // its merged message is not written yet: said when it is (published)
|
||||
}
|
||||
end.Members = append(end.Members, forgemsg.GroupMerge{Repository: member.Repository, Number: member.Number, Merge: m.Pulls[at].Commit})
|
||||
end.Members = append(end.Members, forgemsg.GroupMerge{Repository: member.Repository, Number: int(member.Number), Merge: merge})
|
||||
}
|
||||
// The members in the order the gate merged them, so the last commit names the end.
|
||||
slices.SortStableFunc(end.Members, func(a, b forgemsg.GroupMerge) int {
|
||||
return slices.IndexFunc(m.Pulls, func(p seat.MergesByGroupValuePullsItem) bool { return p.Commit == a.Merge }) -
|
||||
slices.IndexFunc(m.Pulls, func(p seat.MergesByGroupValuePullsItem) bool { return p.Commit == b.Merge })
|
||||
})
|
||||
data, err := forgemsg.EncodeForgeGroupEnd(end)
|
||||
if err != nil {
|
||||
g.logger.Printf("the end of the group %q cannot be written: %v", m.Group, err)
|
||||
g.logger.Printf("the end of the group %q cannot be written: %v", m.ID, err)
|
||||
return false
|
||||
}
|
||||
id := groupkeys.EndID(m.Group, end.Members[len(end.Members)-1].Merge, "")
|
||||
if again != "" {
|
||||
id += "." + again
|
||||
}
|
||||
if err := g.send(ctx, forgeSubject(forgemsg.ForgeGroupEndEvent), data, id); err != nil {
|
||||
g.logger.Printf("the end of the group %q is not written on FORGE, written at the gate's next write or by bridge_group_end_again: %v", m.Group, err)
|
||||
last := end.Members[len(end.Members)-1].Merge
|
||||
if err := g.send(ctx, forgeSubject(forgemsg.ForgeGroupEndEvent), data, groupkeys.EndID(m.ID, last, again)); err != nil {
|
||||
g.logger.Printf("the end of the group %q is not written on FORGE, written by bridge_group_end_again: %v", m.ID, err)
|
||||
return false
|
||||
}
|
||||
if err := g.done.Put(ctx, groupkeys.Key(key), rev); err != nil {
|
||||
g.logger.Printf("the end of the group %q is written and not kept: the stream's duplicate window drops a second one: %v", m.Group, err)
|
||||
if err := g.done.Put(ctx, key, last); err != nil {
|
||||
g.logger.Printf("the end of the group %q is written and not kept: the stream's duplicate window drops a second one: %v", m.ID, err)
|
||||
}
|
||||
g.logger.Printf("the end of the group %q is written: %d members merged, the last %s", m.Group, len(end.Members), end.Members[len(end.Members)-1].Merge)
|
||||
g.logger.Printf("the end of the group %q is written: %d members merged, the last %s", m.ID, len(end.Members), last)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
|
||||
@@ -26,32 +26,20 @@ func (p *idPub) send(_ context.Context, subject string, data []byte, id string)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAGroupsMembersNameItAndItsEndIsSaidOnce(t *testing.T) { // 1585/T16, T17, T19 (ADR 0633 §4)
|
||||
// 1585/T16, T17, T19 (ADR 0633 §4), acceptance 49: a member's merged message names its group from the seat
|
||||
// mesh-delivery's verb groups, the group's end is said once after the last member's merged message, and said again by hand.
|
||||
func TestAGroupsMembersNameItAndItsEndIsSaidOnce(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h1, h2 := strings.Repeat("1", 40), strings.Repeat("2", 40)
|
||||
c1, c2 := strings.Repeat("c", 40), strings.Repeat("d", 40)
|
||||
merge := func(states ...seat.MergesByGroupValuePullsItem) []byte {
|
||||
v, err := meshdeliveryclient.EncodeMerge(seat.MergesByGroupValue{Group: "feat/take-in", Ask: "ask", Pulls: states,
|
||||
Approved: map[string]string{"novox/mesh-sdk@37": h1, "novox/mesh-delivery@9": h2}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
sdk := func(state meshdeliveryclient.MergeState, commit, words string) seat.MergesByGroupValuePullsItem {
|
||||
return seat.MergesByGroupValuePullsItem{Repository: "novox/mesh-sdk", Number: 37, Head: h1, State: string(state), Commit: commit, Words: words}
|
||||
}
|
||||
dlv := func(state meshdeliveryclient.MergeState, commit string) seat.MergesByGroupValuePullsItem {
|
||||
return seat.MergesByGroupValuePullsItem{Repository: "novox/mesh-delivery", Number: 9, Head: h2, State: string(state), Commit: commit}
|
||||
}
|
||||
gate := map[string][]byte{}
|
||||
group := seat.Group{ID: "feat/take-in", State: "merging", Members: []seat.Member{
|
||||
{Repository: "novox/mesh-sdk", Number: 37, Head: h1, State: "merged"}, {Repository: "novox/mesh-delivery", Number: 9, Head: h2, State: "merging"}}}
|
||||
var of []seat.Group
|
||||
var read error
|
||||
var logs bytes.Buffer
|
||||
pub, done := &idPub{}, memRevisions{}
|
||||
pub, done := &idPub{}, memStrs{}
|
||||
g := &grouper{send: pub.send, done: done, logger: log.New(&logs, "", 0),
|
||||
merges: func(_ context.Context, key string) ([]byte, uint64, bool, error) {
|
||||
v, ok := gate[key]
|
||||
return v, 7, ok, nil
|
||||
}}
|
||||
groups: func(context.Context) ([]seat.Group, error) { return of, read }}
|
||||
mergedOf := func(repo string, number int, head, commit string) []byte {
|
||||
data, err := forgemsg.EncodeForge(forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: repo, Branch: "feat/take-in", Number: number, Head: head, Merge: commit, Root: "r"})
|
||||
if err != nil {
|
||||
@@ -59,79 +47,64 @@ func TestAGroupsMembersNameItAndItsEndIsSaidOnce(t *testing.T) { // 1585/T16, T1
|
||||
}
|
||||
return data
|
||||
}
|
||||
// A lone pull request's merged message carries no group.
|
||||
if _, f, ok := g.member(ctx, mergedOf("novox/hq", 4, h1, c1)); ok || f.Group != "" {
|
||||
// A lone pull request's merged message carries no group, and neither does one whose groups cannot be read.
|
||||
of = []seat.Group{group, {ID: "solo", Members: []seat.Member{{Repository: "novox/hq", Number: 4, Head: h1}}}}
|
||||
lone, _ := forgemsg.EncodeForge(forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: "novox/hq", Branch: "solo", Number: 4, Head: h1, Merge: c1, Root: "r"})
|
||||
if _, f, ok := g.member(ctx, lone); ok || f.Group != "" {
|
||||
t.Errorf("a lone pull request names the group %q", f.Group)
|
||||
}
|
||||
// The first member: its message names the group and both members; the group is not ended.
|
||||
gate["feat/take-in"] = merge(sdk(meshdeliveryclient.MergeMerged, c1, ""), dlv(meshdeliveryclient.MergeMerging, ""))
|
||||
read = errors.New("the seat did not answer")
|
||||
if _, f, ok := g.member(ctx, mergedOf("novox/mesh-sdk", 37, h1, c1)); ok || f.Group != "" || !strings.Contains(logs.String(), "the seat did not answer") {
|
||||
t.Errorf("a group read that failed names the group %q: %s", f.Group, logs.String())
|
||||
}
|
||||
read = nil
|
||||
// The first member: its message names the group and both members in the group's order; the group is not ended.
|
||||
data, f, ok := g.member(ctx, mergedOf("novox/mesh-sdk", 37, h1, c1))
|
||||
if back, err := forgemsg.DecodeForge(data); !ok || err != nil || back.Group != "feat/take-in" || len(back.Members) != 2 {
|
||||
if back, err := forgemsg.DecodeForge(data); !ok || err != nil || back.Group != "feat/take-in" || len(back.Members) != 2 || back.Members[0].Number != 37 {
|
||||
t.Fatalf("a member's message: %+v %v", back, err)
|
||||
}
|
||||
g.published(ctx, f)
|
||||
// A refusal of the other member is no end (ADR 0441 §3: the group merges on from it later).
|
||||
gate["feat/take-in"] = merge(sdk(meshdeliveryclient.MergeMerged, c1, ""), seat.MergesByGroupValuePullsItem{Repository: "novox/mesh-delivery", Number: 9, Head: h2, State: string(meshdeliveryclient.MergeRefused), Words: "405"})
|
||||
g.ended(ctx, "feat/take-in", 8, gate["feat/take-in"])
|
||||
// Merged on at the gate before its merged message is written: still no end; then its message ends the group.
|
||||
gate["feat/take-in"] = merge(sdk(meshdeliveryclient.MergeMerged, c1, ""), dlv(meshdeliveryclient.MergeMerged, c2))
|
||||
g.ended(ctx, "feat/take-in", 9, gate["feat/take-in"])
|
||||
if len(pub.subjects) != 0 {
|
||||
t.Fatalf("an end before every member's merged message is written: %v", pub.subjects)
|
||||
if len(pub.subjects) != 0 || done[groupkeys.PublishedKey("novox/mesh-sdk", 37)] != c1 {
|
||||
t.Fatalf("after the first member: ends %v, performed %v", pub.subjects, done)
|
||||
}
|
||||
// The last member's merged message ends the group; a second merged message of it says no second end.
|
||||
_, f, _ = g.member(ctx, mergedOf("novox/mesh-delivery", 9, h2, c2))
|
||||
g.published(ctx, f)
|
||||
g.ended(ctx, "feat/take-in", 10, gate["feat/take-in"]) // a later write of the gate says no second end
|
||||
g.published(ctx, f)
|
||||
if len(pub.subjects) != 1 || pub.subjects[0] != "mesh.stream.mesh-delivery-forge-bridge.event.forge.group.merged" || pub.ids[0] != "group-end.feat/take-in."+c2 {
|
||||
t.Fatalf("ends %v ids %v, want one forge.group.merged with id group-end.feat/take-in.%s", pub.subjects, pub.ids, c2)
|
||||
}
|
||||
end, err := forgemsg.DecodeForgeGroupEnd(pub.data[0])
|
||||
if err != nil || len(end.Members) != 2 || end.Members[0].Merge != c1 || end.Members[1].Merge != c2 {
|
||||
t.Errorf("the end = %+v %v, want both members in their merge order", end, err)
|
||||
t.Errorf("the end = %+v %v, want both members in the group's order", end, err)
|
||||
}
|
||||
if rev, ok := done[groupkeys.Key("feat/take-in")]; !ok || rev != 7 {
|
||||
t.Errorf("performed %v: want groups.feat/take-in kept", done)
|
||||
}
|
||||
// Again by hand: written once more, its id not the first's.
|
||||
if !g.end(ctx, "feat/take-in", 10, gate["feat/take-in"], "20261104T2300") || pub.ids[1] == pub.ids[0] {
|
||||
t.Errorf("the end again by hand: ids %v", pub.ids)
|
||||
}
|
||||
// 1585/T19: an ask to say a group's end again is answered by the bridge from the gate's merges, with the ask's time on
|
||||
// the message id; not written while a member is not merged, with the reason; not for a group the gate does not hold.
|
||||
pull := func(repo string, n int, head string, state meshdeliveryclient.MergeState, commit string) seat.MergesByGroupValuePullsItem {
|
||||
return seat.MergesByGroupValuePullsItem{Repository: repo, Number: int64(n), Head: head, State: string(state), Commit: commit}
|
||||
}
|
||||
askMerge := func(second meshdeliveryclient.MergeState, commit2 string) []byte {
|
||||
v, err := meshdeliveryclient.EncodeMerge(seat.MergesByGroupValue{Group: "feat/take-in", Ask: "ask", Approved: map[string]string{"novox/mesh-sdk@37": h1, "novox/mesh-delivery@9": h2},
|
||||
Pulls: []seat.MergesByGroupValuePullsItem{pull("novox/mesh-sdk", 37, h1, meshdeliveryclient.MergeMerged, c1), pull("novox/mesh-delivery", 9, h2, second, commit2)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return v
|
||||
if key, _ := groupkeys.Key("feat/take-in"); done[key] != c2 {
|
||||
t.Errorf("performed %v: want groups.feat~take-in kept with the last merge", done)
|
||||
}
|
||||
// 1585/T19: an ask to say a group's end again is answered from the seat's groups, with the ask's time on the message id;
|
||||
// not written while a member's merged message is not written, with the reason; not for a group the seat does not hold,
|
||||
// nor when the groups cannot be read.
|
||||
ask := groupkeys.EndAsk{V: 1, Group: "feat/take-in", By: "operator@shanks", Why: "the indexer never took it in", At: time.Date(2026, 11, 4, 23, 0, 0, 123, time.UTC)}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
gate map[string][]byte
|
||||
of []seat.Group
|
||||
read error
|
||||
published bool
|
||||
written bool
|
||||
answer string
|
||||
}{
|
||||
{"merged and published: written again", map[string][]byte{"feat/take-in": askMerge(meshdeliveryclient.MergeMerged, c2)}, true, true, "written again"},
|
||||
{"a member's message not written", map[string][]byte{"feat/take-in": askMerge(meshdeliveryclient.MergeMerged, c2)}, false, false, "not written"},
|
||||
{"a member not merged", map[string][]byte{"feat/take-in": askMerge(meshdeliveryclient.MergeMerging, "")}, true, false, "not written"},
|
||||
{"no merge at the gate", map[string][]byte{}, true, false, "holds no merge"},
|
||||
{"every message written: written again", []seat.Group{group}, nil, true, true, "written again"},
|
||||
{"a member's message not written", []seat.Group{group}, nil, false, false, "not written"},
|
||||
{"no such group at the seat", []seat.Group{{ID: "other", Members: group.Members}}, nil, true, false, "holds no group"},
|
||||
{"the groups cannot be read", nil, errors.New("no answer"), true, false, "cannot be read"},
|
||||
} {
|
||||
pub, done := &idPub{}, memRevisions{}
|
||||
pub, done := &idPub{}, memStrs{}
|
||||
if tc.published {
|
||||
done[groupkeys.PublishedKey("novox/mesh-sdk", 37, c1)], done[groupkeys.PublishedKey("novox/mesh-delivery", 9, c2)] = 1, 1
|
||||
done[groupkeys.PublishedKey("novox/mesh-sdk", 37)], done[groupkeys.PublishedKey("novox/mesh-delivery", 9)] = c1, c2
|
||||
}
|
||||
g := &grouper{send: pub.send, done: done, logger: log.New(&bytes.Buffer{}, "", 0),
|
||||
merges: func(_ context.Context, key string) ([]byte, uint64, bool, error) {
|
||||
v, ok := tc.gate[key]
|
||||
return v, 4, ok, nil
|
||||
}}
|
||||
got := g.answerEnd(ctx, "feat/take-in", ask, ask.At.Add(time.Second))
|
||||
groups: func(context.Context) ([]seat.Group, error) { return tc.of, tc.read }}
|
||||
got := g.answerEnd(ctx, ask, ask.At.Add(time.Second))
|
||||
if got.Written != tc.written || !strings.Contains(got.Answer, tc.answer) || !got.Done() || len(pub.ids) != map[bool]int{true: 1}[tc.written] {
|
||||
t.Errorf("%s: %+v, ids %v", tc.name, got, pub.ids)
|
||||
}
|
||||
|
||||
+352
-336
@@ -7,6 +7,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -20,28 +21,27 @@ import (
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
git "git.novox.be/novox/mesh-delivery/contracts/go/seats/git"
|
||||
seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery"
|
||||
checker "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
"git.novox.be/novox/mesh-sdk/go/keys"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
|
||||
"mesh-delivery-forge-bridge/internal/gitfixture"
|
||||
groupkeys "mesh-delivery-forge-bridge/internal/groups"
|
||||
)
|
||||
|
||||
// busConf is the shape of the composed accounts file (mesh-controller internal/broker): one account with JetStream,
|
||||
// the controller's user, and the bridge's own principal <node>.<module> with the permissions the controller composes for
|
||||
// it, and no others, so a permission the bridge lacks fails a test here and not on the mesh (issue 1279).
|
||||
// busConf is the shape of the composed accounts file (the grants part's buscompose): one account with JetStream, the
|
||||
// controller's user, and the bridge's own principal <node>.<module> with the permissions composed for its manifest, and
|
||||
// no others, so a permission the bridge lacks fails a test here and not on the mesh (issue 1279).
|
||||
//
|
||||
// The gate's buckets mesh-delivery-gate_merges and mesh-delivery-gate_owed are read and watched (the manifest's reads), the
|
||||
// bucket performed is the bridge's own (its state), and the five forge tools of the gate's acts are invoked (issue 1341, 1342).
|
||||
//
|
||||
// The bridge neither reads the checker's owed nor hears its stream (ADR 0521, R4e finding R2-1): the forge module writes
|
||||
// mesh/check from it. The stream checks is made here so a test can say the checker wrote a status.
|
||||
//
|
||||
// The lists are what mesh-controller's broker.PermissionsFor answers (internal/broker/nats.go:577) for a module principal
|
||||
// with the manifest of module.json: consumes the three gitea.pull events, invokes gitea.gitea_open_pulls,
|
||||
// gitea.gitea_commit_trees and gitea.gitea_get_file, state heads, performed, inputs and end-asks (ADR 0633 §5's hand end), and the stream forge; `**` is written `>` on the wire (modstreams.go
|
||||
// WirePattern). The consumer's durable is <node>_<module> on EVENTS (nats.go consumerDurable). The controller's own
|
||||
// user keeps all rights, as it has.
|
||||
// From module.json: the seat git's events heard through its durable on EVENTS; what the gate and the checker owe heard
|
||||
// through its durable on each seat's own stream owed (SEAT_<seat>_owed); the seat git's verbs, the seat mesh-delivery's
|
||||
// groups and the seat mesh-hand-acts' hand-act called on mesh.seat.<seat>.call.<verb>.<token> (or its plain fallback);
|
||||
// its own states heads, performed, inputs, end-asks and owed-asks; its stream forge. It reads no state of the gate's or
|
||||
// the checker's (design 47, acceptance 49 and 53). `**` is written `>` on the wire. The controller's own user keeps all
|
||||
// rights, as it has.
|
||||
const busConf = `
|
||||
listen: 127.0.0.1:-1
|
||||
jetstream { store_dir: %q }
|
||||
@@ -53,38 +53,23 @@ accounts {
|
||||
{ user: "n1.mesh-delivery-forge-bridge", password: "pw"
|
||||
permissions {
|
||||
publish { allow: [
|
||||
"$JS.ACK.EVENTS.n1_mesh-delivery-forge-bridge.>"
|
||||
"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-forge-bridge_heads.>"
|
||||
"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-forge-bridge_heads.>"
|
||||
"$JS.API.CONSUMER.INFO.EVENTS.n1_mesh-delivery-forge-bridge"
|
||||
"$JS.API.CONSUMER.MSG.NEXT.EVENTS.n1_mesh-delivery-forge-bridge"
|
||||
"$JS.API.DIRECT.GET.KV_mesh-delivery-forge-bridge_heads.>"
|
||||
"$JS.API.STREAM.INFO.KV_mesh-delivery-forge-bridge_heads"
|
||||
"$JS.FC.KV_mesh-delivery-forge-bridge_heads.>"
|
||||
"$KV.mesh-delivery-forge-bridge_heads.>"
|
||||
"mesh.mod.gitea.call.gitea_commit_trees.*.n1~mesh-delivery-forge-bridge"
|
||||
"mesh.mod.gitea.call.gitea_commit_trees.n1~mesh-delivery-forge-bridge"
|
||||
"mesh.mod.gitea.call.gitea_open_pulls.*.n1~mesh-delivery-forge-bridge"
|
||||
"mesh.mod.gitea.call.gitea_open_pulls.n1~mesh-delivery-forge-bridge"
|
||||
"mesh.mod.gitea.tool.gitea_commit_trees"
|
||||
"mesh.mod.gitea.tool.gitea_commit_trees.*"
|
||||
"mesh.mod.gitea.call.gitea_get_file.*.n1~mesh-delivery-forge-bridge"
|
||||
"mesh.mod.gitea.call.gitea_get_file.n1~mesh-delivery-forge-bridge"
|
||||
"mesh.mod.gitea.tool.gitea_get_file"
|
||||
"mesh.mod.gitea.tool.gitea_get_file.*"
|
||||
"mesh.mod.gitea.tool.gitea_open_pulls"
|
||||
"mesh.mod.gitea.tool.gitea_open_pulls.*"
|
||||
"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-forge-bridge_performed.>"
|
||||
"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-forge-bridge_inputs.>"
|
||||
"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-forge-bridge_performed.>"
|
||||
"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-forge-bridge_inputs.>"
|
||||
"$JS.API.DIRECT.GET.KV_mesh-delivery-forge-bridge_performed.>"
|
||||
"$JS.API.DIRECT.GET.KV_mesh-delivery-forge-bridge_inputs.>"
|
||||
"$JS.API.STREAM.INFO.KV_mesh-delivery-forge-bridge_performed"
|
||||
"$JS.API.STREAM.INFO.KV_mesh-delivery-forge-bridge_inputs"
|
||||
"$JS.FC.KV_mesh-delivery-forge-bridge_performed.>"
|
||||
"$JS.FC.KV_mesh-delivery-forge-bridge_inputs.>"
|
||||
"$KV.mesh-delivery-forge-bridge_performed.>"
|
||||
"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-forge-bridge_inputs.>"
|
||||
"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-forge-bridge_inputs.>"
|
||||
"$JS.API.DIRECT.GET.KV_mesh-delivery-forge-bridge_inputs.>"
|
||||
"$JS.API.STREAM.INFO.KV_mesh-delivery-forge-bridge_inputs"
|
||||
"$JS.FC.KV_mesh-delivery-forge-bridge_inputs.>"
|
||||
"$KV.mesh-delivery-forge-bridge_inputs.>"
|
||||
"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-forge-bridge_end-asks.>"
|
||||
"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-forge-bridge_end-asks.>"
|
||||
@@ -92,45 +77,51 @@ accounts {
|
||||
"$JS.API.STREAM.INFO.KV_mesh-delivery-forge-bridge_end-asks"
|
||||
"$JS.FC.KV_mesh-delivery-forge-bridge_end-asks.>"
|
||||
"$KV.mesh-delivery-forge-bridge_end-asks.>"
|
||||
"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-gate_merges.>"
|
||||
"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-gate_merges.>"
|
||||
"$JS.API.DIRECT.GET.KV_mesh-delivery-gate_merges.>"
|
||||
"$JS.API.STREAM.INFO.KV_mesh-delivery-gate_merges"
|
||||
"$JS.FC.KV_mesh-delivery-gate_merges.>"
|
||||
"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-gate_owed.>"
|
||||
"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-gate_owed.>"
|
||||
"$JS.API.DIRECT.GET.KV_mesh-delivery-gate_owed.>"
|
||||
"$JS.API.STREAM.INFO.KV_mesh-delivery-gate_owed"
|
||||
"$JS.FC.KV_mesh-delivery-gate_owed.>"
|
||||
"mesh.mod.gitea.call.gitea_merge_pull_request.*.n1~mesh-delivery-forge-bridge"
|
||||
"mesh.mod.gitea.call.gitea_merge_pull_request.n1~mesh-delivery-forge-bridge"
|
||||
"mesh.mod.gitea.tool.gitea_merge_pull_request"
|
||||
"mesh.mod.gitea.tool.gitea_merge_pull_request.*"
|
||||
"mesh.mod.gitea.call.gitea_commit_status.*.n1~mesh-delivery-forge-bridge"
|
||||
"mesh.mod.gitea.call.gitea_commit_status.n1~mesh-delivery-forge-bridge"
|
||||
"mesh.mod.gitea.tool.gitea_commit_status"
|
||||
"mesh.mod.gitea.tool.gitea_commit_status.*"
|
||||
"mesh.mod.gitea.call.gitea_delivery_view.*.n1~mesh-delivery-forge-bridge"
|
||||
"mesh.mod.gitea.call.gitea_delivery_view.n1~mesh-delivery-forge-bridge"
|
||||
"mesh.mod.gitea.tool.gitea_delivery_view"
|
||||
"mesh.mod.gitea.tool.gitea_delivery_view.*"
|
||||
"mesh.mod.gitea.call.gitea_note_append.*.n1~mesh-delivery-forge-bridge"
|
||||
"mesh.mod.gitea.call.gitea_note_append.n1~mesh-delivery-forge-bridge"
|
||||
"mesh.mod.gitea.tool.gitea_note_append"
|
||||
"mesh.mod.gitea.tool.gitea_note_append.*"
|
||||
"mesh.mod.gitea.call.gitea_add_comment.*.n1~mesh-delivery-forge-bridge"
|
||||
"mesh.mod.gitea.call.gitea_add_comment.n1~mesh-delivery-forge-bridge"
|
||||
"mesh.mod.gitea.tool.gitea_add_comment"
|
||||
"mesh.mod.gitea.tool.gitea_add_comment.*"
|
||||
"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-forge-bridge_owed-asks.>"
|
||||
"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-forge-bridge_owed-asks.>"
|
||||
"$JS.API.DIRECT.GET.KV_mesh-delivery-forge-bridge_owed-asks.>"
|
||||
"$JS.API.STREAM.INFO.KV_mesh-delivery-forge-bridge_owed-asks"
|
||||
"$JS.FC.KV_mesh-delivery-forge-bridge_owed-asks.>"
|
||||
"$KV.mesh-delivery-forge-bridge_owed-asks.>"
|
||||
"$JS.ACK.EVENTS.n1_mesh-delivery-forge-bridge.>"
|
||||
"$JS.API.CONSUMER.INFO.EVENTS.n1_mesh-delivery-forge-bridge"
|
||||
"$JS.API.CONSUMER.MSG.NEXT.EVENTS.n1_mesh-delivery-forge-bridge"
|
||||
"$JS.ACK.SEAT_mesh-delivery_owed.n1_mesh-delivery-forge-bridge.>"
|
||||
"$JS.API.CONSUMER.INFO.SEAT_mesh-delivery_owed.n1_mesh-delivery-forge-bridge"
|
||||
"$JS.API.CONSUMER.MSG.NEXT.SEAT_mesh-delivery_owed.n1_mesh-delivery-forge-bridge"
|
||||
"$JS.ACK.SEAT_mesh-delivery-checker_owed.n1_mesh-delivery-forge-bridge.>"
|
||||
"$JS.API.CONSUMER.INFO.SEAT_mesh-delivery-checker_owed.n1_mesh-delivery-forge-bridge"
|
||||
"$JS.API.CONSUMER.MSG.NEXT.SEAT_mesh-delivery-checker_owed.n1_mesh-delivery-forge-bridge"
|
||||
"mesh.seat.git.call.status.n1~mesh-delivery-forge-bridge"
|
||||
"mesh.seat.git.tool.status"
|
||||
"mesh.seat.git.call.comment.n1~mesh-delivery-forge-bridge"
|
||||
"mesh.seat.git.tool.comment"
|
||||
"mesh.seat.git.call.view.n1~mesh-delivery-forge-bridge"
|
||||
"mesh.seat.git.tool.view"
|
||||
"mesh.seat.git.call.note.n1~mesh-delivery-forge-bridge"
|
||||
"mesh.seat.git.tool.note"
|
||||
"mesh.seat.git.call.merge-pull.n1~mesh-delivery-forge-bridge"
|
||||
"mesh.seat.git.tool.merge-pull"
|
||||
"mesh.seat.git.call.open-pulls.n1~mesh-delivery-forge-bridge"
|
||||
"mesh.seat.git.tool.open-pulls"
|
||||
"mesh.seat.git.call.trees.n1~mesh-delivery-forge-bridge"
|
||||
"mesh.seat.git.tool.trees"
|
||||
"mesh.seat.git.call.file.n1~mesh-delivery-forge-bridge"
|
||||
"mesh.seat.git.tool.file"
|
||||
"mesh.seat.git.call.pull.n1~mesh-delivery-forge-bridge"
|
||||
"mesh.seat.git.tool.pull"
|
||||
"mesh.seat.git.call.tags.n1~mesh-delivery-forge-bridge"
|
||||
"mesh.seat.git.tool.tags"
|
||||
"mesh.seat.mesh-delivery.call.groups.n1~mesh-delivery-forge-bridge"
|
||||
"mesh.seat.mesh-delivery.tool.groups"
|
||||
"mesh.seat.mesh-hand-acts.call.hand-act.n1~mesh-delivery-forge-bridge"
|
||||
"mesh.seat.mesh-hand-acts.tool.hand-act"
|
||||
"mesh.stream.mesh-delivery-forge-bridge.event.forge.>"
|
||||
"mesh.stream.mesh-delivery-forge-bridge.event.owed.ended"
|
||||
] }
|
||||
subscribe { allow: [
|
||||
"_INBOX.n1.mesh-delivery-forge-bridge.>"
|
||||
"mesh.assignment.n1.mesh-delivery-forge-bridge"
|
||||
"mesh.mod.gitea.event.forge.delete"
|
||||
"mesh.mod.gitea.event.pull.closed"
|
||||
"mesh.mod.gitea.event.pull.merged"
|
||||
"mesh.mod.gitea.event.pull.updated"
|
||||
"mesh.mod.mesh-delivery-forge-bridge.call.>"
|
||||
"mesh.mod.mesh-delivery-forge-bridge.tool.>"
|
||||
] }
|
||||
@@ -186,26 +177,32 @@ type bed struct {
|
||||
ctx context.Context
|
||||
done <-chan error
|
||||
reply func(string)
|
||||
// files sets what the stand-in forge answers to gitea_get_file from then on; it is asked with the tool's arguments.
|
||||
files func(func(owner, repo, path, ref string) (string, bool))
|
||||
// files sets what the stand-in seat git answers to file from then on; it is asked with the verb's input.
|
||||
files func(func(repository, path, ref string) (string, bool))
|
||||
// answer sets the stand-in's answer to one verb of a seat (`<seat>.<verb>`) from then on.
|
||||
answer func(verb string, answer string)
|
||||
// calls are the verbs the stand-in was called with, in order, as `<seat>.<verb> <input>`.
|
||||
calls func() []string
|
||||
// restart stops the bridge, runs between, and starts it again on the same bus.
|
||||
restart func(between func())
|
||||
}
|
||||
|
||||
// getFileReply is the reply envelope for gitea_get_file as mesh-catalog modules/gitea tools/index.ts and client.ts getFile
|
||||
// answer it ({file: {path, ref, sha, size, content}}), and for a path the forge does not have the error client.ts request
|
||||
// writes: `Gitea API <path>: 404 <body>`.
|
||||
func getFileReply(path, ref, content string, found bool) []byte {
|
||||
// answerOf is a verb's answer in the reply envelope of mesh-sdk go/process/call.go ({result, error, node}).
|
||||
func answerOf(answer string) []byte { return []byte(`{"result":` + answer + `,"node":"n0"}`) }
|
||||
|
||||
// fileAnswer is the seat git's answer to file: the content, or absent.
|
||||
func fileAnswer(path, ref, content string, found bool) string {
|
||||
if !found {
|
||||
b, _ := json.Marshal(map[string]any{"error": "Gitea API /repos/novox/mesh-delivery/contents/" + path + "?ref=" + ref + `: 404 {"message":"GetContentsOrList","url":"https://forge.example/api/swagger"}`})
|
||||
return b
|
||||
return `{"absent":true}`
|
||||
}
|
||||
b, _ := json.Marshal(map[string]any{"result": map[string]any{"file": map[string]any{"path": path, "ref": ref, "sha": "0123456789abcdef0123456789abcdef01234567",
|
||||
"size": len(content), "content": content}}, "node": "n0"})
|
||||
return b
|
||||
b, _ := json.Marshal(git.FileAnswer{Path: path, Ref: ref, Content: content, Size: int64(len(content))})
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// startBed is startBridge with a step between the streams and the consumer: prepare writes what the streams hold before
|
||||
// the bridge's consumer is made, as the controller makes it, from now (ADR 0443 §1), and reply sets the answer of the
|
||||
// forge's gitea_open_pulls from then on.
|
||||
// startBed is startBridge with a step between the streams and the consumers: prepare writes what the streams hold before
|
||||
// the bridge's consumers are made, as the grants part makes them, from now (ADR 0443 §1), and reply sets the answer of the
|
||||
// seat git's open-pulls from then on. A stand-in holds the seats the bridge calls: git (every verb, trees r0 by default,
|
||||
// a module.json of {} at every path), mesh-delivery's groups (none by default) and mesh-hand-acts' hand-act.
|
||||
func startBed(t *testing.T, openReply string, prepare func(ctx context.Context, js jetstream.JetStream)) *bed {
|
||||
srv := startBus(t)
|
||||
ctl, err := nats.Connect(srv.ClientURL(), nats.UserInfo("ctl", "pw"))
|
||||
@@ -219,70 +216,74 @@ func startBed(t *testing.T, openReply string, prepare func(ctx context.Context,
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "EVENTS", Subjects: []string{"mesh.mod.*.event.>"}}); err != nil {
|
||||
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "EVENTS", Subjects: []string{"mesh.mod.*.event.>", "mesh.seat.*.event.>"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "FORGE", Subjects: []string{"mesh.stream.mesh-delivery-forge-bridge.event.forge.>"}}); err != nil {
|
||||
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "FORGE", Subjects: []string{"mesh.stream.mesh-delivery-forge-bridge.event.>"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The checker's stream checks, which the bridge no longer hears (ADR 0521): a test says on it that the checker wrote a status.
|
||||
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "CHECKS", Subjects: []string{"mesh.stream.mesh-delivery-checker.event.>"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
// Each seat's own stream owed, as its contract keeps it (Stream<E>, SEAT_<seat>_owed), as the grants part makes it: no
|
||||
// subjects of its own, sourced from EVENTS filtered by the event's subject.
|
||||
for name, subjects := range map[string]string{seat.StreamOwed: seat.SubjectOwedAll, checker.StreamOwed: checker.SubjectOwedAll} {
|
||||
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: name, MaxAge: 7 * 24 * time.Hour,
|
||||
Sources: []*jetstream.StreamSource{{Name: "EVENTS", FilterSubject: subjects}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// The bridge's state bucket, made by the controller from the manifest's state entry before the process starts.
|
||||
if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: headsBucket, History: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Its other state bucket, and the gate's buckets the bridge reads, made by the controller from the gate's manifest
|
||||
// (the gate's merges and owed with their histories).
|
||||
for name, history := range map[string]uint8{performedBucket: 1, inputsBucket: 1, mergesBucket: 10, owedBucket: 5} {
|
||||
if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: name, History: history}); err != nil {
|
||||
// The bridge's states, made from the manifest's state entries and its requires before the process starts.
|
||||
for _, name := range []string{headsBucket, performedBucket, inputsBucket, groupkeys.EndAsksBucket, groupkeys.OwedAsksBucket} {
|
||||
if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: name, History: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if prepare != nil {
|
||||
prepare(ctx, js)
|
||||
}
|
||||
acct := process.Account{Node: "n1", Module: "mesh-delivery-forge-bridge"}
|
||||
if _, err := js.CreateOrUpdateConsumer(ctx, "EVENTS", jetstream.ConsumerConfig{
|
||||
Durable: "n1_mesh-delivery-forge-bridge", AckPolicy: jetstream.AckExplicitPolicy, DeliverPolicy: jetstream.DeliverNewPolicy,
|
||||
FilterSubjects: []string{"mesh.mod.gitea.event.pull.updated", "mesh.mod.gitea.event.pull.merged", "mesh.mod.gitea.event.pull.closed", "mesh.mod.gitea.event.forge.delete"},
|
||||
Durable: acct.EventConsumerName(), AckPolicy: jetstream.AckExplicitPolicy, DeliverPolicy: jetstream.DeliverNewPolicy,
|
||||
FilterSubjects: []string{git.SubjectPullUpdated, git.SubjectPullMerged, git.SubjectPullClosed, git.SubjectForgeDelete},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, subjects := range map[string]string{seat.StreamOwed: seat.SubjectOwedAll, checker.StreamOwed: checker.SubjectOwedAll} {
|
||||
if _, err := js.CreateOrUpdateConsumer(ctx, name, acct.SeatEventConsumerConfig([]string{subjects}, false)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// A stand-in answers the forge's gitea_open_pulls, which the bridge asks at its start and after a reconnect, on its tool
|
||||
// subject (mesh-control internal/broker/nats.go invokedSubjects) in the reply envelope of mesh-sdk go/process/call.go.
|
||||
// The stand-in seats, answering on the caller-named subject and its plain fallback.
|
||||
var mu sync.Mutex
|
||||
current := openReply
|
||||
open, err := ctl.Subscribe("mesh.mod.gitea.tool.gitea_open_pulls", func(m *nats.Msg) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
_ = m.Respond([]byte(current))
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
answers := map[string]string{
|
||||
"git.open-pulls": openReply, "git.trees": `{"root":"r0","trees":{"":"t0","modules/a":"t1"}}`,
|
||||
"mesh-delivery.groups": `{"groups":[]}`, "mesh-hand-acts.hand-act": `{"recorded":true}`,
|
||||
}
|
||||
t.Cleanup(func() { _ = open.Unsubscribe() })
|
||||
if err := ctl.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
for _, verb := range []string{"status", "comment", "view", "note", "merge-pull"} {
|
||||
answers["git."+verb] = string(gitfixture.Given(t, verb+" answers", "verb:"+verb+".answer"))
|
||||
}
|
||||
|
||||
// A stand-in answers the forge's gitea_get_file: by default a module.json of {} at every path and no other file.
|
||||
var fmu sync.Mutex
|
||||
filesOf := func(_, _, path, _ string) (string, bool) { return "{}", strings.HasSuffix(path, "module.json") }
|
||||
getFile, err := ctl.Subscribe("mesh.mod.gitea.tool.gitea_get_file", func(m *nats.Msg) {
|
||||
var a struct{ Owner, Repo, Path, Ref string }
|
||||
_ = json.Unmarshal(m.Data, &a)
|
||||
fmu.Lock()
|
||||
f := filesOf
|
||||
fmu.Unlock()
|
||||
content, found := f(a.Owner, a.Repo, a.Path, a.Ref)
|
||||
_ = m.Respond(getFileReply(a.Path, a.Ref, content, found))
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
filesOf := func(_, path, _ string) (string, bool) { return "{}", strings.HasSuffix(path, "module.json") }
|
||||
var called []string
|
||||
for _, subject := range []string{"mesh.seat.*.call.*.*", "mesh.seat.*.tool.*"} {
|
||||
sub, err := ctl.Subscribe(subject, func(m *nats.Msg) {
|
||||
parts := strings.Split(m.Subject, ".")
|
||||
verb := parts[2] + "." + parts[4]
|
||||
mu.Lock()
|
||||
called = append(called, verb+" "+string(m.Data))
|
||||
answer, files := answers[verb], filesOf
|
||||
mu.Unlock()
|
||||
if verb == "git.file" {
|
||||
var in git.FileInput
|
||||
_ = json.Unmarshal(m.Data, &in)
|
||||
content, found := files(in.Repository, in.Path, in.Ref)
|
||||
answer = fileAnswer(in.Path, in.Ref, content, found)
|
||||
}
|
||||
_ = m.Respond(answerOf(answer))
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sub.Unsubscribe() })
|
||||
}
|
||||
t.Cleanup(func() { _ = getFile.Unsubscribe() })
|
||||
if err := ctl.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -293,24 +294,37 @@ func startBed(t *testing.T, openReply string, prepare func(ctx context.Context,
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := map[string]string{"MESH_BROKER_FILE": broker}
|
||||
runCtx, stop := context.WithCancel(ctx)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- run(runCtx, func(k string) string { return env[k] }, io.Discard) }()
|
||||
t.Cleanup(stop)
|
||||
return &bed{srv: srv, ctl: ctl, js: js, ctx: ctx, done: done, reply: func(s string) { mu.Lock(); current = s; mu.Unlock() },
|
||||
files: func(f func(owner, repo, path, ref string) (string, bool)) { fmu.Lock(); filesOf = f; fmu.Unlock() }}
|
||||
var stop context.CancelFunc
|
||||
start := func() {
|
||||
var runCtx context.Context
|
||||
runCtx, stop = context.WithCancel(ctx)
|
||||
go func() { done <- run(runCtx, func(k string) string { return env[k] }, io.Discard) }()
|
||||
}
|
||||
start()
|
||||
t.Cleanup(func() { stop() })
|
||||
set := func(verb, answer string) { mu.Lock(); answers[verb] = answer; mu.Unlock() }
|
||||
return &bed{srv: srv, ctl: ctl, js: js, ctx: ctx, done: done, reply: func(s string) { set("git.open-pulls", s) },
|
||||
files: func(f func(repository, path, ref string) (string, bool)) { mu.Lock(); filesOf = f; mu.Unlock() },
|
||||
answer: set,
|
||||
calls: func() []string { mu.Lock(); defer mu.Unlock(); return append([]string(nil), called...) },
|
||||
restart: func(between func()) {
|
||||
stop()
|
||||
<-done
|
||||
between()
|
||||
start()
|
||||
}}
|
||||
}
|
||||
|
||||
// TestBridgeWritesPullMessages starts the program the way the node does (its entry, run, with MESH_BROKER_FILE).
|
||||
func TestBridgeWritesPullMessages(t *testing.T) { // 1235/AC2
|
||||
ctl, js, ctx, _ := startBridge(t, `{"result":{"pulls":[]}}`)
|
||||
ctl, js, ctx, _ := startBridge(t, `{"pulls":[]}`)
|
||||
defer branchDeletedCase(t, ctl.ConnectedUrl(), js, ctx)
|
||||
|
||||
// gitea.pull.updated as the forge module publishes it: the body of trunk.ts:233-250 on mesh.mod.gitea.event.pull.updated.
|
||||
ev, _ := json.Marshal(map[string]any{"owner": "novox", "repo": "mesh-delivery", "number": 12, "title": "t", "body": "b", "base": "main",
|
||||
"head": "feat/x", "head_sha": head, "clone_url": "u", "html_url": "h", "paths": []string{"modules/a/x.go"}, "paths_truncated": false,
|
||||
"removed": []string{}, "module_dirs": []string{"modules/a"}, "module_dirs_said": true})
|
||||
if _, err := js.Publish(ctx, "mesh.mod.gitea.event.pull.updated", ev); err != nil {
|
||||
// pull.updated as the seat git says it (its contract's PullUpdatedEvent).
|
||||
ev, _ := json.Marshal(git.PullUpdatedEvent{Repository: "novox/mesh-delivery", Number: 12, Title: "t", Base: "main", Branch: "feat/x", Head: head,
|
||||
Change: &git.Change{Paths: []string{"modules/a/x.go"}, ModuleDirs: []string{"modules/a"}}})
|
||||
if _, err := js.Publish(ctx, git.SubjectPullUpdated, ev); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -357,13 +371,13 @@ func TestBridgeWritesPullMessages(t *testing.T) { // 1235/AC2
|
||||
t.Errorf("the event is not acknowledged: %+v", ci)
|
||||
}
|
||||
|
||||
// branchDeletedCase is DLV-FB1's case of the bridge's whole-module test (design 51): the forge module's forge.delete of
|
||||
// a branch is a branch-deleted message on FORGE, and a forge.delete from another account than the forge module's is
|
||||
// refused by the bus.
|
||||
// branchDeletedCase is DLV-FB1's case of the bridge's whole-module test (design 51): the seat git's forge.delete of a
|
||||
// branch is a branch-deleted message on FORGE, and a forge.delete from another account than the seat's holder is refused
|
||||
// by the bus.
|
||||
func branchDeletedCase(t *testing.T, srv string, js jetstream.JetStream, ctx context.Context) {
|
||||
t.Helper()
|
||||
ev, _ := json.Marshal(map[string]any{"owner": "novox", "repo": "mesh-sdk", "ref": "feat/x", "ref_type": "branch", "sha": head})
|
||||
if _, err := js.Publish(ctx, "mesh.mod.gitea.event.forge.delete", ev); err != nil {
|
||||
ev, _ := json.Marshal(git.ForgeDeleteEvent{Repository: "novox/mesh-sdk", Ref: "feat/x", RefType: "branch"})
|
||||
if _, err := js.Publish(ctx, git.SubjectForgeDelete, ev); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
forge, err := js.Stream(ctx, "FORGE")
|
||||
@@ -388,21 +402,26 @@ func branchDeletedCase(t *testing.T, srv string, js jetstream.JetStream, ctx con
|
||||
ojs, _ := jetstream.New(other)
|
||||
octx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
if _, err := ojs.Publish(octx, "mesh.mod.gitea.event.forge.delete", ev); err == nil {
|
||||
t.Error("a forge.delete from the bridge's account was taken by the bus: only the forge module's account may say it")
|
||||
if _, err := ojs.Publish(octx, git.SubjectForgeDelete, ev); err == nil {
|
||||
t.Error("a forge.delete from the bridge's account was taken by the bus: only the seat git's holder may say it")
|
||||
}
|
||||
}
|
||||
|
||||
const merge = "89abcdef0123456789abcdef0123456789abcdef"
|
||||
|
||||
// TestBridgeWritesMergedMessage has a stand-in answer the forge's gitea_commit_trees on its tool subject
|
||||
// (mesh-control internal/broker/nats.go invokedSubjects: mesh.mod.<module>.tool.<tool>), in the reply envelope of
|
||||
// mesh-sdk go/process/call.go ({result, error, node}).
|
||||
// openPull is one entry of the seat git's open-pulls answer.
|
||||
func openPull(repo string, number int, branch, head string) string {
|
||||
b, _ := json.Marshal(git.OpenPull{Pull: git.Pull{Repository: "novox/" + repo, Number: int64(number), Base: "main", Branch: branch, Head: head, State: "open"},
|
||||
Checks: map[string]git.Status{}, Required: []string{}})
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// TestBridgeWritesMergedMessage: the stand-in seat git answers trees and file on the caller-named subjects.
|
||||
func TestBridgeWritesMergedMessage(t *testing.T) { // 1236/AC4, 1448/AC5
|
||||
bed := startBed(t, `{"result":{"pulls":[]}}`, nil)
|
||||
ctl, js, ctx := bed.ctl, bed.js, bed.ctx
|
||||
bed := startBed(t, `{"pulls":[]}`, nil)
|
||||
js, ctx := bed.js, bed.ctx
|
||||
// The stand-in forge's files are two real files of this repository (this module's module.json and go.sum, read from the
|
||||
// working tree), in gitea_get_file's real answer shape: the root has a manifest only, modules/a a manifest and go.sum.
|
||||
// working tree), in the seat git's file answer: the root has a manifest only, modules/a a manifest and go.sum.
|
||||
manifest, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -411,7 +430,7 @@ func TestBridgeWritesMergedMessage(t *testing.T) { // 1236/AC4, 1448/AC5
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bed.files(func(_, _, path, ref string) (string, bool) {
|
||||
bed.files(func(_, path, ref string) (string, bool) {
|
||||
if ref != merge {
|
||||
t.Errorf("the forge was asked for %s at %s, want the merge commit %s", path, ref, merge)
|
||||
}
|
||||
@@ -423,25 +442,10 @@ func TestBridgeWritesMergedMessage(t *testing.T) { // 1236/AC4, 1448/AC5
|
||||
}
|
||||
return "", false
|
||||
})
|
||||
var asked chan []byte = make(chan []byte, 4)
|
||||
sub, err := ctl.Subscribe("mesh.mod.gitea.tool.gitea_commit_trees", func(m *nats.Msg) {
|
||||
asked <- m.Data
|
||||
_ = m.Respond([]byte(`{"result":{"root":"r0","trees":{"":"t0","modules/a":"t1"}},"node":"n0"}`))
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sub.Unsubscribe() })
|
||||
if err := ctl.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// gitea.pull.merged as mesh-catalog modules/gitea/index.ts:186-207 publishes it.
|
||||
ev, _ := json.Marshal(map[string]any{"owner": "novox", "repo": "mesh-delivery", "number": 12, "title": "t", "body": "b",
|
||||
"head_sha": head, "head": "feat/x", "base": "main", "merge_commit_sha": merge, "merged_at": "2026-10-19T12:00:00Z",
|
||||
"clone_url": "u", "html_url": "h", "paths": []string{"modules/a/x.go"}, "paths_truncated": false, "removed": []string{},
|
||||
"module_dirs": []string{"modules/a"}, "module_dirs_said": true})
|
||||
if _, err := js.Publish(ctx, "mesh.mod.gitea.event.pull.merged", ev); err != nil {
|
||||
// pull.merged as the seat git says it (its contract's PullMergedEvent).
|
||||
ev, _ := json.Marshal(git.PullMergedEvent{Repository: "novox/mesh-delivery", Number: 12, Title: "t", Head: head, Branch: "feat/x", Base: "main",
|
||||
MergeCommit: merge, Change: &git.Change{Paths: []string{"modules/a/x.go"}, ModuleDirs: []string{"modules/a"}}})
|
||||
if _, err := js.Publish(ctx, git.SubjectPullMerged, ev); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -475,16 +479,15 @@ func TestBridgeWritesMergedMessage(t *testing.T) { // 1236/AC4, 1448/AC5
|
||||
if !forgemsg.SameForge(f, want) {
|
||||
t.Errorf("message = %+v, want %+v", f, want)
|
||||
}
|
||||
select {
|
||||
case body := <-asked:
|
||||
if string(body) != `{"owner":"novox","repo":"mesh-delivery","sha":"`+merge+`"}` {
|
||||
t.Errorf("the tool was asked %s", body)
|
||||
var trees []string
|
||||
for _, c := range bed.calls() {
|
||||
if strings.HasPrefix(c, "git.trees ") {
|
||||
trees = append(trees, c)
|
||||
}
|
||||
default:
|
||||
t.Error("the stand-in was not asked")
|
||||
}
|
||||
if len(asked) != 0 {
|
||||
t.Error("the tool was asked more than once")
|
||||
in, _ := json.Marshal(git.TreesInput{Repository: "novox/mesh-delivery", Commit: merge})
|
||||
if len(trees) != 1 || trees[0] != "git.trees "+string(in) {
|
||||
t.Errorf("the seat git was asked for trees %v, want once at the merge commit", trees)
|
||||
}
|
||||
cons, err := js.Consumer(ctx, "EVENTS", "n1_mesh-delivery-forge-bridge")
|
||||
if err != nil {
|
||||
@@ -503,10 +506,10 @@ func TestBridgeWritesMergedMessage(t *testing.T) { // 1236/AC4, 1448/AC5
|
||||
// TestBridgeSaysTheOpenPullsAtStart: the forge holds two open pull requests when the bridge starts; one message each is on
|
||||
// FORGE, and the open message naming both after them (ADR 0443 §5).
|
||||
func TestBridgeSaysTheOpenPullsAtStart(t *testing.T) { // 1237/AC1
|
||||
// The entries are gitea_open_pulls's (mesh-catalog modules/gitea/client.ts:369-381, head the branch).
|
||||
_, js, ctx, _ := startBridge(t, `{"result":{"pulls":[
|
||||
{"owner":"novox","repo":"mesh-delivery","number":12,"base":"main","head":"feat/x","head_sha":"`+head+`","html_url":"h","required":[],"statuses":[],"checks":{},"labels":[]},
|
||||
{"owner":"novox","repo":"mesh-catalog","number":300,"base":"main","head":"feat/x","head_sha":"`+strings.Repeat("a", 40)+`","html_url":"h","required":[],"statuses":[],"checks":{},"labels":[]}]},"node":"n0"}`)
|
||||
// The entries are the seat git's open-pulls answer (its contract's OpenPull).
|
||||
_, js, ctx, _ := startBridge(t, `{"pulls":[
|
||||
`+openPull("mesh-delivery", 12, "feat/x", head)+`,
|
||||
`+openPull("mesh-catalog", 300, "feat/x", strings.Repeat("a", 40))+`]}`)
|
||||
forge, err := js.Stream(ctx, "FORGE")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -533,10 +536,10 @@ func TestBridgeSaysTheOpenPullsAtStart(t *testing.T) { // 1237/AC1
|
||||
|
||||
// TestBridgeExitsWhenTheOpenPullsCannotBeRead: the process manager starts it again.
|
||||
func TestBridgeExitsWhenTheOpenPullsCannotBeRead(t *testing.T) { // 1237/AC3
|
||||
_, _, _, done := startBridge(t, `{"error":"the forge said 502"}`)
|
||||
_, _, _, done := startBridge(t, `{"error":"the forge said 502","error-kind":"failed","pulls":[]}`)
|
||||
select {
|
||||
case err := <-done:
|
||||
if err == nil || !strings.Contains(err.Error(), "gitea.gitea_open_pulls") || !strings.Contains(err.Error(), "the forge said 502") {
|
||||
if err == nil || !strings.Contains(err.Error(), "open-pulls") || !strings.Contains(err.Error(), "the forge said 502") {
|
||||
t.Errorf("run returned %v", err)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
@@ -555,14 +558,12 @@ func TestBridgeReadsTheForgeAtStartAndAfterAReconnectAndHearsNoOlderEvent(t *tes
|
||||
return "mesh.stream.mesh-delivery-forge-bridge.event.forge.novox." + repo + ".pull"
|
||||
}
|
||||
openSubject := "mesh.stream.mesh-delivery-forge-bridge.event.forge.open"
|
||||
b := startBed(t, `{"result":{"pulls":[
|
||||
{"owner":"novox","repo":"mesh-delivery","number":12,"base":"main","head":"feat/x","head_sha":"`+head+`","html_url":"h","required":[],"statuses":[],"checks":{},"labels":[]},
|
||||
{"owner":"novox","repo":"mesh-catalog","number":300,"base":"main","head":"feat/x","head_sha":"`+h2+`","html_url":"h","required":[],"statuses":[],"checks":{},"labels":[]}]},"node":"n0"}`,
|
||||
b := startBed(t, `{"pulls":[`+openPull("mesh-delivery", 12, "feat/x", head)+`,`+openPull("mesh-catalog", 300, "feat/x", h2)+`]}`,
|
||||
func(ctx context.Context, js jetstream.JetStream) {
|
||||
// Older events: a pull request long closed and one long updated, from before the bridge's consumer.
|
||||
for subject, ev := range map[string]map[string]any{
|
||||
"mesh.mod.gitea.event.pull.updated": {"owner": "novox", "repo": "mesh-old", "number": 5, "head": "feat/old", "head_sha": h2},
|
||||
"mesh.mod.gitea.event.pull.closed": {"owner": "novox", "repo": "mesh-older", "number": 6, "head": "feat/older", "head_sha": h2},
|
||||
for subject, ev := range map[string]any{
|
||||
git.SubjectPullUpdated: git.PullUpdatedEvent{Repository: "novox/mesh-old", Number: 5, Base: "main", Branch: "feat/old", Head: h2},
|
||||
git.SubjectPullClosed: git.PullClosedEvent{Repository: "novox/mesh-older", Number: 6, Base: "main", Branch: "feat/older", Head: h2},
|
||||
} {
|
||||
raw, _ := json.Marshal(ev)
|
||||
if _, err := js.Publish(ctx, subject, raw); err != nil {
|
||||
@@ -651,171 +652,186 @@ func TestBridgeReadsTheForgeAtStartAndAfterAReconnectAndHearsNoOlderEvent(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
// TestBridgeMergesAGroupOneMemberAtATime: the gate (played by the test) writes merges.g with member A handed and the
|
||||
// Approve's heads of A and B; a stand-in forge merges A and announces it; the gate then hands B; both end merged on FORGE
|
||||
// (1342/AC3, ADR 0437 §8's check).
|
||||
func TestBridgeMergesAGroupOneMemberAtATime(t *testing.T) { // 1342/AC3
|
||||
b := startBed(t, `{"result":{"pulls":[]}}`, nil)
|
||||
const headB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
const mergeA, mergeB = "1111111111111111111111111111111111111111", "2222222222222222222222222222222222222222"
|
||||
merges := make(chan string, 4)
|
||||
// The stand-in for gitea_merge_pull_request answers in the reply envelope of mesh-sdk go/process/call.go and publishes
|
||||
// gitea.pull.merged as mesh-catalog modules/gitea/index.ts:186-207 does, with the merge commit of the member merged.
|
||||
sub, err := b.ctl.Subscribe("mesh.mod.gitea.tool.gitea_merge_pull_request", func(m *nats.Msg) {
|
||||
var a struct {
|
||||
Repo string `json:"repo"`
|
||||
Head string `json:"head"`
|
||||
}
|
||||
_ = json.Unmarshal(m.Data, &a)
|
||||
merges <- string(m.Data)
|
||||
commit, number := mergeA, 12
|
||||
if a.Repo == "mesh-sdk" {
|
||||
commit, number = mergeB, 4
|
||||
}
|
||||
ev, _ := json.Marshal(map[string]any{"owner": "novox", "repo": a.Repo, "number": number, "head_sha": a.Head, "head": "feat/x",
|
||||
"base": "main", "merge_commit_sha": commit, "module_dirs": []string{"modules/a"}, "module_dirs_said": true})
|
||||
_, _ = b.js.Publish(b.ctx, "mesh.mod.gitea.event.pull.merged", ev)
|
||||
_ = m.Respond([]byte(`{"result":{"merged":true},"node":"n0"}`))
|
||||
})
|
||||
// heard is every message published under the bridge's stream subjects from now, by subject.
|
||||
func heard(t *testing.T, b *bed) <-chan *nats.Msg {
|
||||
t.Helper()
|
||||
ch := make(chan *nats.Msg, 64)
|
||||
sub, err := b.ctl.ChanSubscribe("mesh.stream.mesh-delivery-forge-bridge.event.>", ch)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sub.Unsubscribe() })
|
||||
trees, err := b.ctl.Subscribe("mesh.mod.gitea.tool.gitea_commit_trees", func(m *nats.Msg) {
|
||||
_ = m.Respond([]byte(`{"result":{"root":"r0","trees":{"":"t0","modules/a":"t1"}},"node":"n0"}`))
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = trees.Unsubscribe() })
|
||||
if err := b.ctl.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ch
|
||||
}
|
||||
|
||||
kv, err := b.js.KeyValue(b.ctx, mergesBucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approved := map[string]string{meshdeliveryclient.ApprovedKey("novox/mesh-delivery", 12): head, meshdeliveryclient.ApprovedKey("novox/mesh-sdk", 4): headB}
|
||||
pullA := seat.MergesByGroupValuePullsItem{Repository: "novox/mesh-delivery", Number: 12, Head: head, State: string(meshdeliveryclient.MergeMerging)}
|
||||
pullB := seat.MergesByGroupValuePullsItem{Repository: "novox/mesh-sdk", Number: 4, Head: headB, State: string(meshdeliveryclient.MergeMerging)}
|
||||
put := func(pulls ...seat.MergesByGroupValuePullsItem) {
|
||||
v, err := meshdeliveryclient.EncodeMerge(seat.MergesByGroupValue{Group: "g", Ask: "merge.g.0123456789ab", Approved: approved, Pulls: pulls})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := kv.Put(b.ctx, "g", v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
next := func(repo string) {
|
||||
// next is the next message heard on subject within a bound; the others are passed by.
|
||||
func next(t *testing.T, ch <-chan *nats.Msg, subject string) []byte {
|
||||
t.Helper()
|
||||
bound := time.After(20 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case got := <-merges:
|
||||
if !strings.Contains(got, `"repo":"`+repo+`"`) {
|
||||
t.Errorf("the forge was asked %s, want %s", got, repo)
|
||||
case m := <-ch:
|
||||
if m.Subject == subject {
|
||||
return m.Data
|
||||
}
|
||||
for _, want := range []string{"novox/mesh-delivery@" + head, "novox/mesh-sdk@" + headB} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("the merge %s does not carry the approved head %s", got, want)
|
||||
}
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatalf("%s was not merged within 10 s", repo)
|
||||
case <-bound:
|
||||
t.Fatalf("nothing on %s within 20 s", subject)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
put(pullA)
|
||||
next("mesh-delivery")
|
||||
// The gate hands the next member only after the bridge reported the one before merged (ADR 0441 §5).
|
||||
for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline) && len(forgeMerged(t, b)) < 1; time.Sleep(50 * time.Millisecond) {
|
||||
}
|
||||
pullA.State, pullA.Commit = string(meshdeliveryclient.MergeMerged), mergeA
|
||||
put(pullA, pullB)
|
||||
next("mesh-sdk")
|
||||
var got []string
|
||||
for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
|
||||
if got = forgeMerged(t, b); len(got) == 2 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("FORGE holds merged messages for %v, want both members", got)
|
||||
}
|
||||
select {
|
||||
case extra := <-merges:
|
||||
t.Errorf("a third merge was asked: %s", extra)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// forgeMerged lists the repositories FORGE holds a merged message for, sorted.
|
||||
func forgeMerged(t *testing.T, b *bed) []string {
|
||||
// refusedRead says the bridge's account cannot read the bucket: a key-value read of another module's state is refused.
|
||||
func refusedRead(t *testing.T, b *bed, bucket string) {
|
||||
t.Helper()
|
||||
forge, err := b.js.Stream(b.ctx, "FORGE")
|
||||
if _, err := b.js.CreateKeyValue(b.ctx, jetstream.KeyValueConfig{Bucket: bucket}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nc, err := nats.Connect(b.srv.ClientURL(), nats.UserInfo("n1.mesh-delivery-forge-bridge", "pw"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out []string
|
||||
for _, repo := range []string{"mesh-delivery", "mesh-sdk"} {
|
||||
raw, err := forge.GetLastMsgForSubject(b.ctx, "mesh.stream.mesh-delivery-forge-bridge.event.forge.novox."+repo+".merged")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if f, err := forgemsg.DecodeForge(raw.Data); err == nil && f.Kind == forgemsg.ForgeMerged {
|
||||
out = append(out, f.Repository)
|
||||
defer nc.Close()
|
||||
js, _ := jetstream.New(nc)
|
||||
ctx, cancel := context.WithTimeout(b.ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
if kv, err := js.KeyValue(ctx, bucket); err == nil {
|
||||
if _, err := kv.Get(ctx, "any"); err == nil || errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
t.Errorf("the bridge's account reads %s", bucket)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Acceptance 49: the forge bridge sets the group on each member's merged message and says the group's end once, after the
|
||||
// last member's, from the seat mesh-delivery's verb groups; its account reads no state of the gate's.
|
||||
func TestBridgeSaysAGroupFromTheSeatsGroups(t *testing.T) { // 1585, ADR 0633 §4
|
||||
b := startBed(t, `{"pulls":[]}`, nil)
|
||||
headB := strings.Repeat("b", 40)
|
||||
mergeB := strings.Repeat("e", 40)
|
||||
groups, _ := json.Marshal(seat.GroupsAnswer{Groups: []seat.Group{{ID: "feat/x", State: "merging", Members: []seat.Member{
|
||||
{Repository: "novox/mesh-delivery", Number: 12, Head: head, State: "merged"}, {Repository: "novox/mesh-sdk", Number: 4, Head: headB, State: "merging"}}}}})
|
||||
b.answer("mesh-delivery.groups", string(groups))
|
||||
ch := heard(t, b)
|
||||
for _, m := range []git.PullMergedEvent{
|
||||
{Repository: "novox/mesh-delivery", Number: 12, Head: head, Branch: "feat/x", Base: "main", MergeCommit: merge, Change: &git.Change{Paths: []string{"modules/a/x.go"}, ModuleDirs: []string{"modules/a"}}},
|
||||
{Repository: "novox/mesh-sdk", Number: 4, Head: headB, Branch: "feat/x", Base: "main", MergeCommit: mergeB, Change: &git.Change{Paths: []string{"go/x.go"}}},
|
||||
} {
|
||||
ev, _ := json.Marshal(m)
|
||||
if _, err := b.js.Publish(b.ctx, git.SubjectPullMerged, ev); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := strings.TrimPrefix(m.Repository, "novox/")
|
||||
f, err := forgemsg.DecodeForge(next(t, ch, "mesh.stream.mesh-delivery-forge-bridge.event.forge.novox."+repo+".merged"))
|
||||
if err != nil || f.Group != "feat/x" || len(f.Members) != 2 || f.Members[1].Number != 4 {
|
||||
t.Fatalf("%s's merged message: %+v %v, want the group feat/x with both members", repo, f, err)
|
||||
}
|
||||
}
|
||||
end, err := forgemsg.DecodeForgeGroupEnd(next(t, ch, forgeSubject(forgemsg.ForgeGroupEndEvent)))
|
||||
if err != nil || end.Group != "feat/x" || len(end.Members) != 2 || end.Members[0].Merge != merge || end.Members[1].Merge != mergeB {
|
||||
t.Errorf("the group's end: %+v %v", end, err)
|
||||
}
|
||||
refusedRead(t, b, "mesh-delivery-gate_merges")
|
||||
}
|
||||
|
||||
// Acceptance 53 (design 47 *What the gate owes the forge*): an owed act said on a seat's own stream owed is performed through
|
||||
// the seat git and its outcome said as owed.ended; said twice it is performed once; one it cannot read is said undecodable;
|
||||
// one said while the bridge is stopped is performed once after its restart; bridge_owed_again's ask is answered by the
|
||||
// process; and its account reads neither the gate's nor the checker's state.
|
||||
func TestBridgePerformsWhatIsOwedOnce(t *testing.T) {
|
||||
b := startBed(t, `{"pulls":[]}`, nil)
|
||||
ch := heard(t, b)
|
||||
ended := func() forgemsg.OwedEnded {
|
||||
t.Helper()
|
||||
e, err := forgemsg.DecodeOwedEnded(next(t, ch, forgeSubject(forgemsg.OwedEndedEvent)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return e
|
||||
}
|
||||
count := func(verb string) int {
|
||||
n := 0
|
||||
for _, c := range b.calls() {
|
||||
if strings.HasPrefix(c, verb+" ") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
gateOwed := gitfixture.Seat(t, seat.Seat, "events", "owed is said, a merge", "event:owed")[0]
|
||||
subject := seat.SubjectOwed("novox~mesh-sdk", "178")
|
||||
for range 2 {
|
||||
if _, err := b.js.Publish(b.ctx, subject, gateOwed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if e := ended(); e.Outcome != forgemsg.OwedPerformed || e.Of != seat.Seat {
|
||||
t.Errorf("the gate's merge: %+v, want performed", e)
|
||||
}
|
||||
if _, err := b.js.Publish(b.ctx, subject, []byte("{")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e := ended(); e.Outcome != forgemsg.OwedUndecodable {
|
||||
t.Errorf("an act it cannot read: %+v, want undecodable", e)
|
||||
}
|
||||
if n := count("git.merge-pull"); n != 1 {
|
||||
t.Errorf("an act said twice was performed %d times", n)
|
||||
}
|
||||
// Said while the bridge is stopped: performed once after its restart.
|
||||
checkerOwed := gitfixture.Seat(t, checker.Seat, "events", "owed is said", "event:owed")[0]
|
||||
b.restart(func() {
|
||||
if _, err := b.js.Publish(b.ctx, checker.SubjectOwed("novox~mesh-sdk", "178"), checkerOwed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if e := ended(); e.Of != checker.Seat || e.Outcome != forgemsg.OwedPerformed {
|
||||
t.Errorf("the checker's status after the restart: %+v, want performed", e)
|
||||
}
|
||||
if n := count("git.status"); n != 1 {
|
||||
t.Errorf("the checker's status was performed %d times, want once", n)
|
||||
}
|
||||
// bridge_owed_again asks through the state owed-asks; the process performs the act again and answers there.
|
||||
var g struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
_ = json.Unmarshal(gateOwed, &g)
|
||||
key, value, err := groupkeys.NewOwedAsk(groupkeys.OwedAgain, seat.Seat, g.ID, "a test", "lead@n1", time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
asks, err := b.js.KeyValue(b.ctx, groupkeys.OwedAsksBucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w, err := asks.Watch(b.ctx, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer w.Stop()
|
||||
if _, err := asks.Put(b.ctx, key, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bound := time.After(20 * time.Second)
|
||||
for answered := false; !answered; {
|
||||
select {
|
||||
case e := <-w.Updates():
|
||||
if e == nil {
|
||||
continue
|
||||
}
|
||||
if a, err := groupkeys.DecodeOwedAsk(e.Value()); err == nil && a.Done() {
|
||||
answered = true
|
||||
if a.Outcome != forgemsg.OwedPerformed || count("git.merge-pull") != 2 {
|
||||
t.Errorf("bridge_owed_again: %+v, merge-pull asked %d times; want performed again", a, count("git.merge-pull"))
|
||||
}
|
||||
}
|
||||
case <-bound:
|
||||
t.Fatal("bridge_owed_again was not answered within 20 s")
|
||||
}
|
||||
}
|
||||
refusedRead(t, b, "mesh-delivery-gate_owed")
|
||||
refusedRead(t, b, "mesh-delivery-checker_owed")
|
||||
}
|
||||
|
||||
func sha256Sum(b []byte) []byte { s := sha256.Sum256(b); return s[:] }
|
||||
|
||||
// TestBridgePerformsNoMeshCheckAct: the checker owes mesh/check on a head and says so on its stream; the bridge calls no
|
||||
// tool with mesh/check and publishes nothing of its own on EVENTS for it. The forge module writes mesh/check from the
|
||||
// checker's owed state itself (ADR 0521; R4e round 2 finding R2-1).
|
||||
func TestBridgePerformsNoMeshCheckAct(t *testing.T) { // R2-1, ADR 0521
|
||||
b := startBed(t, `{"result":{"pulls":[]}}`, nil)
|
||||
calls := make(chan string, 8)
|
||||
// Every forge call is watched, and none answered here: the bed's stand-ins answer the ones the bridge asks.
|
||||
sub, err := b.ctl.Subscribe("mesh.mod.gitea.>", func(m *nats.Msg) {
|
||||
if strings.Contains(string(m.Data), "mesh/check") {
|
||||
calls <- m.Subject + " " + string(m.Data)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sub.Unsubscribe() })
|
||||
if err := b.ctl.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kv, err := b.js.CreateKeyValue(b.ctx, jetstream.KeyValueConfig{Bucket: "mesh-delivery-checker_owed", History: 5})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key, err := meshdeliveryclient.OwedKey("novox/mesh-delivery", 12, head, "status=mesh/check")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v, err := meshdeliveryclient.EncodeOwed(seat.OwedByRepositoryNumberHeadActValue{Act: meshdeliveryclient.OwedStatus, Context: meshdeliveryclient.StatusContextCheck, State: "success", Text: "pass", Target: "https://view.example/checks/g.0123456789ab"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := kv.Put(b.ctx, key, v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := b.js.Publish(b.ctx, "mesh.stream.mesh-delivery-checker.event.owed.written", []byte(`{"key":"`+key+`"}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case c := <-calls:
|
||||
t.Errorf("a forge tool was called with mesh/check: %s", c)
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
events, err := b.js.Stream(b.ctx, "EVENTS")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := events.GetLastMsgForSubject(b.ctx, "mesh.mod.mesh-delivery-forge-bridge.event.>"); err == nil {
|
||||
t.Error("the bridge published an event of its own for the checker's mesh/check")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ import (
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
git "git.novox.be/novox/mesh-delivery/contracts/go/seats/git"
|
||||
seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery"
|
||||
checker "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
|
||||
"mesh-delivery-forge-bridge/internal/bridge"
|
||||
@@ -145,39 +148,14 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
// Everything written on FORGE, an event's and a sync's, keeps the head in the bucket.
|
||||
rec := &recorder{pub: js, heads: heads, logger: logger}
|
||||
said := func(ctx context.Context) error { return syncOpen(ctx, call, rec, heads, &turn, logger) }
|
||||
// What the gate asks of the forge (ADR 0437 §8): its owed acts and its merges, each performed once per revision.
|
||||
performedKV, err := process.EssentialBucket(ctx, js, "bridge", performedBucket, "perform an act of the gate only once")
|
||||
performedKV, err := process.EssentialBucket(ctx, js, "bridge", performedBucket, "keep what it performed of what is owed to the forge")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
perf := &performer{call: call, pub: rec, done: kvRevisions{performedKV}, logger: logger}
|
||||
// A delivery group's member names its group, and its end is said once (ADR 0633 §4), from the gate's merges.
|
||||
groups := &grouper{send: sendVia(js), done: kvRevisions{performedKV}, logger: logger}
|
||||
watching := make(chan error, 3)
|
||||
for _, w := range []struct {
|
||||
bucket string
|
||||
react func(ctx context.Context, key string, rev uint64, value []byte)
|
||||
}{{owedBucket, perf.owed}, {mergesBucket, func(ctx context.Context, key string, rev uint64, value []byte) {
|
||||
perf.merges(ctx, key, rev, value)
|
||||
groups.ended(ctx, key, rev, value)
|
||||
}}} {
|
||||
// The gate's buckets come with the gate and its grant; the bridge does not wait for them (no timer), and is started
|
||||
// again by its delivery when the gate arrives. A bucket refused or not answered is the same (issue 1945).
|
||||
kv := process.OptionalBucket(ctx, js, w.bucket, "nothing of it is performed until the bridge starts again", logger.Printf)
|
||||
if kv == nil {
|
||||
continue
|
||||
}
|
||||
if w.bucket == mergesBucket {
|
||||
groups.merges = kvMerges(kv)
|
||||
}
|
||||
go func() {
|
||||
watching <- watch(ctx, kv, func(key string, rev uint64, value []byte) {
|
||||
hctx, cancel := context.WithTimeout(ctx, syncDeadline)
|
||||
defer cancel()
|
||||
w.react(hctx, key, rev, value)
|
||||
})
|
||||
}()
|
||||
}
|
||||
// What the gate and the checker owe the forge, performed through the seat git (design 47 *What the gate owes the forge*).
|
||||
owe := &owing{call: call, pub: js, performed: kvPerformed{performedKV}, now: time.Now, logger: logger}
|
||||
// A delivery group's member names its group, and its end is said once (ADR 0633 §4), from the seat mesh-delivery's groups.
|
||||
groups := &grouper{groups: groupsVia(call), send: sendVia(js), done: kvHeads{performedKV}, logger: logger}
|
||||
// A person says a group's end again with bridge_group_end_again, which writes the state end-asks (ADR 0633 §4): watched, and
|
||||
// answered there; a state not there at this start is answered from the next start.
|
||||
if kv := process.OptionalBucket(ctx, js, groupkeys.EndAsksBucket, "bridge_group_end_again is not answered until the bridge starts again", logger.Printf); kv != nil {
|
||||
@@ -191,13 +169,51 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
if err := said(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
// What the gate and the checker say they owe, each on its seat's own stream owed through this module's durable there
|
||||
// (made from its consumes). A stream not there yet is said, and the bridge goes on: its other work does not wait on the
|
||||
// gate, and what was said meanwhile is caught up from the groups at the next start.
|
||||
for _, owner := range []struct{ of, kept string }{{seat.Seat, seat.StreamOwed}, {checker.Seat, checker.StreamOwed}} {
|
||||
cc, err := cred.HearSeatEvents(ctx, js, owner.kept, func(m jetstream.Msg) {
|
||||
hctx, cancel := context.WithTimeout(ctx, syncDeadline)
|
||||
defer cancel()
|
||||
owe.hear(hctx, owner.of, m)
|
||||
}, logger.Printf)
|
||||
if err != nil {
|
||||
logger.Printf("nothing %s owes the forge is performed until the bridge starts again: %v", owner.of, err)
|
||||
continue
|
||||
}
|
||||
defer cc.Stop()
|
||||
}
|
||||
// A person performs an act again, drops one or reads the consumers' backlog with the tools bridge_owed_again,
|
||||
// bridge_owed_drop and bridge_owed, which write the state owed-asks: watched, and answered there.
|
||||
if kv := process.OptionalBucket(ctx, js, groupkeys.OwedAsksBucket, "bridge_owed_again, bridge_owed_drop and bridge_owed's backlog are not answered until the bridge starts again", logger.Printf); kv != nil {
|
||||
backlog := owedBacklog(js, cred, time.Now)
|
||||
go func() {
|
||||
err := watch(ctx, kv, func(key string, rev uint64, value []byte) {
|
||||
hctx, cancel := context.WithTimeout(ctx, syncDeadline)
|
||||
defer cancel()
|
||||
owe.asked(hctx, kv, key, rev, value, backlog)
|
||||
})
|
||||
if err != nil && ctx.Err() == nil {
|
||||
logger.Printf("the owed asks are no longer watched, so they are not answered until the bridge starts again: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
// What was said while the bridge was away and is still unanswered, read from the seat (design 47 *The bridge catches up
|
||||
// from the seat*). The gate comes after the bridge, so a seat that does not answer yet is said, and the bridge goes on.
|
||||
if n, err := owe.catchUp(ctx); err != nil {
|
||||
logger.Printf("nothing owed is caught up from the groups at this start, so an act said while the bridge was away waits for its saying again: %v", err)
|
||||
} else if n > 0 {
|
||||
logger.Printf("%d acts owed to the forge, said while the bridge was away, performed from the groups", n)
|
||||
}
|
||||
go onReconnect(ctx, again, said, logger)
|
||||
// What a merge read of each module directory, by its tree (issue 1126): a merge reads only the directories it changed.
|
||||
inputs := newKept(process.OptionalBucket(ctx, js, inputsBucket, "a merge's module directories are kept in memory only, so the first merge after a start reads every one", logger.Printf), logger)
|
||||
// The module's one pull of its durable consumer on the shared events stream is its event hub; the bridge has the one
|
||||
// handler, for every event its consumes filter lets through. Essential: the forge's events are what the bridge carries.
|
||||
hub := process.NewEventHub(logger.Printf)
|
||||
if err := hub.Handle("mesh.mod.*.event.>", func(m jetstream.Msg) {
|
||||
// The forge's events are the seat git's (novox/hq design 47 *The seat `git`*), whichever module holds it.
|
||||
if err := hub.Handle("mesh.seat."+git.Seat+".event.>", func(m jetstream.Msg) {
|
||||
hear(&turn, func() {
|
||||
// Bounded: the turn is held, and no sync or other event goes on without it.
|
||||
hctx, cancel := context.WithTimeout(ctx, syncDeadline)
|
||||
@@ -211,10 +227,6 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
return fmt.Errorf("%w: the bridge hears no forge event without it, so it stops; where the bus refused it or did not answer, this module's consumes (made by the controller with its grant) have not reached the bus yet", err)
|
||||
}
|
||||
defer hub.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case err := <-watching:
|
||||
return fmt.Errorf("watching the gate's keys: %w", err)
|
||||
}
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
git "git.novox.be/novox/mesh-delivery/contracts/go/seats/git"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
@@ -21,6 +22,7 @@ import (
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
|
||||
"mesh-delivery-forge-bridge/internal/bridge"
|
||||
"mesh-delivery-forge-bridge/internal/gitfixture"
|
||||
)
|
||||
|
||||
const head = "0123456789abcdef0123456789abcdef01234567"
|
||||
@@ -53,18 +55,14 @@ func (p *fakePub) Publish(_ context.Context, subject string, payload []byte, _ .
|
||||
return &jetstream.PubAck{}, nil
|
||||
}
|
||||
|
||||
// body is gitea.pull.updated as mesh-catalog modules/gitea/trunk.ts:233-250 writes it (the fields the bridge reads).
|
||||
// body is the seat git's pull.updated (contracts/seats/git), the head head_sha laid over it when given.
|
||||
func body(m map[string]any) []byte {
|
||||
base := map[string]any{"owner": "novox", "repo": "mesh-delivery", "number": 12, "head": "feat/x", "head_sha": head,
|
||||
"module_dirs": []string{"modules/a"}, "module_dirs_said": true}
|
||||
for k, v := range m {
|
||||
if v == nil {
|
||||
delete(base, k)
|
||||
} else {
|
||||
base[k] = v
|
||||
}
|
||||
e := map[string]any{"repository": "novox/mesh-delivery", "number": 12, "branch": "feat/x", "head": head, "base": "main",
|
||||
"change": map[string]any{"paths": []string{"modules/a/x.go"}, "module-dirs": []string{"modules/a"}}}
|
||||
if h, ok := m["head_sha"]; ok {
|
||||
e["head"] = h
|
||||
}
|
||||
b, _ := json.Marshal(base)
|
||||
b, _ := json.Marshal(e)
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -80,7 +78,7 @@ func TestReadableEventIsWrittenOnForgeAndAcked(t *testing.T) { // 1235/AC2 (unit
|
||||
}
|
||||
})
|
||||
var logs bytes.Buffer
|
||||
m, pub := &fakeMsg{subject: "mesh.mod.gitea.event.pull.updated", data: body(nil)}, &fakePub{}
|
||||
m, pub := &fakeMsg{subject: git.SubjectPullUpdated, data: body(nil)}, &fakePub{}
|
||||
handle(context.Background(), m, pub, nil, someFiles, nil, log.New(&logs, "", 0))
|
||||
if len(pub.subjects) != 1 || pub.subjects[0] != "mesh.stream.mesh-delivery-forge-bridge.event.forge.novox.mesh-delivery.pull" {
|
||||
t.Fatalf("published on %v", pub.subjects)
|
||||
@@ -96,7 +94,7 @@ func TestReadableEventIsWrittenOnForgeAndAcked(t *testing.T) { // 1235/AC2 (unit
|
||||
|
||||
func TestUnreadableEventIsTerminatedAndLogged(t *testing.T) { // 1235/AC5
|
||||
var logs bytes.Buffer
|
||||
m, pub := &fakeMsg{subject: "mesh.mod.gitea.event.pull.updated", data: body(map[string]any{"head_sha": "short"})}, &fakePub{}
|
||||
m, pub := &fakeMsg{subject: git.SubjectPullUpdated, data: body(map[string]any{"head_sha": "short"})}, &fakePub{}
|
||||
handle(context.Background(), m, pub, nil, someFiles, nil, log.New(&logs, "", 0))
|
||||
if len(pub.subjects) != 0 {
|
||||
t.Errorf("a message was written: %v", pub.subjects)
|
||||
@@ -104,13 +102,13 @@ func TestUnreadableEventIsTerminatedAndLogged(t *testing.T) { // 1235/AC5
|
||||
if m.termd != 1 || m.acked != 0 {
|
||||
t.Errorf("terminated %d acked %d, want terminated once", m.termd, m.acked)
|
||||
}
|
||||
if !strings.Contains(logs.String(), "mesh.mod.gitea.event.pull.updated") || !strings.Contains(logs.String(), "head") {
|
||||
if !strings.Contains(logs.String(), git.SubjectPullUpdated) || !strings.Contains(logs.String(), "head") {
|
||||
t.Errorf("log %q names neither the subject nor why", logs.String())
|
||||
}
|
||||
// 1235: an unwritten message is not acked (a failed write is redelivered by the server, not retried here); merged here
|
||||
// from a test of its own, for the test budget.
|
||||
logs.Reset()
|
||||
m, pub = &fakeMsg{subject: "mesh.mod.gitea.event.pull.updated", data: body(nil)}, &fakePub{err: errors.New("no stream")}
|
||||
m, pub = &fakeMsg{subject: git.SubjectPullUpdated, data: body(nil)}, &fakePub{err: errors.New("no stream")}
|
||||
handle(context.Background(), m, pub, nil, someFiles, nil, log.New(&logs, "", 0))
|
||||
if m.acked != 0 || m.termd != 0 {
|
||||
t.Errorf("acked %d terminated %d, want neither", m.acked, m.termd)
|
||||
@@ -120,16 +118,16 @@ func TestUnreadableEventIsTerminatedAndLogged(t *testing.T) { // 1235/AC5
|
||||
}
|
||||
}
|
||||
|
||||
// mergedEvent is gitea.pull.merged as mesh-catalog modules/gitea/index.ts:186-207 writes it (the fields the bridge reads).
|
||||
// mergedEvent is the seat git's pull.merged.
|
||||
func mergedEvent() []byte {
|
||||
b, _ := json.Marshal(map[string]any{"owner": "novox", "repo": "mesh-delivery", "number": 12, "head": "feat/x", "head_sha": head,
|
||||
"base": "main", "merge_commit_sha": "89abcdef0123456789abcdef0123456789abcdef"})
|
||||
b, _ := json.Marshal(map[string]any{"repository": "novox/mesh-delivery", "number": 12, "branch": "feat/x", "head": head,
|
||||
"base": "main", "merge-commit": "89abcdef0123456789abcdef0123456789abcdef"})
|
||||
return b
|
||||
}
|
||||
|
||||
func TestTreesRefusedLeavesTheEventForTenSecondsAndLogs(t *testing.T) { // 1236/AC3
|
||||
var logs bytes.Buffer
|
||||
m, pub := &fakeMsg{subject: "mesh.mod.gitea.event.pull.merged", data: mergedEvent()}, &fakePub{}
|
||||
m, pub := &fakeMsg{subject: git.SubjectPullMerged, data: mergedEvent()}, &fakePub{}
|
||||
call := func(context.Context, string, any) (json.RawMessage, error) {
|
||||
return nil, errors.New("the tree listing of novox/mesh-delivery was cut")
|
||||
}
|
||||
@@ -147,33 +145,36 @@ func TestTreesRefusedLeavesTheEventForTenSecondsAndLogs(t *testing.T) { // 1236/
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeCallsTheToolOnceAndWritesTheMergedMessage(t *testing.T) { // 1236/AC1 (through the tool's request and answer)
|
||||
func TestMergeCallsTheToolOnceAndWritesTheMergedMessage(t *testing.T) { // 1236/AC1; design 47 *The seat git* (verbs trees, file)
|
||||
var logs bytes.Buffer
|
||||
m, pub := &fakeMsg{subject: "mesh.mod.gitea.event.pull.merged", data: mergedEvent()}, &fakePub{}
|
||||
m, pub := &fakeMsg{subject: git.SubjectPullMerged, data: mergedEvent()}, &fakePub{}
|
||||
var keys []string
|
||||
var args []any
|
||||
var mu sync.Mutex // the files are asked for concurrently (issue 1458)
|
||||
var mu sync.Mutex // the files are asked for concurrently (issue 1458)
|
||||
trees := gitfixture.Given(t, "trees answers", "verb:trees.answer") // root ddd…, "" ddd…, contracts eee…
|
||||
file := gitfixture.Given(t, "file answers", "verb:file.answer")
|
||||
call := func(_ context.Context, key string, body any) (json.RawMessage, error) {
|
||||
mu.Lock()
|
||||
keys, args = append(keys, key), append(args, body)
|
||||
mu.Unlock()
|
||||
if key == "gitea.gitea_get_file" { // the manifests, 1448; the trees are what this test is about
|
||||
return json.RawMessage(`{"file":{"path":"p","sha":"0123","size":2,"content":"{}"}}`), nil
|
||||
if key == "seat:git.file" { // the manifests, 1448; the trees are what this test is about
|
||||
return file, nil
|
||||
}
|
||||
return json.RawMessage(`{"root":"r0","trees":{"":"t0","modules/a":"t1"}}`), nil
|
||||
return trees, nil
|
||||
}
|
||||
handle(context.Background(), m, pub, treesVia(context.Background(), call), filesVia(context.Background(), call), nil, log.New(&logs, "", 0))
|
||||
if len(keys) == 0 || keys[0] != "gitea.gitea_commit_trees" || slices.Index(keys[1:], "gitea.gitea_commit_trees") >= 0 {
|
||||
if len(keys) == 0 || keys[0] != "seat:git.trees" || slices.Index(keys[1:], "seat:git.trees") >= 0 {
|
||||
t.Fatalf("calls %v, want the trees once and first", keys)
|
||||
}
|
||||
if got, _ := json.Marshal(args[0]); string(got) != `{"owner":"novox","repo":"mesh-delivery","sha":"89abcdef0123456789abcdef0123456789abcdef"}` {
|
||||
if got, _ := json.Marshal(args[0]); !sameJSON(got, []byte(`{"repository":"novox/mesh-delivery","commit":"89abcdef0123456789abcdef0123456789abcdef"}`)) {
|
||||
t.Errorf("arguments %s", got)
|
||||
}
|
||||
if len(pub.subjects) != 1 || pub.subjects[0] != "mesh.stream.mesh-delivery-forge-bridge.event.forge.novox.mesh-delivery.merged" {
|
||||
t.Fatalf("published on %v", pub.subjects)
|
||||
}
|
||||
f, err := forgemsg.DecodeForge(pub.data[0])
|
||||
if err != nil || f.Root != "r0" || f.Trees[""] != "t0" || f.Trees["modules/a"] != "t1" || f.Merge == "" {
|
||||
d, e := strings.Repeat("d", 40), strings.Repeat("e", 40)
|
||||
if err != nil || f.Root != d || f.Trees[""] != d || f.Trees["contracts"] != e || f.Merge == "" {
|
||||
t.Errorf("message %+v err %v", f, err)
|
||||
}
|
||||
if m.acked != 1 {
|
||||
@@ -181,60 +182,65 @@ func TestMergeCallsTheToolOnceAndWritesTheMergedMessage(t *testing.T) { // 1236/
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAnswerWithoutARootIsNotUsed(t *testing.T) { // 1236 (a shape the tool does not answer)
|
||||
call := func(context.Context, string, any) (json.RawMessage, error) {
|
||||
return json.RawMessage(`{"trees":{"a":"t"}}`), nil
|
||||
}
|
||||
if _, _, err := treesVia(context.Background(), call)("o", "r", "s"); err == nil {
|
||||
t.Error("an answer with no root was taken")
|
||||
}
|
||||
call = func(context.Context, string, any) (json.RawMessage, error) { return json.RawMessage(`[1]`), nil }
|
||||
if _, _, err := treesVia(context.Background(), call)("o", "r", "s"); err == nil {
|
||||
t.Error("an answer that is not an object was taken")
|
||||
func TestAnAnswerWithoutARootIsNotUsed(t *testing.T) { // 1236 (a shape the seat does not answer); design 47 *A failed read answers an error*
|
||||
for _, raw := range []json.RawMessage{json.RawMessage(`{"trees":{"a":"t"}}`), json.RawMessage(`[1]`),
|
||||
gitfixture.Given(t, "trees answers an error, never none", "verb:trees.answer")} {
|
||||
call := func(context.Context, string, any) (json.RawMessage, error) { return raw, nil }
|
||||
if _, _, err := treesVia(context.Background(), call)("o", "r", "s"); err == nil {
|
||||
t.Errorf("the answer %s was taken", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The answer of gitea_get_file as mesh-catalog modules/gitea tools/index.ts and client.ts getFile write it, and the
|
||||
// error of a path the forge does not have as client.ts request writes it (`Gitea API ${path}: ${status} ${text}`).
|
||||
const getFileAnswer = `{"file":{"path":"modules/a/go.sum","ref":"89abcdef0123456789abcdef0123456789abcdef","sha":"0123","size":4,"content":"x y\n"}}`
|
||||
const getFile404 = "Gitea API /repos/novox/mesh-delivery/contents/modules/a/go.sum?ref=89abcdef: 404 {\"message\":\"GetContentsOrList\",\"url\":\"https://forge.example/api/swagger\"}"
|
||||
|
||||
func TestFilesViaAsksByOwnerRepoPathRefAndTakesTheContent(t *testing.T) { // 1448/AC4
|
||||
func TestFilesViaAsksByOwnerRepoPathRefAndTakesTheContent(t *testing.T) { // 1448/AC4; design 47 *The seat git* (verb file)
|
||||
var keys []string
|
||||
var args []any
|
||||
call := func(_ context.Context, key string, body any) (json.RawMessage, error) {
|
||||
keys, args = append(keys, key), append(args, body)
|
||||
return json.RawMessage(getFileAnswer), nil
|
||||
return gitfixture.Given(t, "file answers", "verb:file.answer"), nil
|
||||
}
|
||||
got, err := filesVia(context.Background(), call)("novox", "mesh-delivery", "modules/a/go.sum", "89abcdef0123456789abcdef0123456789abcdef")
|
||||
if err != nil || string(got) != "x y\n" {
|
||||
got, err := filesVia(context.Background(), call)("novox", "mesh-contracts", "seats/index.json", strings.Repeat("a", 40))
|
||||
if err != nil || string(got) != `{"format":1,"seats":["mesh-broker"]}` {
|
||||
t.Fatalf("got %q err %v", got, err)
|
||||
}
|
||||
if len(keys) != 1 || keys[0] != "gitea.gitea_get_file" {
|
||||
if len(keys) != 1 || keys[0] != "seat:git.file" {
|
||||
t.Fatalf("calls %v", keys)
|
||||
}
|
||||
if b, _ := json.Marshal(args[0]); string(b) != `{"owner":"novox","path":"modules/a/go.sum","ref":"89abcdef0123456789abcdef0123456789abcdef","repo":"mesh-delivery"}` {
|
||||
t.Errorf("arguments %s", b)
|
||||
want := gitfixture.Given(t, "file asks", "verb:file.input")
|
||||
if b, _ := json.Marshal(args[0]); !sameJSON(b, want) {
|
||||
t.Errorf("arguments %s, want the seat's %s", b, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilesViaTellsNotFoundFromOtherErrorsAndOtherShapes(t *testing.T) { // 1448/AC3
|
||||
call := func(err error, raw string) caller {
|
||||
return func(context.Context, string, any) (json.RawMessage, error) { return json.RawMessage(raw), err }
|
||||
func TestFilesViaTellsNotFoundFromOtherErrorsAndOtherShapes(t *testing.T) { // 1448/AC3; design 47 *A failed read answers an error*
|
||||
call := func(err error, raw json.RawMessage) caller {
|
||||
return func(context.Context, string, any) (json.RawMessage, error) { return raw, err }
|
||||
}
|
||||
if _, err := filesVia(context.Background(), call(errors.New(getFile404), ""))("o", "r", "p", "s"); !errors.Is(err, bridge.ErrNotFound) {
|
||||
t.Errorf("a 404 is %v, want ErrNotFound", err)
|
||||
absent := gitfixture.All(t, "file answers", "verb:file.answer")[1]
|
||||
if _, err := filesVia(context.Background(), call(nil, absent))("o", "r", "p", "s"); !errors.Is(err, bridge.ErrNotFound) {
|
||||
t.Errorf("an absent file is %v, want ErrNotFound", err)
|
||||
}
|
||||
if _, err := filesVia(context.Background(), call(errors.New("Gitea API /repos/o/r/contents/p: 502 bad gateway"), ""))("o", "r", "p", "s"); err == nil || errors.Is(err, bridge.ErrNotFound) {
|
||||
t.Errorf("a 502 is %v, want an error that is not ErrNotFound", err)
|
||||
if _, err := filesVia(context.Background(), call(errors.New("nats: timeout"), nil))("o", "r", "p", "s"); err == nil || errors.Is(err, bridge.ErrNotFound) {
|
||||
t.Errorf("a seat that does not answer is %v, want an error that is not ErrNotFound", err)
|
||||
}
|
||||
for _, raw := range []string{`{"file":null}`, `{}`, `[1]`, `{"file":{"path":"p"}}`} {
|
||||
for _, raw := range []json.RawMessage{gitfixture.Given(t, "file answers an error, never none", "verb:file.answer"), json.RawMessage(`{}`), json.RawMessage(`[1]`), json.RawMessage(`{"path":"p"}`)} {
|
||||
if _, err := filesVia(context.Background(), call(nil, raw))("o", "r", "p", "s"); err == nil || errors.Is(err, bridge.ErrNotFound) {
|
||||
t.Errorf("the answer %s gave %v, want an error", raw, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sameJSON is whether two JSON texts say the same value.
|
||||
func sameJSON(a, b []byte) bool {
|
||||
var x, y any
|
||||
if json.Unmarshal(a, &x) != nil || json.Unmarshal(b, &y) != nil {
|
||||
return false
|
||||
}
|
||||
ax, _ := json.Marshal(x)
|
||||
by, _ := json.Marshal(y)
|
||||
return bytes.Equal(ax, by)
|
||||
}
|
||||
|
||||
// someFiles is a forge that has a module.json at every path and no other file, for the tests that are not about the files.
|
||||
func someFiles(_, _, path, _ string) ([]byte, error) {
|
||||
if strings.HasSuffix(path, "module.json") {
|
||||
|
||||
@@ -2,15 +2,9 @@ package main
|
||||
|
||||
import groupkeys "mesh-delivery-forge-bridge/internal/groups"
|
||||
|
||||
// The holders and buckets the forge bridge names, taken from its own manifest (module.json: its name, its `reads`, its
|
||||
// The holders and buckets the forge bridge names, taken from its own manifest (module.json: its name and its
|
||||
// states); a unit row checks them against it (TestTheNamesAreTheManifests). No contract names a holder
|
||||
// (novox/hq ADR 0625 §4, ADR 0634 §1).
|
||||
const (
|
||||
forgeBridgeModule = groupkeys.Module
|
||||
gateModule = "mesh-delivery-gate"
|
||||
|
||||
// mergesBucket and owedBucket are the gate's states merges and owed, read by the bridge (reads: mesh-delivery-gate.merges,
|
||||
// mesh-delivery-gate.owed).
|
||||
mergesBucket = gateModule + "_merges"
|
||||
owedBucket = gateModule + "_owed"
|
||||
)
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// theNamesAreTheManifests (a subtest of TestAnOwedStatusIsSetOnceForARevision): the holders and buckets the bridge names
|
||||
// are its manifest's (its module, its `reads`, its states), since no contract names a holder (novox/hq ADR 0634 §1).
|
||||
// theNamesAreTheManifests (a subtest of TestWhatIsOwedIsPerformedSaidAndKept): the holders and buckets the bridge names
|
||||
// are its manifest's (its module, its `reads`, its states and its kept data in `requires`), since no contract names a holder (novox/hq ADR 0634 §1).
|
||||
func theNamesAreTheManifests(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
@@ -20,6 +20,7 @@ func theNamesAreTheManifests(t *testing.T) {
|
||||
State []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"state"`
|
||||
Requires []json.RawMessage `json:"requires"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -27,15 +28,22 @@ func theNamesAreTheManifests(t *testing.T) {
|
||||
if m.Module != forgeBridgeModule {
|
||||
t.Errorf("the manifest's module is %q, the bridge names itself %q", m.Module, forgeBridgeModule)
|
||||
}
|
||||
for _, read := range []string{gateModule + ".merges", gateModule + ".owed"} {
|
||||
if !slices.Contains(m.Reads, read) {
|
||||
t.Errorf("the bridge reads %s, its manifest's reads are %v", read, m.Reads)
|
||||
}
|
||||
if len(m.Reads) != 0 {
|
||||
t.Errorf("the bridge reads no other module's state (acceptance 49), its manifest's reads are %v", m.Reads)
|
||||
}
|
||||
var own []string
|
||||
for _, s := range m.State {
|
||||
own = append(own, forgeBridgeModule+"_"+s.Name)
|
||||
}
|
||||
for _, r := range m.Requires {
|
||||
var kept struct {
|
||||
Name string `json:"name"`
|
||||
Template string `json:"template"`
|
||||
}
|
||||
if json.Unmarshal(r, &kept) == nil && kept.Template == "state" {
|
||||
own = append(own, forgeBridgeModule+"_"+kept.Name)
|
||||
}
|
||||
}
|
||||
for _, b := range []string{headsBucket, performedBucket, inputsBucket} {
|
||||
if !slices.Contains(own, b) {
|
||||
t.Errorf("the bridge keeps %s, its manifest's states are %v", b, own)
|
||||
|
||||
@@ -10,14 +10,15 @@ import (
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
git "git.novox.be/novox/mesh-delivery/contracts/go/seats/git"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
|
||||
"mesh-delivery-forge-bridge/internal/bridge"
|
||||
)
|
||||
|
||||
// openPullsTool is the forge's tool for every open pull request (mesh-catalog modules/gitea/tools/index.ts
|
||||
// gitea_open_pulls: no arguments, answers {pulls: [...]}).
|
||||
const openPullsTool = "gitea.gitea_open_pulls"
|
||||
// openPullsTool is the seat git's verb for every open pull request (novox/hq design 47 *The seat `git`*): no arguments,
|
||||
// answers {pulls: [...]} or an error.
|
||||
var openPullsTool = bridge.GitKey(git.VerbOpenPulls)
|
||||
|
||||
// headStore is the bucket heads: the head last written on FORGE for each pull request, by bridge.Track's key
|
||||
// (novox/hq ADR 0443 §5).
|
||||
@@ -84,7 +85,7 @@ func syncOpen(ctx context.Context, call caller, pub publisher, heads headStore,
|
||||
defer mu.Unlock()
|
||||
ctx, cancel := context.WithTimeout(ctx, syncDeadline)
|
||||
defer cancel()
|
||||
raw, err := call(ctx, openPullsTool, struct{}{})
|
||||
raw, err := call(ctx, openPullsTool, git.OpenPullsInput{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", openPullsTool, err)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -16,12 +17,14 @@ import (
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
)
|
||||
|
||||
// openAnswer is gitea_open_pulls's answer as mesh-catalog modules/gitea/client.ts:369-381 builds its entries inside {pulls}.
|
||||
// openAnswer is the seat git's answer of open-pulls: two into the trunk, one into another branch.
|
||||
func openAnswer() json.RawMessage {
|
||||
return json.RawMessage(`{"pulls":[
|
||||
{"owner":"novox","repo":"mesh-delivery","number":12,"base":"main","head":"feat/x","head_sha":"` + head + `","html_url":"h","required":[],"statuses":[],"checks":{},"labels":[]},
|
||||
{"owner":"novox","repo":"mesh-catalog","number":300,"base":"main","head":"feat/x","head_sha":"` + strings.Repeat("a", 40) + `","html_url":"h","required":[],"statuses":[],"checks":{},"labels":[]},
|
||||
{"owner":"novox","repo":"mesh-catalog","number":301,"base":"feat/y","head":"feat/z","head_sha":"` + strings.Repeat("b", 40) + `","html_url":"h","required":[],"statuses":[],"checks":{},"labels":[]}]}`)
|
||||
entry := func(repo string, number int, base, branch, sha string) string {
|
||||
return `{"pull":{"repository":"novox/` + repo + `","number":` + strconv.Itoa(number) + `,"base":"` + base + `","branch":"` + branch +
|
||||
`","head":"` + sha + `","state":"open","url":"h"},"required":[],"checks":{}}`
|
||||
}
|
||||
return json.RawMessage(`{"pulls":[` + entry("mesh-delivery", 12, "main", "feat/x", head) + `,` +
|
||||
entry("mesh-catalog", 300, "main", "feat/x", strings.Repeat("a", 40)) + `,` + entry("mesh-catalog", 301, "feat/y", "feat/z", strings.Repeat("b", 40)) + `]}`)
|
||||
}
|
||||
|
||||
func TestSyncOpenWritesOneUpdatedMessagePerOpenPull(t *testing.T) { // 1237/AC1, AC4
|
||||
@@ -35,7 +38,7 @@ func TestSyncOpenWritesOneUpdatedMessagePerOpenPull(t *testing.T) { // 1237/AC1,
|
||||
if err := runSync(call, pub, newHeads(), &sync.Mutex{}, log.New(&logs, "", 0)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(keys) != 1 || keys[0] != "gitea.gitea_open_pulls" {
|
||||
if len(keys) != 1 || keys[0] != "seat:git.open-pulls" {
|
||||
t.Errorf("calls %v", keys)
|
||||
}
|
||||
if len(pub.subjects) != 3 {
|
||||
@@ -232,7 +235,7 @@ func TestSyncOpenFailureNamesTheCallAndTheForgesWords(t *testing.T) { // 1237/AC
|
||||
return nil, errors.New("the forge said 502")
|
||||
}
|
||||
err := runSync(call, pub, newHeads(), &sync.Mutex{}, log.New(&bytes.Buffer{}, "", 0))
|
||||
if err == nil || !strings.Contains(err.Error(), "gitea.gitea_open_pulls") || !strings.Contains(err.Error(), "the forge said 502") {
|
||||
if err == nil || !strings.Contains(err.Error(), "seat:git.open-pulls") || !strings.Contains(err.Error(), "the forge said 502") {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
if len(pub.subjects) != 0 {
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
git "git.novox.be/novox/mesh-delivery/contracts/go/seats/git"
|
||||
seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery"
|
||||
checker "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
|
||||
"mesh-delivery-forge-bridge/internal/bridge"
|
||||
"mesh-delivery-forge-bridge/internal/groups"
|
||||
)
|
||||
|
||||
// performedBucket is the module's state performed (manifest `requires`, the template state): one key per owed act it
|
||||
// performed, the act's outcome and when, with the owed event as heard (novox/hq design 47 *What the gate owes the
|
||||
// forge*). It is a saving and a read, never what keeps an act from happening twice: every act is safe to repeat at the
|
||||
// forge. The group ends' bookkeeping (`published.`, `groups.`, groups.go) is in it too, under the same 7 days.
|
||||
const performedBucket = forgeBridgeModule + "_performed"
|
||||
|
||||
// performedKey is the key of one act in performed: the seat that said it and its id, the id in unpadded base64url, since
|
||||
// an id may hold what a key may not (`#`, `/`).
|
||||
func performedKey(of, id string) string {
|
||||
return of + "." + base64.RawURLEncoding.EncodeToString([]byte(id))
|
||||
}
|
||||
|
||||
// Performed is one value of the state performed: the outcome said as owed.ended, and the owed event it answers, so
|
||||
// bridge_owed_again performs it again from here.
|
||||
type Performed struct {
|
||||
Ended forgemsg.OwedEnded `json:"ended"`
|
||||
Owed json.RawMessage `json:"owed,omitempty"`
|
||||
}
|
||||
|
||||
// performedStore is the state performed as the owing reads and writes it.
|
||||
type performedStore interface {
|
||||
Get(ctx context.Context, key string) (value []byte, found bool, err error)
|
||||
Put(ctx context.Context, key string, value []byte) error
|
||||
}
|
||||
|
||||
type kvPerformed struct{ kv jetstream.KeyValue }
|
||||
|
||||
func (s kvPerformed) Get(ctx context.Context, key string) ([]byte, bool, error) {
|
||||
e, err := s.kv.Get(ctx, key)
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return e.Value(), true, nil
|
||||
}
|
||||
|
||||
func (s kvPerformed) Put(ctx context.Context, key string, value []byte) error {
|
||||
_, err := s.kv.Put(ctx, key, value)
|
||||
return err
|
||||
}
|
||||
|
||||
// owing performs what the gate and the checker owe the forge (design 47 *What the gate owes the forge*): each owed act
|
||||
// through the seat git's verb under its id, its outcome said as owed.ended on the stream forge and then kept in
|
||||
// performed. It holds no timer.
|
||||
type owing struct {
|
||||
call caller
|
||||
pub publisher
|
||||
performed performedStore
|
||||
now func() time.Time
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
// readerOf is the reading of the seat of's owed event into the seat git's verb.
|
||||
func readerOf(of string) (func([]byte) (bridge.OwedCall, error), error) {
|
||||
switch of {
|
||||
case seat.Seat:
|
||||
return bridge.GateOwed, nil
|
||||
case checker.Seat:
|
||||
return bridge.CheckerOwed, nil
|
||||
}
|
||||
return nil, fmt.Errorf("the seat %q says no owed act the bridge performs", of)
|
||||
}
|
||||
|
||||
// heard performs one owed event the seat of said. Its error is only an outcome that could not be said on the stream
|
||||
// forge: the event is then heard again, and the act, safe to repeat, performed again.
|
||||
func (o *owing) heard(ctx context.Context, of string, data []byte) (forgemsg.OwedEnded, error) {
|
||||
read, err := readerOf(of)
|
||||
if err != nil {
|
||||
return o.end(ctx, forgemsg.OwedEnded{Of: of, Outcome: forgemsg.OwedUndecodable, Words: err.Error()}, data)
|
||||
}
|
||||
c, err := read(data)
|
||||
if err != nil {
|
||||
return o.end(ctx, forgemsg.OwedEnded{Of: of, ID: c.ID,
|
||||
Outcome: forgemsg.OwedUndecodable, Words: err.Error()}, data)
|
||||
}
|
||||
if o.held(ctx, c.Of, c.ID) {
|
||||
// said twice, or said again while its outcome was on its way: performed once, the outcome said once
|
||||
return forgemsg.OwedEnded{Of: c.Of, ID: c.ID}, nil
|
||||
}
|
||||
if words, done := o.switchedDone(ctx, c.Of, c.ID); done {
|
||||
// performed by the old bridge before the switch: said performed, never done twice
|
||||
return o.end(ctx, forgemsg.OwedEnded{Of: c.Of, ID: c.ID,
|
||||
Outcome: forgemsg.OwedPerformed, Words: words}, data)
|
||||
}
|
||||
return o.perform(ctx, c, data)
|
||||
}
|
||||
|
||||
// switchedDone says whether a switched act (design 47 *The switch carries what is owed*: the gate's act of a key of its
|
||||
// old state owed or merges, its id meshdeliveryclient.SwitchedActID) was performed by the old bridge: its own bookkeeping
|
||||
// in performed, `<state>.<key>`, holds that key at the act's revision or a later one. Any other id, and bookkeeping that
|
||||
// cannot be read, is not: the act is performed, which changes nothing at the forge.
|
||||
func (o *owing) switchedDone(ctx context.Context, of, id string) (string, bool) {
|
||||
state, key, rev, ok := meshdeliveryclient.ParseSwitchedActID(id)
|
||||
if !ok || of != seat.Seat {
|
||||
return "", false
|
||||
}
|
||||
kept, found, err := o.performed.Get(ctx, state+"."+key)
|
||||
if err != nil || !found {
|
||||
return "", false
|
||||
}
|
||||
at, err := strconv.ParseUint(strings.TrimSpace(string(kept)), 10, 64)
|
||||
if err != nil || at < rev {
|
||||
return "", false
|
||||
}
|
||||
return fmt.Sprintf("performed by the bridge before the switch: its %s.%s at revision %d", state, key, at), true
|
||||
}
|
||||
|
||||
// hear is one message of a seat's stream owed: performed and acknowledged, or, when its outcome could not be said,
|
||||
// left to come again (design 47: the bridge stopped between an act and its owed.ended performs it again).
|
||||
func (o *owing) hear(ctx context.Context, of string, m delivered) {
|
||||
if _, err := o.heard(ctx, of, m.Data()); err != nil {
|
||||
o.logger.Printf("%s, so the act is heard again: %v", m.Subject(), err)
|
||||
_ = m.NakWithDelay(retryDelay)
|
||||
return
|
||||
}
|
||||
_ = m.Ack()
|
||||
}
|
||||
|
||||
// perform calls the seat git's verb of one act and says what it came to.
|
||||
func (o *owing) perform(ctx context.Context, c bridge.OwedCall, data []byte) (forgemsg.OwedEnded, error) {
|
||||
answer, callErr := o.call(ctx, "seat:"+git.Seat+"."+c.Verb, c.Input)
|
||||
outcome, words := bridge.Outcome(answer, callErr)
|
||||
if outcome != forgemsg.OwedPerformed {
|
||||
o.logger.Printf("the owed %s %s of %s#%d (said by %s) is %s: %s", c.Act, c.ID, c.Repository, c.Number, c.Of, outcome, words)
|
||||
}
|
||||
return o.end(ctx, forgemsg.OwedEnded{Of: c.Of, ID: c.ID,
|
||||
Outcome: outcome, Words: words}, data)
|
||||
}
|
||||
|
||||
// end says an act's outcome as owed.ended on the stream forge, then keeps it in performed. An outcome not kept is
|
||||
// logged: the act may be performed again, which changes nothing at the forge.
|
||||
func (o *owing) end(ctx context.Context, e forgemsg.OwedEnded, owed []byte) (forgemsg.OwedEnded, error) {
|
||||
e.At = o.now().UTC()
|
||||
data, err := forgemsg.EncodeOwedEnded(e)
|
||||
if err != nil {
|
||||
return e, fmt.Errorf("the outcome of the owed %s cannot be said: %w", e.ID, err)
|
||||
}
|
||||
if _, err := o.pub.Publish(ctx, forgeSubject(forgemsg.OwedEndedEvent), data); err != nil {
|
||||
return e, fmt.Errorf("the outcome %s of the owed %s could not be said on the stream forge: %w", e.Outcome, e.ID, err)
|
||||
}
|
||||
if e.ID == "" {
|
||||
return e, nil // an undecodable act with no id is said, and has no key to keep
|
||||
}
|
||||
if !json.Valid(owed) {
|
||||
owed = nil
|
||||
}
|
||||
v, err := json.Marshal(Performed{Ended: e, Owed: owed})
|
||||
if err == nil {
|
||||
err = o.performed.Put(ctx, performedKey(e.Of, e.ID), v)
|
||||
}
|
||||
if err != nil {
|
||||
o.logger.Printf("the outcome %s of the owed %s is said but could not be kept in performed: %v", e.Outcome, e.ID, err)
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// held says whether performed holds an outcome of the act already. One that cannot be read is not held: the act is
|
||||
// performed again, which changes nothing at the forge.
|
||||
func (o *owing) held(ctx context.Context, of, id string) bool {
|
||||
_, found, err := o.performed.Get(ctx, performedKey(of, id))
|
||||
if err != nil {
|
||||
o.logger.Printf("performed of the owed %s cannot be read, so it is performed again: %v", id, err)
|
||||
return false
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// catchUp reads the seat mesh-delivery's groups and performs every act said there with no recorded outcome that
|
||||
// performed does not hold either (design 47 *The bridge catches up from the seat*). It answers how many it performed.
|
||||
func (o *owing) catchUp(ctx context.Context) (int, error) {
|
||||
of, err := groups.Read(ctx, o.call)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n := 0
|
||||
for _, g := range of {
|
||||
for _, act := range g.Acts {
|
||||
if act.Outcome != "" || o.held(ctx, seat.Seat, act.Owed.ID) {
|
||||
continue // answered, or performed and its outcome on its way to the gate
|
||||
}
|
||||
data, err := json.Marshal(act.Owed)
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
if _, err := o.heard(ctx, seat.Seat, data); err != nil {
|
||||
return n, err
|
||||
}
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// owedOf is the owed event of an act to perform again: as performed keeps it, else, for the gate's, as its groups say it.
|
||||
func (o *owing) owedOf(ctx context.Context, of, id string) ([]byte, error) {
|
||||
v, found, err := o.performed.Get(ctx, performedKey(of, id))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("performed cannot be read: %w", err)
|
||||
}
|
||||
if found {
|
||||
var p Performed
|
||||
if err := json.Unmarshal(v, &p); err == nil && len(p.Owed) > 0 {
|
||||
return p.Owed, nil
|
||||
}
|
||||
}
|
||||
if of != seat.Seat {
|
||||
return nil, fmt.Errorf("the bridge holds no owed act %s of %s: the checker says its unanswered acts again at its start", id, of)
|
||||
}
|
||||
raw, err := o.call(ctx, "seat:"+seat.Seat+"."+seat.VerbGroups, seat.GroupsInput{All: seat.GroupsInputAllTrue})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the seat %s's groups could not be read: %w", seat.Seat, err)
|
||||
}
|
||||
var a seat.GroupsAnswer
|
||||
if err := json.Unmarshal(raw, &a); err != nil || a.Error != "" {
|
||||
return nil, fmt.Errorf("the seat %s's groups do not answer: %v %s", seat.Seat, err, a.Error)
|
||||
}
|
||||
for _, g := range a.Groups {
|
||||
for _, act := range g.Acts {
|
||||
if act.Owed.ID == id {
|
||||
return json.Marshal(act.Owed)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("neither the bridge nor the seat %s's groups hold an owed act %s", seat.Seat, id)
|
||||
}
|
||||
|
||||
// answer answers one owed ask: an act performed again now, whatever performed holds of it; an act dropped, said as
|
||||
// owed.ended with the outcome dropped and the ask's why (the tool wrote the hand-act log first); or the consumers' backlog.
|
||||
func (o *owing) answer(ctx context.Context, a groups.OwedAsk, backlog func(context.Context) []groups.Backlog) groups.OwedAsk {
|
||||
a.Answered = o.now().UTC()
|
||||
switch a.Kind {
|
||||
case groups.OwedBacklog:
|
||||
a.Backlog, a.Answer = backlog(ctx), "the backlog of each seat's stream owed"
|
||||
return a
|
||||
}
|
||||
data, err := o.owedOf(ctx, a.Of, a.ID)
|
||||
if err != nil {
|
||||
a.Answer = err.Error()
|
||||
return a
|
||||
}
|
||||
var e forgemsg.OwedEnded
|
||||
if a.Kind == groups.OwedDrop {
|
||||
e, err = o.end(ctx, forgemsg.OwedEnded{Of: a.Of, ID: a.ID, Outcome: forgemsg.OwedDropped, Words: a.Why + " (dropped by " + a.By + ")"}, data)
|
||||
} else if read, rerr := readerOf(a.Of); rerr != nil {
|
||||
err = rerr
|
||||
} else if c, rerr := read(data); rerr != nil {
|
||||
e, err = o.end(ctx, forgemsg.OwedEnded{Of: a.Of, ID: a.ID, Outcome: forgemsg.OwedUndecodable, Words: rerr.Error()}, data)
|
||||
} else {
|
||||
e, err = o.perform(ctx, c, data)
|
||||
}
|
||||
if err != nil {
|
||||
a.Answer = err.Error()
|
||||
return a
|
||||
}
|
||||
a.Outcome, a.Answer = e.Outcome, "said as owed.ended: "+e.Outcome
|
||||
if e.Words != "" {
|
||||
a.Answer += ": " + e.Words
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// asked answers one value of the state owed-asks not yet answered, written back by compare-and-set at its revision; an
|
||||
// answered one (this bridge's own write) is left.
|
||||
func (o *owing) asked(ctx context.Context, kv jetstream.KeyValue, key string, rev uint64, value []byte, backlog func(context.Context) []groups.Backlog) {
|
||||
a, err := groups.DecodeOwedAsk(value)
|
||||
if err != nil {
|
||||
o.logger.Printf("%s.%s holds what is not an owed ask, left: %v", groups.OwedAsksState, key, err)
|
||||
return
|
||||
}
|
||||
if a.Done() {
|
||||
return
|
||||
}
|
||||
a = o.answer(ctx, a, backlog)
|
||||
o.logger.Printf("the owed ask %s of %s %s by %s (%s): %s", a.Kind, a.Of, a.ID, a.By, a.Why, a.Answer)
|
||||
data, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if _, err := kv.Update(ctx, key, data, rev); err != nil && !errors.Is(err, jetstream.ErrKeyExists) && !errors.Is(err, jetstream.ErrKeyRevisionMismatch) {
|
||||
o.logger.Printf("%s.%s: the answer could not be written: %v", groups.OwedAsksState, key, err)
|
||||
}
|
||||
}
|
||||
|
||||
// owedBacklog reads the bridge's durable on each seat's stream owed: its acknowledged floor, what waits and what is held,
|
||||
// and the time of the first event after the floor.
|
||||
func owedBacklog(js jetstream.JetStream, cred process.Account, now func() time.Time) func(context.Context) []groups.Backlog {
|
||||
return func(ctx context.Context) []groups.Backlog {
|
||||
var out []groups.Backlog
|
||||
for _, kept := range []string{seat.StreamOwed, checker.StreamOwed} {
|
||||
row := groups.Backlog{Stream: kept, Consumer: cred.EventConsumerName()}
|
||||
c, err := js.Consumer(ctx, row.Stream, row.Consumer)
|
||||
var info *jetstream.ConsumerInfo
|
||||
if err == nil {
|
||||
info, err = c.Info(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
row.Error = err.Error()
|
||||
out = append(out, row)
|
||||
continue
|
||||
}
|
||||
row.LastApplied, row.Waiting, row.Held = info.AckFloor.Stream, info.NumPending, uint64(info.NumAckPending)
|
||||
if row.Waiting+row.Held > 0 {
|
||||
if s, err := js.Stream(ctx, row.Stream); err == nil {
|
||||
if m, err := s.GetMsg(ctx, info.AckFloor.Stream+1); err == nil {
|
||||
row.Oldest, row.OldestSecs = m.Time.UTC(), int64(now().Sub(m.Time).Seconds())
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -156,7 +156,7 @@ type Forge struct {
|
||||
PredefinedRead bool `json:"predefined-read,omitempty"`
|
||||
// Group is the delivery group a merged message's pull request was merged in, and Members every approved member of
|
||||
// that group with its approved head, this one included (novox/hq ADR 0633 §4): the forge bridge sets both from the
|
||||
// gate's merges state, and the indexer gathers the group's merges until the group's end (ForgeGroupEnd). A merge
|
||||
// seat mesh-delivery's verb groups, and the indexer gathers the group's merges until the group's end (ForgeGroupEnd). A merge
|
||||
// with no group carries neither, and an older bridge sends neither; an older reader ignores both.
|
||||
Group string `json:"group,omitempty"`
|
||||
Members []meshdeliveryclient.Head `json:"members,omitempty"`
|
||||
|
||||
@@ -3,7 +3,10 @@ module mesh-delivery-forge-bridge
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/seats/git v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
)
|
||||
|
||||
// Call is a call of a forge tool: the tool by its manifest key and its arguments.
|
||||
type Call struct {
|
||||
Tool string
|
||||
Args map[string]any
|
||||
}
|
||||
|
||||
// Act is one thing the bridge does on the forge for a key of the gate: the act's name (an owed key's act, `status=<context>`,
|
||||
// `view`, `note`, `comment.<seq>`, or `merge`), the pull request and head it is on (Number 0 for a commit no pull
|
||||
// request names) and the forge call that performs it.
|
||||
type Act struct {
|
||||
Name string
|
||||
Repository string
|
||||
Number int
|
||||
Head string
|
||||
Call Call
|
||||
}
|
||||
|
||||
// The forge's tools, by manifest key; their arguments are the ones of mesh-catalog modules/gitea: meshdeliveryclient.ts
|
||||
// commitStatusTool (owner, repo, sha, context, state, description, target_url), tools/index.ts gitea_delivery_view
|
||||
// (owner, repo, number, body), gitea_note_append (owner, repo, sha, ref, line), gitea_add_comment (owner, repo,
|
||||
// number, body), merge.ts gitea_merge_pull_request (owner, repo, number, head, approved_heads).
|
||||
const (
|
||||
commitStatusTool = "gitea.gitea_commit_status"
|
||||
deliveryViewTool = "gitea.gitea_delivery_view"
|
||||
noteAppendTool = "gitea.gitea_note_append"
|
||||
addCommentTool = "gitea.gitea_add_comment"
|
||||
mergePullTool = "gitea.gitea_merge_pull_request"
|
||||
)
|
||||
|
||||
// MergeAct is the name of the act of merging a pull request.
|
||||
const MergeAct = "merge"
|
||||
|
||||
func ownerRepo(repository string) (owner, repo string) {
|
||||
owner, repo, _ = strings.Cut(repository, "/")
|
||||
return owner, repo
|
||||
}
|
||||
|
||||
// OwedAct turns an owed key and its value (mesh-sdk go/delivery OwedKey, EncodeOwed) into the forge call that performs
|
||||
// it. A key that is no owed key, a value that is no owed act, a value whose act is not the key's, and a view or a comment
|
||||
// on no pull request are errors, each saying why.
|
||||
func OwedAct(key string, value []byte) (Act, error) {
|
||||
repository, number, head, name, err := meshdeliveryclient.ParseOwedKey(key)
|
||||
if err != nil {
|
||||
return Act{}, err
|
||||
}
|
||||
o, err := meshdeliveryclient.DecodeOwed(value)
|
||||
if err != nil {
|
||||
return Act{}, fmt.Errorf("the owed act %s: %w", key, err)
|
||||
}
|
||||
kind, context, err := meshdeliveryclient.ParseOwedAct(name)
|
||||
if err != nil {
|
||||
return Act{}, err
|
||||
}
|
||||
if o.Act != kind {
|
||||
return Act{}, fmt.Errorf("the owed act %s says %q, not the %q its key names", key, o.Act, kind)
|
||||
}
|
||||
owner, repo := ownerRepo(repository)
|
||||
a := Act{Name: name, Repository: repository, Number: number, Head: head}
|
||||
needsPull := func() error {
|
||||
if number <= 0 {
|
||||
return fmt.Errorf("the owed %s %s is on a commit no pull request names, and it needs the pull request", kind, key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
switch kind {
|
||||
case meshdeliveryclient.OwedStatus:
|
||||
if o.Context != context {
|
||||
return Act{}, fmt.Errorf("the owed status %s says the context %q, not the %q its key names", key, o.Context, context)
|
||||
}
|
||||
if o.Context == meshdeliveryclient.StatusContextCheck {
|
||||
// No tool writes mesh/check (R4e finding K1), and the bridge performs no act of it: the forge module writes it from
|
||||
// the checker's state mesh-delivery-checker.owed, read by a watch (ADR 0521, finding R2-1).
|
||||
return Act{}, fmt.Errorf("the owed status %s is %s, which the bridge does not perform: the forge module writes it from mesh-delivery-checker.owed",
|
||||
key, meshdeliveryclient.StatusContextCheck)
|
||||
}
|
||||
a.Call = Call{Tool: commitStatusTool, Args: map[string]any{"owner": owner, "repo": repo, "sha": head, "context": o.Context,
|
||||
"state": o.State, "description": o.Text, "target_url": o.Target}}
|
||||
case meshdeliveryclient.OwedView:
|
||||
if err := needsPull(); err != nil {
|
||||
return Act{}, err
|
||||
}
|
||||
a.Call = Call{Tool: deliveryViewTool, Args: map[string]any{"owner": owner, "repo": repo, "number": number, "body": o.Text}}
|
||||
case meshdeliveryclient.OwedNote:
|
||||
a.Call = Call{Tool: noteAppendTool, Args: map[string]any{"owner": owner, "repo": repo, "sha": head, "ref": o.Ref, "line": o.Text}}
|
||||
case meshdeliveryclient.OwedComment:
|
||||
if err := needsPull(); err != nil {
|
||||
return Act{}, err
|
||||
}
|
||||
a.Call = Call{Tool: addCommentTool, Args: map[string]any{"owner": owner, "repo": repo, "number": number, "body": o.Text}}
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// MergeActs turns the value of a merges key (the delivery client's EncodeMerge) into the merges to perform: one for each
|
||||
// pull request in the state merging, in the order the value names them, at the head it expects and with the approved
|
||||
// heads of every member of the group, `<owner>/<repo>@<full sha>` sorted (the merge tool's approved_heads). A pull
|
||||
// request merged or refused already gets none. A value with no approved heads is an error: the forge's merge tool
|
||||
// needs them for a group.
|
||||
func MergeActs(value []byte) ([]Act, error) {
|
||||
m, err := meshdeliveryclient.DecodeMerge(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var handed []seat.MergesByGroupValuePullsItem
|
||||
for _, p := range m.Pulls {
|
||||
if meshdeliveryclient.MergeState(p.State) == meshdeliveryclient.MergeMerging {
|
||||
handed = append(handed, p)
|
||||
}
|
||||
}
|
||||
if len(handed) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if len(m.Approved) == 0 {
|
||||
return nil, fmt.Errorf("the merge of the group %s names no approved heads, so the forge's merge tool has nothing to merge at", m.Group)
|
||||
}
|
||||
approved := make([]string, 0, len(m.Approved))
|
||||
for key, sha := range m.Approved {
|
||||
repository, _, ok := strings.Cut(key, "@")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("the approved head %q of the group %s is not <repository>@<number>", key, m.Group)
|
||||
}
|
||||
approved = append(approved, repository+"@"+sha)
|
||||
}
|
||||
slices.Sort(approved)
|
||||
var acts []Act
|
||||
for _, p := range handed {
|
||||
owner, repo := ownerRepo(p.Repository)
|
||||
acts = append(acts, Act{Name: MergeAct, Repository: p.Repository, Number: int(p.Number), Head: p.Head,
|
||||
Call: Call{Tool: mergePullTool, Args: map[string]any{"owner": owner, "repo": repo, "number": int(p.Number), "head": p.Head,
|
||||
"approved_heads": approved}}})
|
||||
}
|
||||
return acts, nil
|
||||
}
|
||||
|
||||
// RefusedMessage is the refused message the bridge writes on FORGE when the forge refuses an act: the act, the pull
|
||||
// request and head it was on, and the forge's own words.
|
||||
func RefusedMessage(a Act, words string) (Message, error) {
|
||||
event, err := forgemsg.ForgeEvent(a.Repository, forgemsg.ForgeRefused)
|
||||
if err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
body, err := forgemsg.EncodeForge(forgemsg.Forge{Kind: forgemsg.ForgeRefused, Repository: a.Repository, Number: a.Number,
|
||||
Head: a.Head, Act: a.Name, Words: words})
|
||||
if err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
return Message{Event: event, Data: body}, nil
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
"git.novox.be/novox/mesh-sdk/go/keys"
|
||||
)
|
||||
|
||||
const (
|
||||
headA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
headB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
)
|
||||
|
||||
// owedValue is an owed value as the SDK writes it (mesh-sdk go/delivery EncodeOwed), and its key as OwedKey spells it.
|
||||
func owedValue(t *testing.T, repository string, number int, act string, o seat.OwedByRepositoryNumberHeadActValue) (string, []byte) {
|
||||
t.Helper()
|
||||
key, err := meshdeliveryclient.OwedKey(repository, number, headA, act)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := meshdeliveryclient.EncodeOwed(o)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return key, b
|
||||
}
|
||||
|
||||
func TestOwedStatusIsACommitStatusCall(t *testing.T) { // 1341/AC1, K1
|
||||
key, v := owedValue(t, "novox/mesh-delivery", 12, "status=mesh/merge-gate",
|
||||
seat.OwedByRepositoryNumberHeadActValue{Act: meshdeliveryclient.OwedStatus, Context: "mesh/merge-gate", State: "pending", Text: "waiting", Target: "https://forge.example/novox/mesh-delivery/pulls/12"})
|
||||
a, err := OwedAct(key, v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := Call{Tool: "gitea.gitea_commit_status", Args: map[string]any{"owner": "novox", "repo": "mesh-delivery", "sha": headA,
|
||||
"context": "mesh/merge-gate", "state": "pending", "description": "waiting", "target_url": "https://forge.example/novox/mesh-delivery/pulls/12"}}
|
||||
if !reflect.DeepEqual(a.Call, want) {
|
||||
t.Errorf("call = %+v, want %+v", a.Call, want)
|
||||
}
|
||||
if a.Repository != "novox/mesh-delivery" || a.Number != 12 || a.Head != headA || a.Name != "status=mesh/merge-gate" {
|
||||
t.Errorf("act = %+v", a)
|
||||
}
|
||||
// R4e findings K1 and R2-1 (issue 1301 S4; ADR 0503 §3, ADR 0521): no tool writes mesh/check, since a tool is told no
|
||||
// caller and node-tools invokes every tool; the forge module writes it from the checker's owed state, and the bridge
|
||||
// performs no mesh/check act.
|
||||
ck, cv := checkerKV(t, seat.OwedByRepositoryNumberHeadActValue{Act: meshdeliveryclient.OwedStatus, Context: meshdeliveryclient.StatusContextCheck, State: "success", Text: "pass", Target: "https://view.example/checks/g.1"})
|
||||
if a, err := OwedAct(ck, cv); err == nil || !strings.Contains(err.Error(), "mesh-delivery-checker.owed") {
|
||||
t.Fatalf("OwedAct(mesh/check) = %+v, %v; want refused, naming where the forge module reads it", a, err)
|
||||
}
|
||||
// Nor through the gate's own owed bucket: the context is refused whichever key carries it.
|
||||
gk, gv := owedValue(t, "novox/mesh-delivery", 12, "status=mesh/check",
|
||||
seat.OwedByRepositoryNumberHeadActValue{Act: meshdeliveryclient.OwedStatus, Context: meshdeliveryclient.StatusContextCheck, State: "success", Text: "pass", Target: "https://x.example"})
|
||||
if a, err := OwedAct(gk, gv); err == nil || !strings.Contains(err.Error(), "mesh-delivery-checker.owed") {
|
||||
t.Fatalf("OwedAct(gate's mesh/check) = %+v, %v; want refused", a, err)
|
||||
}
|
||||
}
|
||||
|
||||
// checkerKV is the status the checker owes, as it writes it (the gate's owed key with the act status=mesh/check).
|
||||
func checkerKV(t *testing.T, o seat.OwedByRepositoryNumberHeadActValue) (string, []byte) {
|
||||
t.Helper()
|
||||
key, err := meshdeliveryclient.OwedKey("novox/mesh-delivery", 12, headA, "status=mesh/check")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v, err := meshdeliveryclient.EncodeOwed(o)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return key, v
|
||||
}
|
||||
|
||||
func TestOwedViewNoteAndCommentCalls(t *testing.T) { // 1341/AC2
|
||||
cases := []struct {
|
||||
act string
|
||||
o seat.OwedByRepositoryNumberHeadActValue
|
||||
want Call
|
||||
}{
|
||||
{"view", seat.OwedByRepositoryNumberHeadActValue{Act: meshdeliveryclient.OwedView, Text: "the view"},
|
||||
Call{Tool: "gitea.gitea_delivery_view", Args: map[string]any{"owner": "novox", "repo": "mesh-delivery", "number": 12, "body": "the view"}}},
|
||||
{"note", seat.OwedByRepositoryNumberHeadActValue{Act: meshdeliveryclient.OwedNote, Text: "a line", Ref: "mesh-plan"},
|
||||
Call{Tool: "gitea.gitea_note_append", Args: map[string]any{"owner": "novox", "repo": "mesh-delivery", "sha": headA, "ref": "mesh-plan", "line": "a line"}}},
|
||||
{"comment.2", seat.OwedByRepositoryNumberHeadActValue{Act: meshdeliveryclient.OwedComment, Text: "hello"},
|
||||
Call{Tool: "gitea.gitea_add_comment", Args: map[string]any{"owner": "novox", "repo": "mesh-delivery", "number": 12, "body": "hello"}}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
key, v := owedValue(t, "novox/mesh-delivery", 12, c.act, c.o)
|
||||
a, err := OwedAct(key, v)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", c.act, err)
|
||||
}
|
||||
if !reflect.DeepEqual(a.Call, c.want) {
|
||||
t.Errorf("%s: call = %+v, want %+v", c.act, a.Call, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwedActWithoutAPullRequestNeedsOneOnlyWhereTheToolDoes(t *testing.T) {
|
||||
key, v := owedValue(t, "novox/mesh-delivery", 0, "note", seat.OwedByRepositoryNumberHeadActValue{Act: meshdeliveryclient.OwedNote, Text: "l", Ref: "mesh-plan"})
|
||||
if _, err := OwedAct(key, v); err != nil {
|
||||
t.Errorf("a note on a commit no pull request names: %v", err)
|
||||
}
|
||||
key, v = owedValue(t, "novox/mesh-delivery", 0, "view", seat.OwedByRepositoryNumberHeadActValue{Act: meshdeliveryclient.OwedView, Text: "v"})
|
||||
if _, err := OwedAct(key, v); err == nil {
|
||||
t.Error("a view of no pull request was taken")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwedActThatDisagreesWithItsKeyIsRefused(t *testing.T) {
|
||||
// A dotted repository's key is unescaped, and its act names the repository whole.
|
||||
dk, dv := owedValue(t, "novox/my.repo", 3, "comment.1", seat.OwedByRepositoryNumberHeadActValue{Act: meshdeliveryclient.OwedComment, Text: "x"})
|
||||
if a, err := OwedAct(dk, dv); err != nil || a.Call.Args["repo"] != "my.repo" || a.Repository != "novox/my.repo" {
|
||||
t.Errorf("a dotted repository's act = %+v, %v", a, err)
|
||||
}
|
||||
key, _ := owedValue(t, "novox/mesh-delivery", 12, "view", seat.OwedByRepositoryNumberHeadActValue{Act: meshdeliveryclient.OwedView, Text: "v"})
|
||||
v, _ := meshdeliveryclient.EncodeOwed(seat.OwedByRepositoryNumberHeadActValue{Act: meshdeliveryclient.OwedComment, Text: "v"})
|
||||
if _, err := OwedAct(key, v); err == nil {
|
||||
t.Error("a comment under a view's key was taken")
|
||||
}
|
||||
if _, err := OwedAct("nonsense", v); err == nil {
|
||||
t.Error("a key that is no owed key was taken")
|
||||
}
|
||||
if _, err := OwedAct(key, []byte(`{"v":1`)); err == nil {
|
||||
t.Error("a value that is not json was taken")
|
||||
}
|
||||
}
|
||||
|
||||
func mergeValue(t *testing.T, m seat.MergesByGroupValue) []byte {
|
||||
t.Helper()
|
||||
b, err := meshdeliveryclient.EncodeMerge(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestMergeActsHandOnlyTheMemberHandedWithEveryApprovedHead(t *testing.T) { // 1342/AC1
|
||||
approved := map[string]string{meshdeliveryclient.ApprovedKey("novox/mesh-sdk", 4): headB, meshdeliveryclient.ApprovedKey("novox/mesh-delivery", 12): headA}
|
||||
v := mergeValue(t, seat.MergesByGroupValue{Group: "g", Ask: "merge.g.0123456789ab", Approved: approved, Pulls: []seat.MergesByGroupValuePullsItem{
|
||||
{Repository: "novox/mesh-sdk", Number: 4, Head: headB, State: string(meshdeliveryclient.MergeMerged), Commit: "c0"},
|
||||
{Repository: "novox/mesh-delivery", Number: 12, Head: headA, State: string(meshdeliveryclient.MergeMerging)},
|
||||
}})
|
||||
acts, err := MergeActs(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(acts) != 1 {
|
||||
t.Fatalf("%d acts, want 1 (the merged member is not merged again)", len(acts))
|
||||
}
|
||||
want := Call{Tool: "gitea.gitea_merge_pull_request", Args: map[string]any{"owner": "novox", "repo": "mesh-delivery", "number": 12, "head": headA,
|
||||
"approved_heads": []string{"novox/mesh-delivery@" + headA, "novox/mesh-sdk@" + headB}}}
|
||||
if !reflect.DeepEqual(acts[0].Call, want) {
|
||||
t.Errorf("call = %+v, want %+v", acts[0].Call, want)
|
||||
}
|
||||
if acts[0].Name != "merge" || acts[0].Number != 12 || acts[0].Head != headA {
|
||||
t.Errorf("act = %+v", acts[0])
|
||||
}
|
||||
// A merge with no approved heads is refused: the forge's tool would refuse it with nothing to say which.
|
||||
v = mergeValue(t, seat.MergesByGroupValue{Group: "g", Ask: "a", Pulls: []seat.MergesByGroupValuePullsItem{
|
||||
{Repository: "novox/mesh-delivery", Number: 12, Head: headA, State: string(meshdeliveryclient.MergeMerging)}}})
|
||||
if _, err := MergeActs(v); err == nil {
|
||||
t.Error("a merge with no approved heads was taken")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefusedMessageNamesTheActAndTheForgesWords(t *testing.T) { // 1341/AC3, 1342/AC2
|
||||
a := Act{Name: "merge", Repository: "novox/mesh-delivery", Number: 12, Head: headA}
|
||||
m, err := RefusedMessage(a, "novox/mesh-delivery#12 is not merged: its head moved")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.Event != "forge.novox.mesh-delivery.refused" {
|
||||
t.Errorf("event = %q", m.Event)
|
||||
}
|
||||
f, err := forgemsg.DecodeForge(m.Data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := forgemsg.Forge{V: keys.Version, Kind: forgemsg.ForgeRefused, Repository: "novox/mesh-delivery", Number: 12, Head: headA,
|
||||
Act: "merge", Words: "novox/mesh-delivery#12 is not merged: its head moved"}
|
||||
if !forgemsg.SameForge(f, want) {
|
||||
t.Errorf("message = %+v", f)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package bridge turns the forge module's pull request events into the messages the forge bridge writes on FORGE.
|
||||
// Package bridge turns the seat git's pull request events into the messages the forge bridge writes on FORGE.
|
||||
package bridge
|
||||
|
||||
import (
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
git "git.novox.be/novox/mesh-delivery/contracts/go/seats/git"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/keys"
|
||||
@@ -35,13 +36,13 @@ var ErrRetry = errors.New("the forge could not say")
|
||||
const Trunk = "main"
|
||||
|
||||
// Trees asks the forge for the trees of a commit: the root tree and the tree of every directory holding a module.json
|
||||
// (the root directory spelled ""), as the forge's gitea_commit_trees answers (issue 1234/AC4).
|
||||
// (the root directory spelled ""), as the seat git's verb trees answers (issue 1234/AC4).
|
||||
type Trees func(owner, repo, sha string) (root string, trees map[string]string, err error)
|
||||
|
||||
// ErrNotFound says the forge has no file at that path and ref (the forge's "404", issue 1448).
|
||||
var ErrNotFound = errors.New("the forge has no such file")
|
||||
|
||||
// Files asks the forge for one file's bytes at a ref (a commit), as the forge's gitea_get_file answers; a file the forge
|
||||
// Files asks the forge for one file's bytes at a ref (a commit), as the seat git's verb file answers; a file the forge
|
||||
// says is not there is ErrNotFound, any other failure any other error (issue 1448).
|
||||
type Files func(owner, repo, path, ref string) ([]byte, error)
|
||||
|
||||
@@ -64,16 +65,16 @@ type Message struct {
|
||||
Said string
|
||||
}
|
||||
|
||||
// The subjects of the forge module's events the bridge writes pull messages for (mesh-catalog modules/gitea,
|
||||
// published by the module as mesh.mod.<module>.event.<event>).
|
||||
// The subjects of the seat git's events the bridge writes pull messages for (novox/hq design 47 *The seat `git`*): the
|
||||
// seat's holder says them on mesh.seat.git.event.<event>, whichever module holds it; the bridge names none.
|
||||
const (
|
||||
UpdatedSubject = "mesh.mod.gitea.event.pull.updated"
|
||||
ClosedSubject = "mesh.mod.gitea.event.pull.closed"
|
||||
MergedSubject = "mesh.mod.gitea.event.pull.merged"
|
||||
// DeletedSubject is the forge module's forge.delete: a branch or a tag deleted (mesh-catalog modules/gitea
|
||||
// webhook.ts: owner, repo, ref, ref_type, sha). Only the forge module's account may publish it, the subject being
|
||||
// in its own namespace (design 32 § 2), so no other module can end a branch's tagged versions.
|
||||
DeletedSubject = "mesh.mod.gitea.event.forge.delete"
|
||||
UpdatedSubject = git.SubjectPullUpdated
|
||||
ClosedSubject = git.SubjectPullClosed
|
||||
MergedSubject = git.SubjectPullMerged
|
||||
// DeletedSubject is the seat's forge.delete: a branch or a tag deleted (repository, ref, ref-type). Only the seat's
|
||||
// holder may publish on the seat's subjects (novox/hq ADR 0363 §2), so no other module can end a branch's tagged
|
||||
// versions.
|
||||
DeletedSubject = git.SubjectForgeDelete
|
||||
)
|
||||
|
||||
// ForgeBranchDeleted is the kind of a branch-deleted message on FORGE (novox/hq design 51 *The forge bridge says a
|
||||
@@ -91,21 +92,14 @@ type BranchDeleted struct {
|
||||
Head string `json:"head,omitempty"`
|
||||
}
|
||||
|
||||
// deleted is the part of forge.delete the bridge reads.
|
||||
type deleted struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
Ref string `json:"ref"`
|
||||
RefType string `json:"ref_type"`
|
||||
SHA string `json:"sha"`
|
||||
}
|
||||
|
||||
// branchDeleted turns forge.delete into a branch-deleted message; a deleted tag is not handled.
|
||||
func branchDeleted(subject string, data []byte) (Message, error) {
|
||||
var d deleted
|
||||
if err := json.Unmarshal(data, &d); err != nil {
|
||||
var e git.ForgeDeleteEvent
|
||||
if err := json.Unmarshal(data, &e); err != nil {
|
||||
return Message{}, unreadable(subject, "the body is not json: %v", err)
|
||||
}
|
||||
d := struct{ Owner, Repo, Ref, RefType string }{Ref: e.Ref, RefType: e.RefType}
|
||||
d.Owner, d.Repo, _ = strings.Cut(e.Repository, "/")
|
||||
if d.RefType != "branch" {
|
||||
return Message{}, fmt.Errorf("%w: %s of a %s (%s)", ErrNotHandled, subject, d.RefType, d.Ref)
|
||||
}
|
||||
@@ -118,7 +112,7 @@ func branchDeleted(subject string, data []byte) (Message, error) {
|
||||
case branch == Trunk:
|
||||
return Message{}, fmt.Errorf("%w: %s of the trunk %s", ErrNotHandled, subject, Trunk)
|
||||
}
|
||||
body, err := json.Marshal(BranchDeleted{V: keys.Version, Kind: ForgeBranchDeleted, Repository: d.Owner + "/" + d.Repo, Branch: branch, Head: d.SHA})
|
||||
body, err := json.Marshal(BranchDeleted{V: keys.Version, Kind: ForgeBranchDeleted, Repository: d.Owner + "/" + d.Repo, Branch: branch})
|
||||
if err != nil {
|
||||
return Message{}, unreadable(subject, "%v", err)
|
||||
}
|
||||
@@ -128,22 +122,48 @@ func branchDeleted(subject string, data []byte) (Message, error) {
|
||||
|
||||
var sha40 = regexp.MustCompile(`^[0-9a-f]{40}$`)
|
||||
|
||||
// pull is the part of gitea.pull.merged (index.ts:186-207), gitea.pull.updated (mesh-catalog modules/gitea/trunk.ts:233-250) and gitea.pull.closed
|
||||
// (index.ts:127-128) the bridge reads; head is the branch and head_sha the commit.
|
||||
// pull is the part of the seat git's pull.updated, pull.merged and pull.closed the bridge reads: Head is the branch and
|
||||
// HeadSHA the commit, Owner and Repo the repository's two halves.
|
||||
type pull struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
Number int `json:"number"`
|
||||
Head string `json:"head"`
|
||||
HeadSHA string `json:"head_sha"`
|
||||
ModuleDirs []string `json:"module_dirs"`
|
||||
ModuleDirsSaid bool `json:"module_dirs_said"`
|
||||
Base string `json:"base"`
|
||||
MergeSHA string `json:"merge_commit_sha"`
|
||||
// Paths are the files the pull request changes (a removed or renamed one included), PathsTruncated says the forge
|
||||
// cut the list; nil when the forge did not say them.
|
||||
Paths *[]string `json:"paths"`
|
||||
PathsTruncated bool `json:"paths_truncated"`
|
||||
Owner, Repo string
|
||||
Number int
|
||||
Head, HeadSHA string
|
||||
ModuleDirs []string
|
||||
ModuleDirsSaid bool
|
||||
Base string
|
||||
MergeSHA string
|
||||
}
|
||||
|
||||
// pullOf reads one of the seat's pull events. The directories are the seat's word only when its change says them: an
|
||||
// absent `module-dirs` is "could not be read", never "none".
|
||||
func pullOf(data []byte) (pull, error) {
|
||||
var e struct {
|
||||
Repository string `json:"repository"`
|
||||
Number int64 `json:"number"`
|
||||
Branch string `json:"branch"`
|
||||
Head string `json:"head"`
|
||||
Base string `json:"base"`
|
||||
MergeCommit string `json:"merge-commit"`
|
||||
Change json.RawMessage `json:"change"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &e); err != nil {
|
||||
return pull{}, err
|
||||
}
|
||||
p := pull{Number: int(e.Number), Head: e.Branch, HeadSHA: e.Head, Base: e.Base, MergeSHA: e.MergeCommit}
|
||||
p.Owner, p.Repo, _ = strings.Cut(e.Repository, "/")
|
||||
if len(e.Change) > 0 && string(e.Change) != "null" {
|
||||
var c map[string]json.RawMessage
|
||||
if err := json.Unmarshal(e.Change, &c); err != nil {
|
||||
return pull{}, fmt.Errorf("its change: %w", err)
|
||||
}
|
||||
if raw, said := c["module-dirs"]; said {
|
||||
if err := json.Unmarshal(raw, &p.ModuleDirs); err != nil {
|
||||
return pull{}, fmt.Errorf("its change's module-dirs: %w", err)
|
||||
}
|
||||
p.ModuleDirsSaid = true
|
||||
}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func unreadable(subject, why string, args ...any) error {
|
||||
@@ -176,12 +196,12 @@ func ConvertKept(subject string, data []byte, trees Trees, files Files, kept Kep
|
||||
default:
|
||||
return Message{}, fmt.Errorf("%w: %s", ErrNotHandled, subject)
|
||||
}
|
||||
var p pull
|
||||
if err := json.Unmarshal(data, &p); err != nil {
|
||||
return Message{}, unreadable(subject, "the body is not json: %v", err)
|
||||
p, err := pullOf(data)
|
||||
if err != nil {
|
||||
return Message{}, unreadable(subject, "the body is not the seat's json: %v", err)
|
||||
}
|
||||
switch {
|
||||
case p.Owner == "" || p.Repo == "":
|
||||
case p.Owner == "" || p.Repo == "" || strings.Contains(p.Repo, "/"):
|
||||
return Message{}, unreadable(subject, "no owner or repo")
|
||||
case p.Number <= 0:
|
||||
return Message{}, unreadable(subject, "no pull request number")
|
||||
@@ -462,29 +482,39 @@ func merged(subject string, p pull, trees Trees, files Files, kept Kept) (Messag
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// openPull is an entry of gitea_open_pulls's answer (mesh-catalog modules/gitea/client.ts:369-381 openPulls, with the
|
||||
// branch as `head`, issue 1234/AC5), the part the bridge reads.
|
||||
type openPull struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
Number int `json:"number"`
|
||||
Base string `json:"base"`
|
||||
Head string `json:"head"`
|
||||
HeadSHA string `json:"head_sha"`
|
||||
// openPulls reads the seat git's answer of open-pulls: an answer saying an error is the error, never none open.
|
||||
func openPulls(answer []byte) ([]pull, error) {
|
||||
var a git.OpenPullsAnswer
|
||||
if err := json.Unmarshal(answer, &a); err != nil {
|
||||
return nil, fmt.Errorf("the seat git's answer of open-pulls is not its contract's: %v", err)
|
||||
}
|
||||
if a.Error != "" {
|
||||
return nil, fmt.Errorf("the seat git refused open-pulls: %s", a.Error)
|
||||
}
|
||||
var said map[string]json.RawMessage
|
||||
if _ = json.Unmarshal(answer, &said); said["pulls"] == nil || string(said["pulls"]) == "null" {
|
||||
// None open is `pulls: []`; an answer naming no list would drop every candidate on a holder's fault.
|
||||
return nil, fmt.Errorf("open-pulls: %w", errNoAnswer)
|
||||
}
|
||||
out := make([]pull, 0, len(a.Pulls))
|
||||
for _, o := range a.Pulls {
|
||||
p := pull{Number: int(o.Pull.Number), Head: o.Pull.Branch, HeadSHA: o.Pull.Head, Base: o.Pull.Base}
|
||||
p.Owner, p.Repo, _ = strings.Cut(o.Pull.Repository, "/")
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// OpenPulls turns the answer of gitea_open_pulls, {pulls: [...]}, into one pull message per open pull request into
|
||||
// OpenPulls turns the seat git's answer of open-pulls into one pull message per open pull request into
|
||||
// the trunk, action updated and its directories not known (the forge's answer does not say them). A pull request into
|
||||
// another branch gets none. An entry that cannot be read is named in skipped and gets none; an answer that is not
|
||||
// {pulls} is an error.
|
||||
// the contract's, or that says an error, is an error.
|
||||
func OpenPulls(answer []byte) (msgs []Message, skipped []string, err error) {
|
||||
var a struct {
|
||||
Pulls *[]openPull `json:"pulls"`
|
||||
pulls, err := openPulls(answer)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := json.Unmarshal(answer, &a); err != nil || a.Pulls == nil {
|
||||
return nil, nil, fmt.Errorf("the answer of gitea_open_pulls is not {pulls: [...]}: %v", err)
|
||||
}
|
||||
for _, p := range *a.Pulls {
|
||||
for _, p := range pulls {
|
||||
if p.Base != Trunk {
|
||||
continue
|
||||
}
|
||||
@@ -523,18 +553,16 @@ func Track(f forgemsg.Forge) (key, head string, ok bool) {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// OpenMessage turns the answer of gitea_open_pulls into the open message: every open pull request into the trunk, by
|
||||
// OpenMessage turns the seat git's answer of open-pulls into the open message: every open pull request into the trunk, by
|
||||
// repository and number, whether its head reads or not and whether FORGE holds it or not, in repository and number
|
||||
// order. A pull request into another branch is not named. An answer that names none is a message too: nothing is open.
|
||||
func OpenMessage(answer []byte) (Message, error) {
|
||||
var a struct {
|
||||
Pulls *[]openPull `json:"pulls"`
|
||||
}
|
||||
if err := json.Unmarshal(answer, &a); err != nil || a.Pulls == nil {
|
||||
return Message{}, fmt.Errorf("the answer of gitea_open_pulls is not {pulls: [...]}: %v", err)
|
||||
pulls, err := openPulls(answer)
|
||||
if err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
open := []forgemsg.OpenPull{}
|
||||
for _, p := range *a.Pulls {
|
||||
for _, p := range pulls {
|
||||
if p.Base != Trunk || p.Owner == "" || p.Repo == "" || p.Number <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
@@ -8,44 +9,63 @@ import (
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
"git.novox.be/novox/mesh-sdk/go/keys"
|
||||
|
||||
"mesh-delivery-forge-bridge/internal/gitfixture"
|
||||
)
|
||||
|
||||
const head = "0123456789abcdef0123456789abcdef01234567"
|
||||
|
||||
// updated is the body of gitea.pull.updated as mesh-catalog modules/gitea/trunk.ts:233-250 (announcementOf) writes
|
||||
// it: owner, repo, number, base_sha, title, body, base, head (the branch), head_sha, clone_url, html_url, paths,
|
||||
// paths_truncated, removed, module_dirs, module_dirs_said, merge_check, merge_check_said.
|
||||
// updated is the body of the seat git's pull.updated (contracts/seats/git): the pull request, its base's head and its
|
||||
// change. extra names the parts a test changes by their short names (owner, repo, number, head for the branch,
|
||||
// head_sha for the commit, base, module_dirs, module_dirs_said), a nil value leaving the part out (seatPull).
|
||||
func updated(extra map[string]any) []byte {
|
||||
m := map[string]any{
|
||||
"owner": "novox", "repo": "mesh-delivery", "number": 12,
|
||||
"base_sha": "ffffffffffffffffffffffffffffffffffffffff", "title": "A change", "body": "text", "base": "main",
|
||||
"head": "feat/x", "head_sha": head, "clone_url": "https://forge.example/novox/mesh-delivery.git",
|
||||
"html_url": "https://forge.example/novox/mesh-delivery/pulls/12",
|
||||
"paths": []string{"modules/a/x.go"}, "paths_truncated": false, "removed": []string{},
|
||||
"module_dirs": []string{"modules/a"}, "module_dirs_said": true,
|
||||
"merge_check": true, "merge_check_said": true,
|
||||
}
|
||||
for k, v := range extra {
|
||||
if v == nil {
|
||||
delete(m, k)
|
||||
} else {
|
||||
m[k] = v
|
||||
return seatPull(map[string]any{"base_sha": "ffffffffffffffffffffffffffffffffffffffff",
|
||||
"paths": []string{"modules/a/x.go"}, "module_dirs": []string{"modules/a"}, "module_dirs_said": true}, extra)
|
||||
}
|
||||
|
||||
// closed is the body of the seat git's pull.closed.
|
||||
func closed() []byte { return seatPull(nil, nil) }
|
||||
|
||||
// seatPull is a pull event of the seat git: the pull request novox/mesh-delivery#12 of feat/x at head into main, with
|
||||
// the parts of base and extra laid over it by their short names, a nil value leaving the part out. module_dirs is said
|
||||
// in the change only when module_dirs_said is true: its absence is "could not be read".
|
||||
func seatPull(base, extra map[string]any) []byte {
|
||||
m := map[string]any{"owner": "novox", "repo": "mesh-delivery", "number": 12, "title": "A change", "head": "feat/x",
|
||||
"head_sha": head, "base": "main", "html_url": "https://forge.example/novox/mesh-delivery/pulls/12"}
|
||||
for _, over := range []map[string]any{base, extra} {
|
||||
for k, v := range over {
|
||||
if v == nil {
|
||||
delete(m, k)
|
||||
} else {
|
||||
m[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
b, _ := json.Marshal(m)
|
||||
e := map[string]any{}
|
||||
owner, _ := m["owner"].(string)
|
||||
repo, _ := m["repo"].(string)
|
||||
if owner != "" || repo != "" {
|
||||
e["repository"] = owner + "/" + repo
|
||||
}
|
||||
for short, name := range map[string]string{"number": "number", "title": "title", "head": "branch", "head_sha": "head",
|
||||
"base": "base", "html_url": "url", "merge_commit_sha": "merge-commit", "base_sha": "base-head", "merged_at": "merged-at"} {
|
||||
if v, ok := m[short]; ok {
|
||||
e[name] = v
|
||||
}
|
||||
}
|
||||
if paths, ok := m["paths"]; ok {
|
||||
c := map[string]any{"paths": paths}
|
||||
if m["module_dirs_said"] == true && m["module_dirs"] != nil {
|
||||
c["module-dirs"] = m["module_dirs"]
|
||||
}
|
||||
e["change"] = c
|
||||
}
|
||||
b, _ := json.Marshal(e)
|
||||
return b
|
||||
}
|
||||
|
||||
// closed is the body of gitea.pull.closed as mesh-catalog modules/gitea/index.ts:127-128 writes it: owner, repo,
|
||||
// number, title, head, head_sha, base, html_url.
|
||||
func closed() []byte {
|
||||
b, _ := json.Marshal(map[string]any{"owner": "novox", "repo": "mesh-delivery", "number": 12, "title": "A change",
|
||||
"head": "feat/x", "head_sha": head, "base": "main", "html_url": "https://forge.example/novox/mesh-delivery/pulls/12"})
|
||||
return b
|
||||
}
|
||||
|
||||
const updatedSubject = "mesh.mod.gitea.event.pull.updated"
|
||||
const closedSubject = "mesh.mod.gitea.event.pull.closed"
|
||||
const updatedSubject = "mesh.seat.git.event.pull-updated"
|
||||
const closedSubject = "mesh.seat.git.event.pull-closed"
|
||||
|
||||
func TestUpdatedBecomesAPullMessage(t *testing.T) { // 1235/AC2 (the conversion; the bus is the integration test)
|
||||
msg, err := Convert(updatedSubject, updated(nil), nil, anyFiles)
|
||||
@@ -142,8 +162,8 @@ func TestUnreadableEventsWriteNothing(t *testing.T) { // 1235/AC5
|
||||
"upper-case head": {updatedSubject, updated(map[string]any{"head_sha": strings.ToUpper(head)}), "head"},
|
||||
"no branch": {updatedSubject, updated(map[string]any{"head": nil}), "branch"},
|
||||
"not json": {updatedSubject, []byte("{"), "json"},
|
||||
"closed no head": {closedSubject, mustDrop(closed(), "head_sha"), "head"},
|
||||
"closed no repo": {closedSubject, mustDrop(closed(), "repo"), "repo"},
|
||||
"closed no head": {closedSubject, mustDrop(closed(), "head"), "head"},
|
||||
"closed no repo": {closedSubject, mustDrop(closed(), "repository"), "repo"},
|
||||
} {
|
||||
msg, err := Convert(c.subject, c.data, nil, anyFiles)
|
||||
if err == nil {
|
||||
@@ -171,21 +191,21 @@ func mustDrop(body []byte, key string) []byte {
|
||||
}
|
||||
|
||||
func TestOtherSubjectsAreNotHandled(t *testing.T) { // 1235, 1236
|
||||
for _, s := range []string{"mesh.mod.gitea.event.something.else", "mesh.mod.other.event.pull.updated", "mesh.mod.other.event.forge.delete"} {
|
||||
for _, s := range []string{"mesh.seat.git.event.something.else", "mesh.mod.gitea.event.pull.updated", "mesh.mod.other.event.forge.delete"} {
|
||||
if _, err := Convert(s, updated(nil), nil, anyFiles); !errors.Is(err, ErrNotHandled) {
|
||||
t.Errorf("%s: err = %v, want ErrNotHandled", s, err)
|
||||
}
|
||||
}
|
||||
// The forge module's forge.delete of a branch is a branch-deleted message (DLV-FB1, design 51); of a tag or the
|
||||
// The seat's forge.delete of a branch is a branch-deleted message (DLV-FB1, design 51); of a tag or the
|
||||
// trunk it is not handled, and a body naming no repository is unreadable.
|
||||
del := func(refType, ref string) []byte {
|
||||
b, _ := json.Marshal(map[string]any{"owner": "novox", "repo": "mesh-sdk", "full_name": "novox/mesh-sdk", "ref": ref, "ref_type": refType, "sha": head})
|
||||
b, _ := json.Marshal(map[string]any{"repository": "novox/mesh-sdk", "ref": ref, "ref-type": refType, "sender": "jochen"})
|
||||
return b
|
||||
}
|
||||
msg, err := Convert(DeletedSubject, del("branch", "feat/x"), nil, anyFiles)
|
||||
var bd BranchDeleted
|
||||
if err != nil || msg.Event != "forge.novox.mesh-sdk.branch-deleted" || json.Unmarshal(msg.Data, &bd) != nil ||
|
||||
bd.Kind != ForgeBranchDeleted || bd.Repository != "novox/mesh-sdk" || bd.Branch != "feat/x" || bd.Head != head || msg.Said == "" {
|
||||
bd.Kind != ForgeBranchDeleted || bd.Repository != "novox/mesh-sdk" || bd.Branch != "feat/x" || bd.Head != "" || msg.Said == "" {
|
||||
t.Errorf("forge.delete of a branch = %+v (%s), %v", msg, msg.Data, err)
|
||||
}
|
||||
for _, c := range []struct{ refType, ref string }{{"tag", "go/v0.1.13"}, {"branch", "main"}} {
|
||||
@@ -193,7 +213,7 @@ func TestOtherSubjectsAreNotHandled(t *testing.T) { // 1235, 1236
|
||||
t.Errorf("forge.delete of %s %s: err = %v, want ErrNotHandled", c.refType, c.ref, err)
|
||||
}
|
||||
}
|
||||
if _, err := Convert(DeletedSubject, []byte(`{"ref":"x","ref_type":"branch"}`), nil, anyFiles); !errors.Is(err, ErrUnreadable) {
|
||||
if _, err := Convert(DeletedSubject, []byte(`{"ref":"x","ref-type":"branch"}`), nil, anyFiles); !errors.Is(err, ErrUnreadable) {
|
||||
t.Errorf("forge.delete naming no repository: err = %v, want ErrUnreadable", err)
|
||||
}
|
||||
// 1236: a merged event without a full merge commit is unreadable, and the forge is not asked.
|
||||
@@ -206,27 +226,13 @@ func TestOtherSubjectsAreNotHandled(t *testing.T) { // 1235, 1236
|
||||
|
||||
const merge = "89abcdef0123456789abcdef0123456789abcdef"
|
||||
|
||||
// mergedBody is gitea.pull.merged as mesh-catalog modules/gitea/index.ts:186-207 writes it.
|
||||
// mergedBody is the seat git's pull.merged, extra by the short names seatPull reads.
|
||||
func mergedBody(extra map[string]any) []byte {
|
||||
m := map[string]any{
|
||||
"owner": "novox", "repo": "mesh-delivery", "number": 12, "title": "A change", "body": "text",
|
||||
"head_sha": head, "head_checks": map[string]string{"mesh/merge-gate": "success"}, "head": "feat/x", "base": "main",
|
||||
"merge_commit_sha": merge, "merged_at": "2026-10-19T12:00:00Z", "clone_url": "https://forge.example/novox/mesh-delivery.git",
|
||||
"html_url": "https://forge.example/novox/mesh-delivery/pulls/12", "paths": []string{"modules/a/x.go"}, "paths_truncated": false,
|
||||
"removed": []string{}, "module_dirs": []string{"modules/a"}, "module_dirs_said": true,
|
||||
}
|
||||
for k, v := range extra {
|
||||
if v == nil {
|
||||
delete(m, k)
|
||||
} else {
|
||||
m[k] = v
|
||||
}
|
||||
}
|
||||
b, _ := json.Marshal(m)
|
||||
return b
|
||||
return seatPull(map[string]any{"merge_commit_sha": merge, "merged_at": "2026-10-19T12:00:00Z",
|
||||
"paths": []string{"modules/a/x.go"}, "module_dirs": []string{"modules/a"}, "module_dirs_said": true}, extra)
|
||||
}
|
||||
|
||||
const mergedSubject = "mesh.mod.gitea.event.pull.merged"
|
||||
const mergedSubject = "mesh.seat.git.event.pull-merged"
|
||||
|
||||
type call struct{ owner, repo, sha string }
|
||||
|
||||
@@ -311,16 +317,15 @@ func TestNoModuleJSONAnywhereWritesAMergedMessageWithNoTrees(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// openAnswer is gitea_open_pulls's answer as mesh-catalog modules/gitea/client.ts:369-381 builds each entry
|
||||
// (owner, repo, number, base, head, head_sha, html_url, required, statuses, checks, labels) inside {pulls}.
|
||||
// openAnswer is the seat git's answer of open-pulls.
|
||||
func openAnswer(pulls ...map[string]any) []byte {
|
||||
b, _ := json.Marshal(map[string]any{"pulls": pulls})
|
||||
return b
|
||||
}
|
||||
|
||||
func openEntry(repo string, number int, base, branch, sha string) map[string]any {
|
||||
return map[string]any{"owner": "novox", "repo": repo, "number": number, "base": base, "head": branch, "head_sha": sha,
|
||||
"html_url": "https://forge.example/x", "required": []string{"mesh/merge-gate"}, "statuses": []string{}, "checks": map[string]string{}, "labels": []string{}}
|
||||
return map[string]any{"pull": map[string]any{"repository": "novox/" + repo, "number": number, "base": base, "branch": branch, "head": sha,
|
||||
"state": "open", "url": "https://forge.example/x"}, "required": []string{"mesh/check"}, "checks": map[string]any{}}
|
||||
}
|
||||
|
||||
func TestOpenPullsAreUpdatedPullMessagesWithUnknownDirs(t *testing.T) { // 1237/AC1
|
||||
@@ -355,7 +360,7 @@ func TestOpenPullsUnreadableEntryIsNamedAndTheRestGo(t *testing.T) { // 1237, 12
|
||||
if err != nil || len(msgs) != 1 || len(skipped) != 1 || !strings.Contains(skipped[0], "novox/a#1") {
|
||||
t.Errorf("msgs %d skipped %v err %v", len(msgs), skipped, err)
|
||||
}
|
||||
for _, bad := range []string{`{"pulls":null}`, `{}`, `[]`, `x`} {
|
||||
for _, bad := range []string{`{"pulls":null}`, `{}`, `[]`, `x`, string(gitFixture(t, "open-pulls answers an error, never none", "verb:open-pulls.answer"))} {
|
||||
if _, _, err := OpenPulls([]byte(bad)); err == nil {
|
||||
t.Errorf("%s was taken as an answer", bad)
|
||||
}
|
||||
@@ -381,7 +386,7 @@ func TestOpenMessageNamesEveryOpenPullIntoTheTrunk(t *testing.T) { // 1279/AC6
|
||||
if f, derr := forgemsg.DecodeForge(m.Data); err != nil || derr != nil || f.Kind != forgemsg.ForgeOpen || len(f.Open) != 0 {
|
||||
t.Errorf("empty answer: %+v %v %v", f, err, derr)
|
||||
}
|
||||
for _, bad := range []string{`{"pulls":null}`, `{}`, `x`} {
|
||||
for _, bad := range []string{`{"pulls":null}`, `{}`, `x`, `{"error":"the forge answered 502","pulls":[]}`} {
|
||||
if _, err := OpenMessage([]byte(bad)); err == nil {
|
||||
t.Errorf("%s was taken as an answer", bad)
|
||||
}
|
||||
@@ -411,41 +416,66 @@ func TestTrackSaysWhichHeadOfWhichPullRequestWasWritten(t *testing.T) { // 1279/
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeTagsAnswersAPackagesTagsByPrefixPageByPage(t *testing.T) { // DLV-FB1, design 51 *The floor*
|
||||
var asked []string
|
||||
page := func(n int) json.RawMessage {
|
||||
var tags []map[string]string
|
||||
for i := 0; i < n; i++ {
|
||||
tags = append(tags, map[string]string{"name": "x" + strings.Repeat("y", i)})
|
||||
// gitFixture is one answer of the seat git's fixtures, by its name and what it is of.
|
||||
func gitFixture(t *testing.T, name, of string) json.RawMessage {
|
||||
t.Helper()
|
||||
return gitfixture.Given(t, name, of)
|
||||
}
|
||||
|
||||
func TestBridgeTagsAnswersAPackagesTagsByPrefixPageByPage(t *testing.T) { // DLV-FB1, design 51 *The floor*; design 47 *The seat git*
|
||||
more := gitFixture(t, "tags answers", "verb:tags.answer") // go/v0.1.15, go/v0.1.14, more
|
||||
failed := gitFixture(t, "tags answers an error, never none", "verb:tags.answer")
|
||||
last := json.RawMessage(`{"repository":"novox/mesh-sdk","tags":["go/v0.1.2","v0.0.1"],"page":2,"bound":50}`)
|
||||
type asked struct {
|
||||
key string
|
||||
in string
|
||||
}
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
answer func(page int) (json.RawMessage, error)
|
||||
repo string
|
||||
tags []string
|
||||
more bool
|
||||
pages int
|
||||
failing string
|
||||
}{
|
||||
{name: "two pages, the second says no more", repo: "novox/mesh-sdk", tags: []string{"go/v0.1.14", "go/v0.1.15", "go/v0.1.2"}, pages: 2,
|
||||
answer: func(p int) (json.RawMessage, error) {
|
||||
if p == 1 {
|
||||
return more, nil
|
||||
}
|
||||
return last, nil
|
||||
}},
|
||||
{name: "every page says more: stopped at the bound", repo: "novox/mesh-sdk", more: true, pages: TagsMaxPages,
|
||||
answer: func(int) (json.RawMessage, error) { return json.RawMessage(`{"tags":[],"more":true}`), nil }},
|
||||
{name: "the seat does not answer", repo: "novox/mesh-sdk", failing: "page 1", pages: 1,
|
||||
answer: func(int) (json.RawMessage, error) { return nil, errors.New("down") }},
|
||||
{name: "an error answer is an error, never none", repo: "novox/mesh-sdk", failing: "404", pages: 1,
|
||||
answer: func(int) (json.RawMessage, error) { return failed, nil }},
|
||||
{name: "a repository with no owner", repo: "mesh-sdk", failing: "<owner>/<name>"},
|
||||
} {
|
||||
var calls []asked
|
||||
call := func(_ context.Context, key string, body any) (json.RawMessage, error) {
|
||||
in, _ := json.Marshal(body)
|
||||
calls = append(calls, asked{key, string(in)})
|
||||
var p struct{ Page int }
|
||||
_ = json.Unmarshal(in, &p)
|
||||
return row.answer(p.Page)
|
||||
}
|
||||
b, _ := json.Marshal(tags)
|
||||
return b
|
||||
}
|
||||
api := func(path string) (json.RawMessage, error) {
|
||||
asked = append(asked, path)
|
||||
if strings.Contains(path, "page=1&") {
|
||||
full := page(TagsPageSize - 2)
|
||||
var tags []map[string]string
|
||||
_ = json.Unmarshal(full, &tags)
|
||||
tags = append(tags, map[string]string{"name": "go/v0.1.13"}, map[string]string{"name": "v0.0.1"})
|
||||
b, _ := json.Marshal(map[string]any{"result": tags})
|
||||
return b, nil
|
||||
a, err := Tags(context.Background(), call, row.repo, "go/v")
|
||||
if row.failing != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), row.failing) {
|
||||
t.Errorf("%s: %v, want an error naming %q", row.name, err, row.failing)
|
||||
}
|
||||
} else if err != nil || !slicesEqual(a.Tags, row.tags) || a.More != row.more {
|
||||
t.Errorf("%s: Tags = %+v, %v", row.name, a, err)
|
||||
}
|
||||
if len(calls) != row.pages {
|
||||
t.Errorf("%s: %d calls, want %d", row.name, len(calls), row.pages)
|
||||
}
|
||||
if len(calls) > 0 && (calls[0].key != "seat:git.tags" || calls[0].in != `{"page":1,"prefix":"go/v","repository":"novox/mesh-sdk"}`) {
|
||||
t.Errorf("%s: first call %+v", row.name, calls[0])
|
||||
}
|
||||
return json.RawMessage(`{"result":[{"name":"go/v0.1.2"}]}`), nil
|
||||
}
|
||||
a, err := Tags(api, "novox/mesh-sdk", "go/v")
|
||||
if err != nil || !slicesEqual(a.Tags, []string{"go/v0.1.13", "go/v0.1.2"}) || a.More || len(asked) != 2 ||
|
||||
asked[0] != "/repos/novox/mesh-sdk/tags?page=1&limit=50" {
|
||||
t.Errorf("Tags = %+v, %v; asked %v", a, err, asked)
|
||||
}
|
||||
if _, err := Tags(func(string) (json.RawMessage, error) { return nil, errors.New("down") }, "novox/mesh-sdk", ""); err == nil || !strings.Contains(err.Error(), "page 1") {
|
||||
t.Errorf("a forge not answering = %v, want an error naming the page", err)
|
||||
}
|
||||
if _, err := Tags(func(string) (json.RawMessage, error) { return json.RawMessage(`[{"name":"go/v0.1.2"}]`), nil }, "novox/mesh-sdk", ""); err == nil || !strings.Contains(err.Error(), `{"result"`) {
|
||||
t.Errorf("a bare list with no wrapper = %v, want the shape named", err)
|
||||
}
|
||||
if _, err := Tags(api, "mesh-sdk", ""); err == nil {
|
||||
t.Error("a repository with no owner answered")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,77 +3,106 @@ package bridge
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
git "git.novox.be/novox/mesh-delivery/contracts/go/seats/git"
|
||||
)
|
||||
|
||||
// Caller calls a module's tool by its manifest key (`<module>.<tool>`) with a JSON body and returns the tool's result:
|
||||
// the bridge's process calls through the bus as its own principal, its tools bundle through the runtime (stdio.Ask).
|
||||
// Caller calls a seat's verb by its manifest key (`seat:<seat>.<verb>`) with a JSON body and returns its answer: the
|
||||
// bridge's process calls through the bus as its own principal, its tools bundle through the runtime (stdio.Ask).
|
||||
type Caller func(ctx context.Context, key string, body any) (json.RawMessage, error)
|
||||
|
||||
// CommitTreesTool is the forge's tool for a commit's trees (mesh-catalog modules/gitea, issue 1234): arguments owner,
|
||||
// repo and sha, answer {root, trees}, the root directory spelled "" (1234/AC4).
|
||||
const CommitTreesTool = "gitea.gitea_commit_trees"
|
||||
// GitKey is the manifest key of a verb of the seat `git` (novox/hq design 47 *The seat `git`*): the bridge reaches the
|
||||
// forge through the seat alone and names no module that holds it.
|
||||
func GitKey(verb string) string { return "seat:" + git.Seat + "." + verb }
|
||||
|
||||
// TreesVia asks the forge for a commit's trees through call.
|
||||
// askGit calls one verb of the seat `git` and decodes its answer into A. An answer the contract cannot type, or one
|
||||
// that says an error, is the call's error: a failed read is never read as none (design 47 *A failed read answers an
|
||||
// error*).
|
||||
func askGit[A any](ctx context.Context, call Caller, verb string, in any) (A, error) {
|
||||
var a A
|
||||
raw, err := call(ctx, GitKey(verb), in)
|
||||
if err != nil {
|
||||
return a, fmt.Errorf("the seat git did not answer %s: %w", verb, err)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
return a, fmt.Errorf("the seat git's answer of %s is not its contract's: %w", verb, err)
|
||||
}
|
||||
var said struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &said)
|
||||
if said.Error != "" {
|
||||
return a, fmt.Errorf("the seat git refused %s: %s", verb, said.Error)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// repository is `<owner>/<name>`, as the seat names a repository.
|
||||
func repository(owner, repo string) string { return owner + "/" + repo }
|
||||
|
||||
// TreesVia asks the seat `git` for a commit's trees (verb trees).
|
||||
func TreesVia(ctx context.Context, call Caller) Trees {
|
||||
return func(owner, repo, sha string) (string, map[string]string, error) {
|
||||
raw, err := call(ctx, CommitTreesTool, map[string]string{"owner": owner, "repo": repo, "sha": sha})
|
||||
a, err := askGit[git.TreesAnswer](ctx, call, git.VerbTrees, git.TreesInput{Repository: repository(owner, repo), Commit: sha})
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
var a struct {
|
||||
Root string `json:"root"`
|
||||
Trees map[string]string `json:"trees"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
return "", nil, fmt.Errorf("the answer of %s is not {root, trees}: %w", CommitTreesTool, err)
|
||||
}
|
||||
if a.Root == "" {
|
||||
return "", nil, fmt.Errorf("the answer of %s names no root tree", CommitTreesTool)
|
||||
return "", nil, fmt.Errorf("the seat git's trees of %s at %s name no root tree", repository(owner, repo), sha)
|
||||
}
|
||||
return a.Root, a.Trees, nil
|
||||
}
|
||||
}
|
||||
|
||||
// GetFileTool is the forge's tool for one file at a ref (mesh-catalog modules/gitea tools/index.ts gitea_get_file):
|
||||
// arguments owner, repo, path and ref, answer {file: {path, ref, sha, size, content}}, the content decoded to text
|
||||
// (client.ts getFile). A path the forge does not have fails with `Gitea API <path>: 404 <body>` (client.ts request).
|
||||
const GetFileTool = "gitea.gitea_get_file"
|
||||
|
||||
// notFound is how the forge's error says 404 (client.ts request: `Gitea API ${path}: ${status} ${text}`; client.ts:670
|
||||
// recognises it as /: 404 /).
|
||||
var notFound = regexp.MustCompile(`: 404 `)
|
||||
|
||||
// FilesVia asks the forge for a file through call. A file the forge says is not there is ErrNotFound.
|
||||
// FilesVia asks the seat `git` for one file at a ref (verb file). A file the seat says is absent is ErrNotFound; an
|
||||
// answer that says neither absent nor content is an error, never an empty file.
|
||||
func FilesVia(ctx context.Context, call Caller) Files {
|
||||
return func(owner, repo, path, ref string) ([]byte, error) {
|
||||
raw, err := call(ctx, GetFileTool, map[string]string{"owner": owner, "repo": repo, "path": path, "ref": ref})
|
||||
raw, err := call(ctx, GitKey(git.VerbFile), git.FileInput{Repository: repository(owner, repo), Path: path, Ref: ref})
|
||||
if err != nil {
|
||||
if notFound.MatchString(err.Error()) {
|
||||
return nil, fmt.Errorf("%w: %v", ErrNotFound, err)
|
||||
}
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("the seat git did not answer file: %w", err)
|
||||
}
|
||||
var a struct {
|
||||
File *struct {
|
||||
Content *string `json:"content"`
|
||||
} `json:"file"`
|
||||
git.FileAnswer
|
||||
Content *string `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &a); err != nil || a.File == nil || a.File.Content == nil {
|
||||
return nil, fmt.Errorf("the answer of %s is not {file: {content}}: %v", GetFileTool, err)
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
return nil, fmt.Errorf("the seat git's answer of file is not its contract's: %w", err)
|
||||
}
|
||||
return []byte(*a.File.Content), nil
|
||||
switch {
|
||||
case a.Error != "":
|
||||
return nil, fmt.Errorf("the seat git refused file %s at %s: %s", path, ref, a.Error)
|
||||
case a.Absent:
|
||||
return nil, fmt.Errorf("%w: %s at %s in %s", ErrNotFound, path, ref, repository(owner, repo))
|
||||
case a.Content == nil:
|
||||
return nil, fmt.Errorf("the seat git's file %s at %s says neither its content nor that it is absent", path, ref)
|
||||
}
|
||||
return []byte(*a.Content), nil
|
||||
}
|
||||
}
|
||||
|
||||
// errNoAnswer is a read whose answer carries nothing the contract says it must.
|
||||
var errNoAnswer = errors.New("the seat git's answer carries nothing")
|
||||
|
||||
// MergedMessage is the merged message of a merge into the trunk, asked again: built as the bridge builds the one it writes
|
||||
// on FORGE (it is that event's body through ConvertKept), from the pull request's owner, repository, number, branch, head
|
||||
// and merge commit, which the gate's merges state holds. It reads the forge for every module directory (no kept inputs).
|
||||
// and merge commit, which the seat git's verb pull answers. It reads the forge for every module directory (no kept inputs).
|
||||
func MergedMessage(owner, repo string, number int, branch, head, merge string, trees Trees, files Files) (Message, error) {
|
||||
body, err := json.Marshal(pull{Owner: owner, Repo: repo, Number: number, Head: branch, HeadSHA: head, Base: Trunk, MergeSHA: merge})
|
||||
body, err := json.Marshal(git.PullMergedEvent{Repository: repository(owner, repo), Number: int64(number), Branch: branch, Head: head, Base: Trunk, MergeCommit: merge})
|
||||
if err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
return ConvertKept(MergedSubject, body, trees, files, nil)
|
||||
}
|
||||
|
||||
// splitRepository is a `<owner>/<name>` cut in two, refusing anything else.
|
||||
func splitRepository(repository string) (string, string, error) {
|
||||
owner, name, ok := strings.Cut(strings.TrimSpace(repository), "/")
|
||||
if !ok || owner == "" || name == "" || strings.Contains(name, "/") {
|
||||
return "", "", fmt.Errorf("repository %q is not <owner>/<name>", repository)
|
||||
}
|
||||
return owner, name, nil
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
git "git.novox.be/novox/mesh-delivery/contracts/go/seats/git"
|
||||
)
|
||||
|
||||
// ForgePull is a pull request as the forge itself says it: what the bridge needs of a merge made on the forge, not
|
||||
@@ -16,51 +16,30 @@ type ForgePull struct {
|
||||
Merge string // the merge commit into the trunk
|
||||
}
|
||||
|
||||
// ForgeMade reads, through the forge module's gitea_api (the path the bridge reads the forge by), the merged pull
|
||||
// request whose merge commit is merge: by number when number is given (the pull request's merge commit must then equal
|
||||
// merge), else by the merge commit (the forge's commit-to-pull-request lookup). A pull request not merged, merged into
|
||||
// another branch than the trunk, or merged at another commit is refused.
|
||||
func ForgeMade(api API, owner, repo string, number int, merge string) (ForgePull, error) {
|
||||
path := fmt.Sprintf("/repos/%s/%s/commits/%s/pull", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(merge))
|
||||
// ForgeMade reads, through the seat `git`'s verb pull, the merged pull request whose merge commit is merge: by number
|
||||
// when number is given (its merge commit must then equal merge), else by the merge commit. A pull request not merged,
|
||||
// merged into another branch than the trunk, or merged at another commit is refused.
|
||||
func ForgeMade(ctx context.Context, call Caller, owner, repo string, number int, merge string) (ForgePull, error) {
|
||||
in := git.PullInput{Repository: repository(owner, repo), MergeCommit: merge}
|
||||
if number > 0 {
|
||||
path = fmt.Sprintf("/repos/%s/%s/pulls/%d", url.PathEscape(owner), url.PathEscape(repo), number)
|
||||
in = git.PullInput{Repository: repository(owner, repo), Number: int64(number)}
|
||||
}
|
||||
raw, err := api(path)
|
||||
a, err := askGit[git.PullAnswer](ctx, call, git.VerbPull, in)
|
||||
if err != nil {
|
||||
return ForgePull{}, fmt.Errorf("the forge did not answer %s: %w", path, err)
|
||||
}
|
||||
body, err := forgeBody(raw)
|
||||
if err != nil {
|
||||
return ForgePull{}, fmt.Errorf("the forge's answer for %s: %w", path, err)
|
||||
}
|
||||
var p struct {
|
||||
Number int `json:"number"`
|
||||
Merged bool `json:"merged"`
|
||||
MergeCommitSHA string `json:"merge_commit_sha"`
|
||||
Head struct {
|
||||
Label string `json:"label"`
|
||||
SHA string `json:"sha"`
|
||||
} `json:"head"`
|
||||
Base struct {
|
||||
Ref string `json:"ref"`
|
||||
} `json:"base"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &p); err != nil {
|
||||
return ForgePull{}, fmt.Errorf("the forge's answer for %s is no pull request: %w", path, err)
|
||||
return ForgePull{}, err
|
||||
}
|
||||
p := a.Pull
|
||||
switch {
|
||||
case p.Number == 0:
|
||||
return ForgePull{}, fmt.Errorf("the forge's answer for %s is no pull request (no number)", path)
|
||||
case !p.Merged:
|
||||
return ForgePull{}, fmt.Errorf("the forge says the pull request %s/%s#%d is not merged", owner, repo, p.Number)
|
||||
case p.MergeCommitSHA != merge:
|
||||
return ForgePull{}, fmt.Errorf("the forge says %s/%s#%d was merged at %s, not at %s", owner, repo, p.Number, p.MergeCommitSHA, merge)
|
||||
case p.Base.Ref != Trunk:
|
||||
return ForgePull{}, fmt.Errorf("the forge says %s/%s#%d was merged into %s, not the trunk %s", owner, repo, p.Number, p.Base.Ref, Trunk)
|
||||
case p == nil || p.Number == 0:
|
||||
return ForgePull{}, fmt.Errorf("the pull request of %s at %s: %w", in.Repository, merge, errNoAnswer)
|
||||
case p.State != git.PullStateMerged:
|
||||
return ForgePull{}, fmt.Errorf("the forge says the pull request %s#%d is %s, not merged", in.Repository, p.Number, p.State)
|
||||
case p.MergeCommit != merge:
|
||||
return ForgePull{}, fmt.Errorf("the forge says %s#%d was merged at %s, not at %s", in.Repository, p.Number, p.MergeCommit, merge)
|
||||
case p.Base != Trunk:
|
||||
return ForgePull{}, fmt.Errorf("the forge says %s#%d was merged into %s, not the trunk %s", in.Repository, p.Number, p.Base, Trunk)
|
||||
case p.Branch == "":
|
||||
return ForgePull{}, fmt.Errorf("the forge gives no head branch for %s#%d", in.Repository, p.Number)
|
||||
}
|
||||
// For a merged pull request the forge's head.ref is "refs/pull/N/head"; the branch is in head.label (same-repository pull requests).
|
||||
if p.Head.Label == "" || strings.HasPrefix(p.Head.Label, "refs/") {
|
||||
return ForgePull{}, fmt.Errorf("the forge gives no head branch for %s/%s#%d (label %q)", owner, repo, p.Number, p.Head.Label)
|
||||
}
|
||||
return ForgePull{Number: p.Number, Branch: p.Head.Label, Head: p.Head.SHA, Merge: p.MergeCommitSHA}, nil
|
||||
return ForgePull{Number: int(p.Number), Branch: p.Branch, Head: p.Head, Merge: p.MergeCommit}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
git "git.novox.be/novox/mesh-delivery/contracts/go/seats/git"
|
||||
seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery"
|
||||
checker "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
)
|
||||
|
||||
// OwedCall is one act owed to the forge as the seat git's verb that performs it: the seat that said it (Of), the act's
|
||||
// id, and the verb with its input (novox/hq design 47 *What the gate owes the forge*). Every verb is safe to repeat at
|
||||
// the forge: a status and the view replace what stands, a merge of a pull request merged at that head is answered
|
||||
// performed, and a comment and a note line carry the id, which the seat's holder skips when it already holds it.
|
||||
type OwedCall struct {
|
||||
Of string
|
||||
ID string
|
||||
Act string
|
||||
Repository string
|
||||
Number int
|
||||
Verb string
|
||||
Input any
|
||||
}
|
||||
|
||||
// ErrUndecodable is an owed event the bridge cannot read: it is said as owed.ended `undecodable`, never only logged.
|
||||
var ErrUndecodable = errors.New("an owed act the bridge cannot read")
|
||||
|
||||
func undecodable(why string, args ...any) error {
|
||||
return fmt.Errorf("%w: %s", ErrUndecodable, fmt.Sprintf(why, args...))
|
||||
}
|
||||
|
||||
// GateOwed reads the seat mesh-delivery's event owed into the seat git's verb that performs it.
|
||||
func GateOwed(data []byte) (OwedCall, error) {
|
||||
var o seat.OwedEvent
|
||||
if err := json.Unmarshal(data, &o); err != nil {
|
||||
return OwedCall{}, undecodable("the gate's owed is not json: %v", err)
|
||||
}
|
||||
c := OwedCall{Of: seat.Seat, ID: o.ID, Act: o.Act, Repository: o.Repository, Number: int(o.Number)}
|
||||
if err := c.common(o.Head); err != nil {
|
||||
return c, err
|
||||
}
|
||||
commit := o.Commit
|
||||
if commit == "" {
|
||||
commit = o.Head
|
||||
}
|
||||
needsPull := func() error {
|
||||
if o.Number <= 0 {
|
||||
return undecodable("the owed %s %s names no pull request", o.Act, o.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
switch o.Act {
|
||||
case seat.OwedActMerge:
|
||||
// Only a switched act is a merge (§169): from G's delivery the gate owes none (§167), a session calls merge-pull
|
||||
// itself; a merge of the old state, not yet performed by the old bridge, is not dropped.
|
||||
if err := needsPull(); err != nil {
|
||||
return c, err
|
||||
}
|
||||
c.Verb, c.Input = git.VerbMergePull, git.MergePullInput{ID: o.ID, Repository: o.Repository, Number: o.Number, Head: o.Head}
|
||||
case seat.OwedActStatus:
|
||||
if o.Context == "" || o.State == "" {
|
||||
return c, undecodable("the owed status %s names no context or state", o.ID)
|
||||
}
|
||||
c.Verb, c.Input = git.VerbStatus, git.StatusInput{ID: o.ID, Repository: o.Repository, Commit: commit, Context: o.Context,
|
||||
State: o.State, Description: o.Description, Target: o.Target}
|
||||
case seat.OwedActView:
|
||||
if err := needsPull(); err != nil {
|
||||
return c, err
|
||||
}
|
||||
c.Verb, c.Input = git.VerbView, git.ViewInput{ID: o.ID, Repository: o.Repository, Number: o.Number, Text: o.Text}
|
||||
case seat.OwedActComment:
|
||||
if err := needsPull(); err != nil {
|
||||
return c, err
|
||||
}
|
||||
c.Verb, c.Input = git.VerbComment, git.CommentInput{ID: o.ID, Repository: o.Repository, Number: o.Number, Text: o.Text}
|
||||
case seat.OwedActNote:
|
||||
if o.Line == "" {
|
||||
return c, undecodable("the owed note line %s says no line", o.ID)
|
||||
}
|
||||
c.Verb, c.Input = git.VerbNote, git.NoteInput{ID: o.ID, Repository: o.Repository, Commit: commit, Line: o.Line}
|
||||
default:
|
||||
return c, undecodable("the owed %s is the act %q, none the seat git performs", o.ID, o.Act)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// CheckerOwed reads the seat mesh-delivery-checker's event owed (its mesh/check status) into the seat git's verb status.
|
||||
func CheckerOwed(data []byte) (OwedCall, error) {
|
||||
var o checker.OwedEvent
|
||||
if err := json.Unmarshal(data, &o); err != nil {
|
||||
return OwedCall{}, undecodable("the checker's owed is not json: %v", err)
|
||||
}
|
||||
c := OwedCall{Of: checker.Seat, ID: o.ID, Act: o.Act, Repository: o.Repository, Number: int(o.Number)}
|
||||
if err := c.common(o.Head); err != nil {
|
||||
return c, err
|
||||
}
|
||||
if o.Act != checker.OwedActStatus || o.Context == "" || o.State == "" {
|
||||
return c, undecodable("the checker's owed %s is %q of the context %q in the state %q, not a status", o.ID, o.Act, o.Context, o.State)
|
||||
}
|
||||
c.Verb, c.Input = git.VerbStatus, git.StatusInput{ID: o.ID, Repository: o.Repository, Commit: o.Head, Context: o.Context,
|
||||
State: o.State, Description: o.Description, Target: o.Target}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c OwedCall) common(head string) error {
|
||||
switch {
|
||||
case c.ID == "":
|
||||
return undecodable("an owed %s of %s names no id", c.Act, c.Repository)
|
||||
case c.Repository == "":
|
||||
return undecodable("the owed %s names no repository", c.ID)
|
||||
case !sha40.MatchString(head):
|
||||
return undecodable("the owed %s names the head %q, not 40 hex digits", c.ID, head)
|
||||
}
|
||||
if _, _, err := splitRepository(c.Repository); err != nil {
|
||||
return undecodable("the owed %s: %v", c.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Outcome is what an act's call of the seat git came to, as owed.ended says it: performed when the forge holds the act
|
||||
// (done now, or its id already held); refused with the forge's own words when the seat answers a refusal or an error
|
||||
// of the kind refused; could not be performed with the error when the seat did not answer, answered an error of the
|
||||
// kind failed, or answered what the contract cannot read.
|
||||
func Outcome(raw json.RawMessage, callErr error) (outcome, words string) {
|
||||
if callErr != nil {
|
||||
return forgemsg.OwedCouldNotBePerformed, callErr.Error()
|
||||
}
|
||||
var a git.MergePullAnswer // every act verb's answer is this one's fields or fewer
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
return forgemsg.OwedCouldNotBePerformed, "the seat git's answer is not its contract's: " + err.Error()
|
||||
}
|
||||
switch {
|
||||
case a.Refused != "":
|
||||
return forgemsg.OwedRefused, a.Refused
|
||||
case a.Error != "" && a.ErrorKind == git.ErrorKindRefused:
|
||||
return forgemsg.OwedRefused, a.Error
|
||||
case a.Error != "":
|
||||
// failed, or a kind the contract does not name: the forge did not do it, and did not say no
|
||||
return forgemsg.OwedCouldNotBePerformed, a.Error
|
||||
case a.Performed || a.Held:
|
||||
return forgemsg.OwedPerformed, ""
|
||||
}
|
||||
return forgemsg.OwedCouldNotBePerformed, "the seat git's answer says neither performed, held, refused nor an error: " + string(raw)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
git "git.novox.be/novox/mesh-delivery/contracts/go/seats/git"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
|
||||
"mesh-delivery-forge-bridge/internal/gitfixture"
|
||||
)
|
||||
|
||||
// Design 47 *What the gate owes the forge*: each act the gate or the checker says as owed is the seat git's verb of that
|
||||
// act, under the act's id; what cannot be read is undecodable, and every answer is one outcome.
|
||||
func TestAnOwedEventIsTheSeatGitsVerbAndItsAnswerOneOutcome(t *testing.T) {
|
||||
gate := func(name string) []byte { return gitfixture.Seat(t, "mesh-delivery", "events", name, "event:owed")[0] }
|
||||
chk := gitfixture.Seat(t, "mesh-delivery-checker", "events", "owed is said", "event:owed")[0]
|
||||
a40 := strings.Repeat("a", 40)
|
||||
edit := func(raw []byte, k string, v any) []byte {
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(raw, &m)
|
||||
if v == nil {
|
||||
delete(m, k)
|
||||
} else {
|
||||
m[k] = v
|
||||
}
|
||||
b, _ := json.Marshal(m)
|
||||
return b
|
||||
}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
read func([]byte) (OwedCall, error)
|
||||
data []byte
|
||||
verb string
|
||||
input any
|
||||
bad string
|
||||
}{
|
||||
{"a merge on no pull request", GateOwed, edit(gate("owed is said, a merge"), "number", 0), "", nil, "no pull request"},
|
||||
{"a status on the head", GateOwed, gate("owed is said, a status"), "status", git.StatusInput{ID: "status.novox/mesh-sdk#178.aaaaaaaaaaaa.mesh/merge-gate",
|
||||
Repository: "novox/mesh-sdk", Commit: a40, Context: "mesh/merge-gate", State: "pending", Description: "waits for the operator's Approve", Target: "https://forge.test/x"}, ""},
|
||||
{"a view", GateOwed, edit(edit(gate("owed is said, a status"), "act", "view"), "text", "the view"), "view",
|
||||
git.ViewInput{ID: "status.novox/mesh-sdk#178.aaaaaaaaaaaa.mesh/merge-gate", Repository: "novox/mesh-sdk", Number: 178, Text: "the view"}, ""},
|
||||
{"a comment", GateOwed, edit(edit(gate("owed is said, a status"), "act", "comment"), "text", "hello"), "comment",
|
||||
git.CommentInput{ID: "status.novox/mesh-sdk#178.aaaaaaaaaaaa.mesh/merge-gate", Repository: "novox/mesh-sdk", Number: 178, Text: "hello"}, ""},
|
||||
{"a note line on its commit", GateOwed, edit(edit(edit(gate("owed is said, a status"), "act", "note"), "line", "a line"), "commit", strings.Repeat("c", 40)), "note",
|
||||
git.NoteInput{ID: "status.novox/mesh-sdk#178.aaaaaaaaaaaa.mesh/merge-gate", Repository: "novox/mesh-sdk", Commit: strings.Repeat("c", 40), Line: "a line"}, ""},
|
||||
{"the checker's status", CheckerOwed, chk, "status", git.StatusInput{ID: "status.novox/mesh-sdk#178.aaaaaaaaaaaa.mesh/check",
|
||||
Repository: "novox/mesh-sdk", Commit: a40, Context: "mesh/check", State: "success", Description: "passed", Target: "https://forge.test/x"}, ""},
|
||||
{"not json", GateOwed, []byte("{"), "", nil, "not json"},
|
||||
{"no id", GateOwed, edit(gate("owed is said, a merge"), "id", nil), "", nil, "no id"},
|
||||
{"a short head", CheckerOwed, edit(chk, "head", "abc"), "", nil, "40 hex"},
|
||||
{"a merge of the switch", GateOwed, gate("owed is said, a merge"), "merge-pull", git.MergePullInput{ID: "merge.feat=47-G.0123456789ab.novox/mesh-sdk#178",
|
||||
Repository: "novox/mesh-sdk", Number: 178, Head: a40}, ""},
|
||||
{"a comment on no pull request", GateOwed, edit(edit(gate("owed is said, a status"), "act", "comment"), "number", 0), "", nil, "no pull request"},
|
||||
{"an act of no kind", GateOwed, edit(gate("owed is said, a status"), "act", "label"), "", nil, "none the seat git"},
|
||||
{"a repository not owner/name", GateOwed, edit(gate("owed is said, a status"), "repository", "mesh-sdk"), "", nil, "owner"},
|
||||
} {
|
||||
got, err := c.read(c.data)
|
||||
switch {
|
||||
case c.bad != "":
|
||||
if !errors.Is(err, ErrUndecodable) || !strings.Contains(err.Error(), c.bad) {
|
||||
t.Errorf("%s: %v, want undecodable naming %q", c.name, err, c.bad)
|
||||
}
|
||||
case err != nil || got.Verb != c.verb || !reflect.DeepEqual(got.Input, c.input):
|
||||
t.Errorf("%s: %s %+v %v, want %s %+v", c.name, got.Verb, got.Input, err, c.verb, c.input)
|
||||
}
|
||||
}
|
||||
answers := func(verb string) []json.RawMessage { return gitfixture.All(t, verb+" answers", "verb:"+verb+".answer") }
|
||||
failed := func(verb string) json.RawMessage {
|
||||
return gitfixture.Given(t, verb+" answers an error, never none", "verb:"+verb+".answer")
|
||||
}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
raw json.RawMessage
|
||||
err error
|
||||
outcome, words string
|
||||
}{
|
||||
{"a merge performed", answers("merge-pull")[0], nil, forgemsg.OwedPerformed, ""},
|
||||
{"a merge refused", answers("merge-pull")[1], nil, forgemsg.OwedRefused, "the head moved"},
|
||||
{"a comment already held", answers("comment")[0], nil, forgemsg.OwedPerformed, ""},
|
||||
{"the forge's error, of the kind refused", failed("comment"), nil, forgemsg.OwedRefused, "403"},
|
||||
{"the forge's error, of the kind failed", failed("view"), nil, forgemsg.OwedCouldNotBePerformed, "502"},
|
||||
{"no answer in the call's bound", nil, errors.New("nats: timeout"), forgemsg.OwedCouldNotBePerformed, "timeout"},
|
||||
{"an answer saying nothing", json.RawMessage(`{"id":"x"}`), nil, forgemsg.OwedCouldNotBePerformed, "neither"},
|
||||
{"an answer not the contract's", json.RawMessage(`[1]`), nil, forgemsg.OwedCouldNotBePerformed, "contract"},
|
||||
} {
|
||||
outcome, words := Outcome(c.raw, c.err)
|
||||
if outcome != c.outcome || !strings.Contains(words, c.words) {
|
||||
t.Errorf("%s: %q %q, want %q naming %q", c.name, outcome, words, c.outcome, c.words)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,39 +1,20 @@
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
git "git.novox.be/novox/mesh-delivery/contracts/go/seats/git"
|
||||
)
|
||||
|
||||
// TagsTool is the forge bridge's tool bridge_tags (novox/hq design 51 *The floor*): a package's git tags, which the
|
||||
// builder's floor reads as one of its three sources.
|
||||
const TagsTool = "bridge_tags"
|
||||
|
||||
// TagsPageSize is how many tags one page of the forge's tags answers; TagsMaxPages bounds the read.
|
||||
const (
|
||||
TagsPageSize = 50
|
||||
TagsMaxPages = 40
|
||||
)
|
||||
|
||||
// API asks the forge one GET of its API (the forge module's gitea_api), answering what the tool answers: the body
|
||||
// wrapped as {"result": <body>}. Every reader of an API answer takes the body through forgeBody, the one place that
|
||||
// knows that shape (issue 1966: two readers took the wrapper for the body).
|
||||
type API func(path string) (json.RawMessage, error)
|
||||
|
||||
// forgeBody is the body of the forge's answer: the tool's "result". An answer without one is refused, naming the shape,
|
||||
// never read as an empty body.
|
||||
func forgeBody(raw json.RawMessage) (json.RawMessage, error) {
|
||||
var w struct {
|
||||
Result json.RawMessage `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &w); err != nil || len(w.Result) == 0 || string(w.Result) == "null" {
|
||||
return nil, fmt.Errorf(`the forge module's answer is not {"result": <body>}`)
|
||||
}
|
||||
return w.Result, nil
|
||||
}
|
||||
// TagsMaxPages bounds the read: at most this many pages of the seat `git`'s verb tags.
|
||||
const TagsMaxPages = 40
|
||||
|
||||
// TagsAnswer is what bridge_tags answers.
|
||||
type TagsAnswer struct {
|
||||
@@ -45,39 +26,30 @@ type TagsAnswer struct {
|
||||
}
|
||||
|
||||
// Tags is bridge_tags: every tag of repository (`<owner>/<name>`) whose name starts with prefix (`go/v` for the Go
|
||||
// package of mesh-sdk's `go` directory, `v` or empty for a package at the root), sorted. A forge that does not answer
|
||||
// is an error naming the page: the builder then counts this source silent.
|
||||
func Tags(api API, repository, prefix string) (TagsAnswer, error) {
|
||||
owner, name, ok := strings.Cut(strings.TrimSpace(repository), "/")
|
||||
if !ok || owner == "" || name == "" || strings.Contains(name, "/") {
|
||||
return TagsAnswer{}, fmt.Errorf("repository %q is not <owner>/<name>", repository)
|
||||
// package of mesh-sdk's `go` directory, `v` or empty for a package at the root), sorted, read page by page through the
|
||||
// seat `git`'s verb tags until a page says no more. A page the seat does not answer is an error naming it: the builder
|
||||
// then counts this source silent.
|
||||
func Tags(ctx context.Context, call Caller, repository, prefix string) (TagsAnswer, error) {
|
||||
owner, name, err := splitRepository(repository)
|
||||
if err != nil {
|
||||
return TagsAnswer{}, err
|
||||
}
|
||||
out := TagsAnswer{Repository: owner + "/" + name, Prefix: prefix, Tags: []string{}}
|
||||
for page := 1; ; page++ {
|
||||
for page := int64(1); ; page++ {
|
||||
if page > TagsMaxPages {
|
||||
out.More = true
|
||||
break
|
||||
}
|
||||
raw, err := api(fmt.Sprintf("/repos/%s/%s/tags?page=%d&limit=%d", url.PathEscape(owner), url.PathEscape(name), page, TagsPageSize))
|
||||
a, err := askGit[git.TagsAnswer](ctx, call, git.VerbTags, git.TagsInput{Repository: out.Repository, Prefix: prefix, Page: page})
|
||||
if err != nil {
|
||||
return TagsAnswer{}, fmt.Errorf("the forge did not answer the tags of %s (page %d): %w", out.Repository, page, err)
|
||||
return TagsAnswer{}, fmt.Errorf("the tags of %s (page %d): %w", out.Repository, page, err)
|
||||
}
|
||||
body, err := forgeBody(raw)
|
||||
if err != nil {
|
||||
return TagsAnswer{}, fmt.Errorf("the forge's tags of %s (page %d): %w", out.Repository, page, err)
|
||||
}
|
||||
var tags []struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &tags); err != nil {
|
||||
return TagsAnswer{}, fmt.Errorf("the forge's tags of %s (page %d) are no list of tags: %w", out.Repository, page, err)
|
||||
}
|
||||
for _, t := range tags {
|
||||
if strings.HasPrefix(t.Name, prefix) {
|
||||
out.Tags = append(out.Tags, t.Name)
|
||||
for _, t := range a.Tags {
|
||||
if strings.HasPrefix(t, prefix) {
|
||||
out.Tags = append(out.Tags, t)
|
||||
}
|
||||
}
|
||||
if len(tags) < TagsPageSize {
|
||||
if !a.More {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// Package gitfixture reads the seat git's own fixtures (contracts/seats/git/fixtures/verbs.json), so the bridge's unit
|
||||
// fakes answer what the seat's holder answers and nothing the contract does not say (design 47 *The seat `git`*).
|
||||
package gitfixture
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// All is every fixture's body named name of of (such as "file answers", "verb:file.answer"), in the file's order; a
|
||||
// name with none fails the test.
|
||||
func All(t testing.TB, name, of string) []json.RawMessage {
|
||||
t.Helper()
|
||||
return Seat(t, "git", "verbs", name, of)
|
||||
}
|
||||
|
||||
// Seat is every fixture's body named name of of in a seat's fixtures file (such as "mesh-delivery", "events"), so the
|
||||
// bridge's fakes of the seats it hears say what those seats say.
|
||||
func Seat(t testing.TB, seat, file, name, of string) []json.RawMessage {
|
||||
t.Helper()
|
||||
_, here, _, _ := runtime.Caller(0)
|
||||
raw, err := os.ReadFile(filepath.Join(filepath.Dir(here), "../../../../contracts/seats", seat, "fixtures", file+".json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var all []struct {
|
||||
Name, Of string
|
||||
Given json.RawMessage
|
||||
}
|
||||
if err := json.Unmarshal(raw, &all); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out []json.RawMessage
|
||||
for _, f := range all {
|
||||
if f.Name == name && f.Of == of {
|
||||
out = append(out, f.Given)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
t.Fatalf("no fixture %q of %s", name, of)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Given is the first fixture's body named name of of.
|
||||
func Given(t testing.TB, name, of string) json.RawMessage { t.Helper(); return All(t, name, of)[0] }
|
||||
@@ -5,6 +5,7 @@
|
||||
package groups
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
@@ -12,12 +13,13 @@ import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/trunk"
|
||||
"git.novox.be/novox/mesh-sdk/go/keys"
|
||||
)
|
||||
|
||||
// The bridge's states the tools read and write.
|
||||
@@ -26,20 +28,34 @@ const (
|
||||
Module = "mesh-delivery-forge-bridge"
|
||||
// PerformedState is the bridge's state of what it performed and published.
|
||||
PerformedState = "performed"
|
||||
// EndAsksState is the state of asks to say a group's end again, keyed by the gate's merges key of the group.
|
||||
// EndAsksState is the state of asks to say a group's end again, keyed by the group escaped as a key.
|
||||
EndAsksState = "end-asks"
|
||||
// EndAsksBucket is its bucket on the bus.
|
||||
EndAsksBucket = Module + "_" + EndAsksState
|
||||
)
|
||||
|
||||
// Key is the key of performed that says a group's end is written: `groups.<merges key>`, its value the revision of the
|
||||
// gate's merges key it was worked out from.
|
||||
func Key(mergesKey string) string { return "groups." + mergesKey }
|
||||
// Key is the key of performed that says a group's end is written: `groups.<group, escaped as a key>`, its value the last
|
||||
// member's merge commit the end named.
|
||||
func Key(group string) (string, error) {
|
||||
part, err := KeyOf(group)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "groups." + part, nil
|
||||
}
|
||||
|
||||
// KeyOf is a group's id (its branch) escaped as a key, as the states performed and end-asks key it.
|
||||
func KeyOf(group string) (string, error) {
|
||||
if strings.TrimSpace(group) == "" {
|
||||
return "", errors.New("a group's id is empty")
|
||||
}
|
||||
return keys.EscapeName(group), nil
|
||||
}
|
||||
|
||||
// PublishedKey is the key of performed that says a member's merged message is written: `published.<16 hex digits of the
|
||||
// sha256 of <repository>#<number>@<merge>>`.
|
||||
func PublishedKey(repository string, number int, merge string) string {
|
||||
sum := sha256.Sum256([]byte(fmt.Sprintf("%s#%d@%s", strings.ToLower(repository), number, merge)))
|
||||
// sha256 of <repository>#<number>>`, its value the merge commit the message named (a pull request merges once).
|
||||
func PublishedKey(repository string, number int) string {
|
||||
sum := sha256.Sum256([]byte(fmt.Sprintf("%s#%d", strings.ToLower(repository), number)))
|
||||
return "published." + hex.EncodeToString(sum[:8])
|
||||
}
|
||||
|
||||
@@ -72,13 +88,13 @@ type EndAsk struct {
|
||||
// Done says the ask is answered.
|
||||
func (a EndAsk) Done() bool { return !a.Answered.IsZero() }
|
||||
|
||||
// NewEndAsk is the value bridge_group_end_again writes for a group: its key (the gate's merges key) and the value.
|
||||
// NewEndAsk is the value bridge_group_end_again writes for a group: its key (the group escaped as a key) and the value.
|
||||
func NewEndAsk(group, why, by string, at time.Time) (string, []byte, error) {
|
||||
group = strings.TrimSpace(group)
|
||||
if group == "" {
|
||||
return "", nil, errors.New("name the group whose end is said again (group)")
|
||||
}
|
||||
key, err := meshdeliveryclient.MergesKey(group)
|
||||
key, err := KeyOf(group)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
@@ -98,21 +114,24 @@ func DecodeEndAsk(data []byte) (EndAsk, error) {
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// Row is one group as bridge_groups answers it: the gate's merge of it and what the bridge kept of its end.
|
||||
// Row is one group as bridge_groups answers it: the seat mesh-delivery's group and what the bridge kept of its end.
|
||||
type Row struct {
|
||||
Key string `json:"key"`
|
||||
Group string `json:"group"`
|
||||
State string `json:"state,omitempty"`
|
||||
Members []RowMember `json:"members"`
|
||||
Ended bool `json:"ended"`
|
||||
EndedAt uint64 `json:"ended-at-revision,omitempty"`
|
||||
Open bool `json:"open"`
|
||||
Words string `json:"words"`
|
||||
// EndedWith is the last member's merge commit the end named, when it is written.
|
||||
EndedWith string `json:"ended-with,omitempty"`
|
||||
Open bool `json:"open"`
|
||||
Words string `json:"words"`
|
||||
// LastEndAsk is the last ask to say the group's end again (bridge_group_end_again), its who, why and when, and the
|
||||
// bridge's answer once it read it; absent when none was asked. The state end-asks keeps the last per group.
|
||||
LastEndAsk *EndAsk `json:"last-end-ask,omitempty"`
|
||||
}
|
||||
|
||||
// RowMember is one approved member of a group: its merge's state at the gate and whether its merged message is written.
|
||||
// RowMember is one member of a group: its delivery state at the seat and, once its merged message is written, the merge
|
||||
// commit it named.
|
||||
type RowMember struct {
|
||||
Repository string `json:"repository"`
|
||||
Number int `json:"number"`
|
||||
@@ -121,12 +140,12 @@ type RowMember struct {
|
||||
Published bool `json:"published"`
|
||||
}
|
||||
|
||||
// Rows are the groups the gate's merges hold with more than one member, each with what the bridge kept: ended (its end
|
||||
// written, the revision it was worked out from) or open (some member is not merged or its merged message not written, or its
|
||||
// end not written). merges is the gate's merges by key, performed the bridge's performed keys by key; unread are the gate's
|
||||
// keys that do not read. asks are the end-asks values by the same key; one that does not read is named unread as
|
||||
// `end-asks.<key>`.
|
||||
func Rows(merges map[string][]byte, performed map[string]bool, endedAt map[string]uint64, asks map[string][]byte) (rows []Row, unread []string) {
|
||||
// Rows are the groups the seat mesh-delivery's verb groups answers, each with what the bridge kept: ended (its end written,
|
||||
// with the last merge it named) or open (some member's merged message is not written, or its end not written). published
|
||||
// is performed's `published.` values by key (the merge commit), ended its `groups.` values by key; asks are the end-asks
|
||||
// values by the same key, and one that does not read is named unread as `end-asks.<key>`. A group whose id cannot be a
|
||||
// key is named unread by its id.
|
||||
func Rows(of []seat.Group, published, ended map[string]string, asks map[string][]byte) (rows []Row, unread []string) {
|
||||
lastAsk := map[string]*EndAsk{}
|
||||
for key, raw := range asks {
|
||||
a, err := DecodeEndAsk(raw)
|
||||
@@ -136,37 +155,32 @@ func Rows(merges map[string][]byte, performed map[string]bool, endedAt map[strin
|
||||
}
|
||||
lastAsk[key] = &a
|
||||
}
|
||||
for key, raw := range merges {
|
||||
m, err := meshdeliveryclient.DecodeMerge(raw)
|
||||
for _, g := range of {
|
||||
if len(g.Members) < 2 {
|
||||
continue
|
||||
}
|
||||
key, err := KeyOf(g.ID)
|
||||
if err != nil {
|
||||
unread = append(unread, key)
|
||||
unread = append(unread, "a group with no id")
|
||||
continue
|
||||
}
|
||||
if len(m.Approved) < 2 {
|
||||
continue
|
||||
r := Row{Key: key, Group: g.ID, State: g.State}
|
||||
allPublished := true
|
||||
for _, m := range g.Members {
|
||||
commit, pub := published[PublishedKey(m.Repository, int(m.Number))]
|
||||
r.Members = append(r.Members, RowMember{Repository: m.Repository, Number: int(m.Number), State: m.State, Commit: commit, Published: pub})
|
||||
allPublished = allPublished && pub
|
||||
}
|
||||
r := Row{Key: key, Group: m.Group}
|
||||
allMerged := true
|
||||
for _, p := range m.Pulls {
|
||||
pub := p.Commit != "" && performed[PublishedKey(p.Repository, int(p.Number), p.Commit)]
|
||||
r.Members = append(r.Members, RowMember{Repository: p.Repository, Number: int(p.Number), State: p.State, Commit: p.Commit, Published: pub})
|
||||
if meshdeliveryclient.MergeState(p.State) != meshdeliveryclient.MergeMerged || !pub {
|
||||
allMerged = false
|
||||
}
|
||||
}
|
||||
if len(m.Pulls) < len(m.Approved) {
|
||||
allMerged = false
|
||||
}
|
||||
r.EndedAt, r.Ended = endedAt[key]
|
||||
r.EndedWith, r.Ended = ended["groups."+key]
|
||||
r.LastEndAsk = lastAsk[key]
|
||||
r.Open = !r.Ended
|
||||
switch {
|
||||
case r.Ended:
|
||||
r.Words = "its end is written"
|
||||
case allMerged:
|
||||
r.Words = "every member is merged and its message written, and its end is not written: bridge_group_end_again says it"
|
||||
case allPublished:
|
||||
r.Words = "every member's merged message is written, and its end is not written: bridge_group_end_again says it"
|
||||
default:
|
||||
r.Words = "gathered: not every member is merged and its merged message written"
|
||||
r.Words = "gathered: not every member's merged message is written"
|
||||
}
|
||||
rows = append(rows, r)
|
||||
}
|
||||
@@ -177,25 +191,46 @@ func Rows(merges map[string][]byte, performed map[string]bool, endedAt map[strin
|
||||
|
||||
func sortRows(rows []Row) { sort.Slice(rows, func(i, j int) bool { return rows[i].Key < rows[j].Key }) }
|
||||
|
||||
// MembersOf are a merge's approved members with their heads, in the order of their keys.
|
||||
func MembersOf(m seat.MergesByGroupValue) []meshdeliveryclient.Head {
|
||||
var out []meshdeliveryclient.Head
|
||||
for _, k := range slices.Sorted(func(yield func(string) bool) {
|
||||
for k := range m.Approved {
|
||||
if !yield(k) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}) {
|
||||
at := strings.LastIndex(k, "@")
|
||||
if at < 0 {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.Atoi(k[at+1:])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, meshdeliveryclient.Head{Repository: k[:at], Number: n, Head: m.Approved[k]})
|
||||
// MembersOf are a group's members with their heads, in the group's order.
|
||||
func MembersOf(g seat.Group) []meshdeliveryclient.Head {
|
||||
out := make([]meshdeliveryclient.Head, 0, len(g.Members))
|
||||
for _, m := range g.Members {
|
||||
out = append(out, meshdeliveryclient.Head{Repository: m.Repository, Number: int(m.Number), Head: m.Head})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Named is the group of id among the groups the seat answered, when it has more than one member and names the pull
|
||||
// request number of repository among them (a lone pull request is no group).
|
||||
func Named(of []seat.Group, id, repository string, number int) (seat.Group, bool) {
|
||||
for _, g := range of {
|
||||
if g.ID != id || len(g.Members) < 2 {
|
||||
continue
|
||||
}
|
||||
if slices.ContainsFunc(g.Members, func(m seat.Member) bool {
|
||||
return trunk.SameRepository(m.Repository, repository) && int(m.Number) == number
|
||||
}) {
|
||||
return g, true
|
||||
}
|
||||
}
|
||||
return seat.Group{}, false
|
||||
}
|
||||
|
||||
// Read is every delivery group the seat mesh-delivery's verb groups answers, the ended ones too (all), called through call
|
||||
// by the verb's manifest key: the bridge reads a group from the seat, never from the gate's state (design 47 *A group's
|
||||
// merges are taken in together*). An answer saying an error, or one the contract cannot type, is an error: a failed read
|
||||
// is never read as no group.
|
||||
func Read(ctx context.Context, call func(ctx context.Context, key string, body any) (json.RawMessage, error)) ([]seat.Group, error) {
|
||||
raw, err := call(ctx, "seat:"+seat.Seat+"."+seat.VerbGroups, seat.GroupsInput{All: seat.GroupsInputAllTrue})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the seat %s did not answer %s: %w", seat.Seat, seat.VerbGroups, err)
|
||||
}
|
||||
var a seat.GroupsAnswer
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
return nil, fmt.Errorf("the seat %s's answer of %s is not its contract's: %w", seat.Seat, seat.VerbGroups, err)
|
||||
}
|
||||
if a.Error != "" {
|
||||
return nil, fmt.Errorf("the seat %s refused %s: %s", seat.Seat, seat.VerbGroups, a.Error)
|
||||
}
|
||||
return a.Groups, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package groups
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// What a person asks of the bridge's performing of what is owed (novox/hq design 47 *What the gate owes the forge*): the
|
||||
// tools bridge_owed (its consumers' backlog), bridge_owed_again and bridge_owed_drop write an ask into the state
|
||||
// owed-asks and wait for the bridge's answer there, as bridge_group_end_again does, since the bridge is the other program
|
||||
// and serves no request subject (ADR 0437 §4).
|
||||
const (
|
||||
// OwedAsksState is the state of asks about what is owed, one key per kind of ask and act.
|
||||
OwedAsksState = "owed-asks"
|
||||
// OwedAsksBucket is its bucket on the bus.
|
||||
OwedAsksBucket = Module + "_" + OwedAsksState
|
||||
)
|
||||
|
||||
// The kinds of an owed ask.
|
||||
const (
|
||||
OwedAgain = "again"
|
||||
OwedDrop = "drop"
|
||||
OwedBacklog = "backlog"
|
||||
)
|
||||
|
||||
// Backlog is one durable consumer of a seat's stream owed as the bridge reads it: the last event applied (its stream
|
||||
// sequence), the events waiting and those delivered and not yet acknowledged, and the oldest of them.
|
||||
type Backlog struct {
|
||||
Stream string `json:"stream"`
|
||||
Consumer string `json:"consumer"`
|
||||
LastApplied uint64 `json:"last-applied"`
|
||||
Waiting uint64 `json:"waiting"`
|
||||
Held uint64 `json:"held"`
|
||||
Oldest time.Time `json:"oldest,omitzero"`
|
||||
OldestSecs int64 `json:"oldest-secs,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// OwedAsk is a value of owed-asks: what was asked (of the act Of said under ID, for again and drop), why, who and when,
|
||||
// and once the bridge answered, when, what it came to (the outcome said as owed.ended) and its words; a backlog ask is
|
||||
// answered with the consumers' backlog.
|
||||
type OwedAsk struct {
|
||||
Kind string `json:"kind"`
|
||||
Of string `json:"of,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Why string `json:"why,omitempty"`
|
||||
By string `json:"by,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
Answered time.Time `json:"answered,omitzero"`
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
Answer string `json:"answer,omitempty"`
|
||||
Backlog []Backlog `json:"backlog,omitempty"`
|
||||
}
|
||||
|
||||
// Done says the bridge answered it.
|
||||
func (a OwedAsk) Done() bool { return !a.Answered.IsZero() }
|
||||
|
||||
// NewOwedAsk is an ask's key and value: a backlog ask under the key backlog, an act's under its kind, its seat and its id
|
||||
// (unpadded base64url, as an id may hold what a key may not). A drop says why.
|
||||
func NewOwedAsk(kind, of, id, why, by string, at time.Time) (string, []byte, error) {
|
||||
a := OwedAsk{Kind: kind, Of: of, ID: id, Why: strings.TrimSpace(why), By: by, At: at.UTC()}
|
||||
key := kind
|
||||
switch kind {
|
||||
case OwedBacklog:
|
||||
a.Of, a.ID = "", ""
|
||||
case OwedAgain, OwedDrop:
|
||||
if of == "" || id == "" {
|
||||
return "", nil, fmt.Errorf("an owed act is named by the seat that said it and its id")
|
||||
}
|
||||
if kind == OwedDrop && a.Why == "" {
|
||||
return "", nil, fmt.Errorf("a drop says why")
|
||||
}
|
||||
key = kind + "." + of + "." + base64.RawURLEncoding.EncodeToString([]byte(id))
|
||||
default:
|
||||
return "", nil, fmt.Errorf("no owed ask %q: again, drop or backlog", kind)
|
||||
}
|
||||
v, err := json.Marshal(a)
|
||||
return key, v, err
|
||||
}
|
||||
|
||||
// DecodeOwedAsk reads a value of owed-asks.
|
||||
func DecodeOwedAsk(data []byte) (OwedAsk, error) {
|
||||
var a OwedAsk
|
||||
if err := json.Unmarshal(data, &a); err != nil {
|
||||
return a, err
|
||||
}
|
||||
switch a.Kind {
|
||||
case OwedAgain, OwedDrop, OwedBacklog:
|
||||
return a, nil
|
||||
}
|
||||
return a, fmt.Errorf("no owed ask %q", a.Kind)
|
||||
}
|
||||
@@ -27,6 +27,14 @@ type state struct {
|
||||
History int `json:"history"`
|
||||
}
|
||||
|
||||
// kept is an entry of requires in its long form: kept data on the bus (novox/hq ADR 0595).
|
||||
type kept struct {
|
||||
Name string `json:"name"`
|
||||
Storage string `json:"storage"`
|
||||
Template string `json:"template"`
|
||||
Options map[string]int `json:"options"`
|
||||
}
|
||||
|
||||
type artifact struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
@@ -46,6 +54,7 @@ type manifest struct {
|
||||
Consumes []string `json:"consumes"`
|
||||
Streams []stream `json:"streams"`
|
||||
State []state `json:"state"`
|
||||
Requires []kept `json:"requires"`
|
||||
Reads []string `json:"reads"`
|
||||
Tools []string `json:"tools"`
|
||||
Invokes []string `json:"invokes"`
|
||||
@@ -87,19 +96,22 @@ func TestManifestDeclarations(t *testing.T) { // 1235/AC1
|
||||
if m.Module != "mesh-delivery-forge-bridge" {
|
||||
t.Errorf("module = %q", m.Module)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Streams, []stream{{Name: "forge", Events: []string{"forge.**"}}}) {
|
||||
if !reflect.DeepEqual(m.Streams, []stream{{Name: "forge", Events: []string{"forge.**", "owed.ended"}}}) {
|
||||
t.Errorf("streams = %+v", m.Streams)
|
||||
}
|
||||
// The checker's owed is the forge module's to write (ADR 0521, R4e finding R2-1): the bridge neither hears nor reads it.
|
||||
// gitea.forge.delete: a branch's end, said as a branch-deleted kind (design 51, DLV-FB1).
|
||||
if want := []string{"gitea.forge.delete", "gitea.pull.closed", "gitea.pull.merged", "gitea.pull.updated"}; !reflect.DeepEqual(sorted(m.Consumes), want) {
|
||||
// What the gate and the checker owe the forge is each seat's event owed, heard on its own stream (design 47 *What the
|
||||
// gate owes the forge*), never read from their state. The forge's events are the seat git's (design 47 *The seat git*); forge.delete: a branch's end, said as a
|
||||
// branch-deleted kind (design 51, DLV-FB1).
|
||||
if want := []string{"git.forge-delete", "git.pull-closed", "git.pull-merged", "git.pull-updated", "mesh-delivery-checker.owed", "mesh-delivery.owed"}; !reflect.DeepEqual(sorted(m.Consumes), want) {
|
||||
t.Errorf("consumes = %v, want %v", m.Consumes, want)
|
||||
}
|
||||
if want := []string{"mesh-delivery-gate.merges", "mesh-delivery-gate.owed"}; !reflect.DeepEqual(sorted(m.Reads), want) {
|
||||
t.Errorf("reads = %v, want %v", m.Reads, want)
|
||||
// A group is read from the seat mesh-delivery's verb groups (design 47, acceptance 49): the bridge reads no state of
|
||||
// another module's.
|
||||
if len(m.Reads) != 0 {
|
||||
t.Errorf("reads = %v, want none", m.Reads)
|
||||
}
|
||||
if want := []string{"gitea.gitea_add_comment", "gitea.gitea_api", "gitea.gitea_commit_status", "gitea.gitea_commit_trees", "gitea.gitea_delivery_view",
|
||||
"gitea.gitea_get_file", "gitea.gitea_merge_pull_request", "gitea.gitea_note_append", "gitea.gitea_open_pulls"}; !reflect.DeepEqual(sorted(m.Invokes), want) {
|
||||
if want := []string{"seat:git.comment", "seat:git.file", "seat:git.merge-pull", "seat:git.note", "seat:git.open-pulls",
|
||||
"seat:git.pull", "seat:git.status", "seat:git.tags", "seat:git.trees", "seat:git.view", "seat:mesh-delivery.groups", "seat:mesh-hand-acts.hand-act"}; !reflect.DeepEqual(sorted(m.Invokes), want) {
|
||||
t.Errorf("invokes = %v, want %v", m.Invokes, want)
|
||||
}
|
||||
if b, ok := m.OwnSecrets["broker"]; !ok || len(m.OwnSecrets) != 1 || b != "${dir:state}/broker" {
|
||||
@@ -108,19 +120,22 @@ func TestManifestDeclarations(t *testing.T) { // 1235/AC1
|
||||
if m.SecretsOwner != "mesh-delivery-forge-bridge" {
|
||||
t.Errorf("secrets-owner = %q", m.SecretsOwner)
|
||||
}
|
||||
// Three buckets: the head last written on FORGE for each pull request, so a sync writes only what differs (ADR 0443
|
||||
// §5), the revision of each key of the gate's buckets the bridge performed, so a key is performed once per
|
||||
// revision (ADR 0437 §8; issue 1341), and what a merge read of each module directory by its tree, so a merge reads
|
||||
// the forge only for the directories it changed (issue 1126).
|
||||
if !reflect.DeepEqual(m.State, []state{{Name: "heads", History: 1}, {Name: "performed", History: 1}, {Name: "inputs", History: 1}, {Name: "end-asks", History: 1}}) { // 1279/AC6, 1341, 1126, 1585
|
||||
t.Errorf("state = %+v, want the buckets heads, performed, inputs and end-asks", m.State)
|
||||
// The head last written on FORGE for each pull request, so a sync writes only what differs (ADR 0443 §5), and what a
|
||||
// merge read of each module directory by its tree, so a merge reads the forge only for the directories it changed
|
||||
// (issue 1126). What it performed of what is owed is kept data in the new form, one key per act, kept 7 days as the
|
||||
// owners' streams owed are; its size is the template's own (design 47 *What the gate owes the forge*, ADR 0595 §3).
|
||||
if !reflect.DeepEqual(m.State, []state{{Name: "heads", History: 1}, {Name: "inputs", History: 1}, {Name: "end-asks", History: 1}, {Name: "owed-asks", History: 1}}) { // 1279/AC6, 1126, 1585
|
||||
t.Errorf("state = %+v, want the buckets heads, inputs, end-asks and owed-asks", m.State)
|
||||
}
|
||||
if want := []kept{{Name: "performed", Storage: "key-value", Template: "state", Options: map[string]int{"history": 1, "ttl-seconds": 604800}}}; !reflect.DeepEqual(m.Requires, want) {
|
||||
t.Errorf("requires = %+v, want performed as %+v", m.Requires, want)
|
||||
}
|
||||
// 1585/T18: the tools read the groups and ask the bridge to say a group's end again, through its state end-asks. The tool bridge_merged_message answers a merge's merged message again (issue 1585); bridge_tags reads a package's tags for the builder's floor (design 51).
|
||||
if want := []string{"bridge_group_end_again", "bridge_groups", "bridge_merged_message", "bridge_tags"}; !reflect.DeepEqual(sorted(m.Tools), want) {
|
||||
if want := []string{"bridge_group_end_again", "bridge_groups", "bridge_merged_message", "bridge_owed", "bridge_owed_again", "bridge_owed_drop", "bridge_tags"}; !reflect.DeepEqual(sorted(m.Tools), want) {
|
||||
t.Errorf("tools = %v, want %v", m.Tools, want)
|
||||
}
|
||||
// No other state, no bus, no seat, no event (ADR 0437 §4; ADR 0521: no check.status).
|
||||
for _, k := range []string{"logs", "emits", "claims", "provides", "requires", "uses", "defines-seats", "seat", "seats"} {
|
||||
// No other state, no seat, no event (ADR 0437 §4; ADR 0521: no check.status); its kept data on the bus is the one entry of requires above.
|
||||
for _, k := range []string{"logs", "emits", "claims", "provides", "uses", "defines-seats", "seat", "seats"} {
|
||||
if _, ok := top[k]; ok {
|
||||
t.Errorf("the manifest declares %q: the bridge has none", k)
|
||||
}
|
||||
|
||||
@@ -3,40 +3,52 @@
|
||||
"version": "1",
|
||||
"slug": "forge-bridge",
|
||||
"consumes": [
|
||||
"gitea.pull.updated",
|
||||
"gitea.pull.merged",
|
||||
"gitea.pull.closed",
|
||||
"gitea.forge.delete"
|
||||
"git.pull-updated",
|
||||
"git.pull-merged",
|
||||
"git.pull-closed",
|
||||
"git.forge-delete",
|
||||
"mesh-delivery.owed",
|
||||
"mesh-delivery-checker.owed"
|
||||
],
|
||||
"streams": [
|
||||
{ "name": "forge", "events": ["forge.**"] }
|
||||
{ "name": "forge", "events": ["forge.**", "owed.ended"] }
|
||||
],
|
||||
"state": [
|
||||
{ "name": "heads", "history": 1 },
|
||||
{ "name": "performed", "history": 1 },
|
||||
{ "name": "inputs", "history": 1 },
|
||||
{ "name": "end-asks", "history": 1 }
|
||||
{ "name": "end-asks", "history": 1 },
|
||||
{ "name": "owed-asks", "history": 1 }
|
||||
],
|
||||
"tools": [
|
||||
"bridge_tags",
|
||||
"bridge_groups",
|
||||
"bridge_group_end_again",
|
||||
"bridge_merged_message"
|
||||
],
|
||||
"reads": [
|
||||
"mesh-delivery-gate.merges",
|
||||
"mesh-delivery-gate.owed"
|
||||
"bridge_merged_message",
|
||||
"bridge_owed",
|
||||
"bridge_owed_again",
|
||||
"bridge_owed_drop"
|
||||
],
|
||||
"invokes": [
|
||||
"gitea.gitea_merge_pull_request",
|
||||
"gitea.gitea_commit_status",
|
||||
"gitea.gitea_delivery_view",
|
||||
"gitea.gitea_note_append",
|
||||
"gitea.gitea_add_comment",
|
||||
"gitea.gitea_open_pulls",
|
||||
"gitea.gitea_commit_trees",
|
||||
"gitea.gitea_get_file",
|
||||
"gitea.gitea_api"
|
||||
"seat:git.status",
|
||||
"seat:git.comment",
|
||||
"seat:git.view",
|
||||
"seat:git.note",
|
||||
"seat:git.merge-pull",
|
||||
"seat:mesh-delivery.groups",
|
||||
"seat:mesh-hand-acts.hand-act",
|
||||
"seat:git.open-pulls",
|
||||
"seat:git.trees",
|
||||
"seat:git.file",
|
||||
"seat:git.pull",
|
||||
"seat:git.tags"
|
||||
],
|
||||
"requires": [
|
||||
{
|
||||
"name": "performed",
|
||||
"storage": "key-value",
|
||||
"template": "state",
|
||||
"options": { "history": 1, "ttl-seconds": 604800 }
|
||||
}
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "${dir:state}/broker"
|
||||
|
||||
@@ -324,8 +324,8 @@ func (t *Teller) catchUp(id string) {
|
||||
t.said(h, *w, err)
|
||||
}
|
||||
|
||||
// mergeAct is what the approve option of the gate's ask binds: the ask's verb (meshdeliveryclient.AskVerb: mesh-delivery.merge, or
|
||||
// mesh-delivery.settle for a person's settle ask, novox/hq ADR 0556 clause 5), this ask, the answer, and the heads it covers.
|
||||
// mergeAct is what the approve option of the gate's ask binds: the ask's verb (meshdeliveryclient.AskVerb: mesh-delivery.merge; the settle ask
|
||||
// went with the verb settle, design 47 G §431), this ask, the answer, and the heads it covers.
|
||||
// It is spelled as the gate composes it (mesh-catalog modules/mesh-delivery-gate approval.go mergeAct, from
|
||||
// modules/mesh-delivery approval.go:63-65) and as mesh-controller verifies it (cmd/mesh-controller/moved.go:98-106).
|
||||
func mergeAct(verb, ask, option string, heads map[string]string) occ.Act {
|
||||
|
||||
@@ -412,16 +412,15 @@ func TestAWarrantThatHoldsWritesOneAnswerAndASecondWritesNone(t *testing.T) { //
|
||||
if len(b.conds) != 0 {
|
||||
t.Errorf("conditions said: %+v", b.conds)
|
||||
}
|
||||
// 1280 (ADR 0556 clause 5): a settle ask holds when its options bind mesh-delivery.settle, and not when they bind a merge.
|
||||
for verb, holds := range map[string]bool{"mesh-delivery.settle": true, "mesh-delivery.merge": false} {
|
||||
// design 47 G §431: the settle ask went with the verb settle, so an ask whose options bind it holds no more.
|
||||
for verb, holds := range map[string]bool{"mesh-delivery.settle": false, "mesh-delivery.merge": true} {
|
||||
b := newBed(t)
|
||||
group := meshdeliveryclient.SettleGroup([]string{"novox/mesh-delivery@" + headA[:12]})
|
||||
key, value, ga := gateAskOf(t, group, verb, twoHeads(), twoHeads(), 1)
|
||||
key, value, ga := gateAskOf(t, "g", verb, twoHeads(), twoHeads(), 1)
|
||||
b.gate[key] = value
|
||||
b.t.OnAsk(key, 1, value)
|
||||
b.deliver(t, warrantFor(ga.Ask, "approve"))
|
||||
if _, written := b.answers.written[answerKey(key)]; written != holds {
|
||||
t.Errorf("a settle ask binding %s: answer written %v, want %v", verb, written, holds)
|
||||
t.Errorf("an ask binding %s: answer written %v, want %v", verb, written, holds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,13 @@ type state struct {
|
||||
History int `json:"history,omitempty"`
|
||||
}
|
||||
|
||||
type stream struct {
|
||||
Name string `json:"name"`
|
||||
Events []string `json:"events"`
|
||||
MaxAgeDays int `json:"max-age-days"`
|
||||
MaxMiB int `json:"max-mib"`
|
||||
}
|
||||
|
||||
type artifact struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
@@ -44,6 +51,7 @@ type manifest struct {
|
||||
Invokes []string `json:"invokes"`
|
||||
Consumes []string `json:"consumes"`
|
||||
Emits []string `json:"emits"`
|
||||
Streams []stream `json:"streams"`
|
||||
OwnSecrets map[string]string `json:"own-secrets"`
|
||||
SecretsOwner string `json:"secrets-owner"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
@@ -102,7 +110,8 @@ func TestManifestDeclarations(t *testing.T) { // 1240/AC1
|
||||
if want := []string{"mesh-delivery-assigner.assigner_read_asks", "seat:mesh-hand-acts.hand-act"}; !reflect.DeepEqual(m.Invokes, want) {
|
||||
t.Errorf("invokes = %v, want %v", m.Invokes, want)
|
||||
}
|
||||
if want := []string{"mesh-delivery-assigner.views.asks"}; !reflect.DeepEqual(m.Consumes, want) {
|
||||
// design 47 G §387: the keeper's ask for the findings is heard, and the finding stream is declared (§383).
|
||||
if want := []string{"mesh-conditions.findings-asked", "mesh-delivery-assigner.views.asks"}; !reflect.DeepEqual(m.Consumes, want) {
|
||||
t.Errorf("consumes = %v, want %v", m.Consumes, want)
|
||||
}
|
||||
if want := []string{"operator-channel"}; !reflect.DeepEqual(sorted(m.Uses), want) {
|
||||
@@ -122,8 +131,15 @@ func TestManifestDeclarations(t *testing.T) { // 1240/AC1
|
||||
if want := []string{tellerevents.AskEnded}; !reflect.DeepEqual(m.Emits, want) {
|
||||
t.Errorf("emits = %v, want %v", m.Emits, want)
|
||||
}
|
||||
// No streams, bus or seat (ADR 0437 §4).
|
||||
for _, k := range []string{"streams", "logs", "claims", "provides", "requires", "defines-seats", "seat", "seats", "bus"} {
|
||||
// design 47 G §383, §387: the stream <module>-findings, 7 days and 16 MiB; §368: version is MAJOR.MINOR.
|
||||
if want := []stream{{Name: "mesh-delivery-teller-findings", Events: []string{"finding-written.*", "finding-cleared.*", "findings-stated.*"}, MaxAgeDays: 7, MaxMiB: 16}}; !reflect.DeepEqual(m.Streams, want) {
|
||||
t.Errorf("streams = %+v, want %+v", m.Streams, want)
|
||||
}
|
||||
if m.Version != "0.1" {
|
||||
t.Errorf("version = %q, want 0.1", m.Version)
|
||||
}
|
||||
// No bus or seat (ADR 0437 §4).
|
||||
for _, k := range []string{"logs", "claims", "provides", "requires", "defines-seats", "seat", "seats", "bus"} {
|
||||
if _, ok := top[k]; ok {
|
||||
t.Errorf("the manifest declares %q: the teller has none", k)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "mesh-delivery-teller",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"slug": "teller",
|
||||
"tools": [
|
||||
"teller_end_ask",
|
||||
@@ -12,8 +12,17 @@
|
||||
"seat:mesh-hand-acts.hand-act"
|
||||
],
|
||||
"consumes": [
|
||||
"mesh-conditions.findings-asked",
|
||||
"mesh-delivery-assigner.views.asks"
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"name": "mesh-delivery-teller-findings",
|
||||
"events": ["finding-written.*", "finding-cleared.*", "findings-stated.*"],
|
||||
"max-age-days": 7,
|
||||
"max-mib": 16
|
||||
}
|
||||
],
|
||||
"emits": [
|
||||
"ask-ended"
|
||||
],
|
||||
|
||||
@@ -797,7 +797,7 @@ func TestProcessWholeSaysTheSourceRevisionIsDelivered(t *testing.T) {
|
||||
want, _ := meshwantsclient.EncodeWant(meshwantsclient.Want{Fp: fp, Composed: "sha256:" + strings.Repeat("c", 64)})
|
||||
failed, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fp, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthFailed})
|
||||
good, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fp, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK})
|
||||
src, _ := meshdeliveryclient.EncodeSource(meshdelivery.SourceByMainValue{Group: "feat/x", Heads: map[string]string{"novox/web": strings.Repeat("1", 40)}})
|
||||
src, _ := meshdeliveryclient.EncodeSource(meshdelivery.SourceByBranchValue{Group: "feat/x", Heads: map[string]string{"novox/web": strings.Repeat("1", 40)}})
|
||||
for bucket, kv := range map[string][]byte{view.ModulesBucket: mod} {
|
||||
if _, err := kvs[bucket].Put(ctx, "web", kv); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -809,7 +809,7 @@ func TestProcessWholeSaysTheSourceRevisionIsDelivered(t *testing.T) {
|
||||
if _, err := kvs[view.HaveBucket].Put(ctx, "ace.web", failed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rev, err := kvs[view.SourceBucket].Put(ctx, meshdeliveryclient.SourceMainKey, src)
|
||||
rev, err := kvs[view.SourceBucket].Put(ctx, meshdeliveryclient.SourceKey("main"), src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package view
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/trunk"
|
||||
"git.novox.be/novox/mesh-sdk/go/keys"
|
||||
@@ -88,3 +89,7 @@ func PackagesKey(module string) (string, error) {
|
||||
}
|
||||
return meshcatalog.PackagesByModuleKey(keys.EscapeName(module)), nil
|
||||
}
|
||||
|
||||
// trunkSourceKey is the key of the gate's state source for the trunk, the watched branch `main` (design 47 G §305: the source
|
||||
// is kept per watched branch, meshdeliveryclient.SourceKey; the view's reads are of the trunk's).
|
||||
var trunkSourceKey = meshdeliveryclient.SourceKey("main")
|
||||
|
||||
@@ -71,7 +71,7 @@ func (m *Model) applyHistory(e Entry) {
|
||||
}
|
||||
m.modHist[e.Key] = h
|
||||
case SourceBucket:
|
||||
if e.Key != meshdeliveryclient.SourceMainKey || e.Deleted || e.Revision == 0 {
|
||||
if e.Key != trunkSourceKey || e.Deleted || e.Revision == 0 {
|
||||
return
|
||||
}
|
||||
src, err := meshdeliveryclient.DecodeSource(e.Value)
|
||||
|
||||
@@ -49,11 +49,11 @@ func TestTheViewDerivesHowFarEachRepositoryReached(t *testing.T) {
|
||||
return append([]Entry{modIn(fpLater, commit, 12, to, true), modIn(fpLater, commit, 12, to, false)}, extra...)
|
||||
}
|
||||
src := func(rev uint64, webHead string) Entry {
|
||||
v, err := meshdeliveryclient.EncodeSource(meshdelivery.SourceByMainValue{Group: "g", Heads: map[string]string{web: webHead, sdk: commitS}})
|
||||
v, err := meshdeliveryclient.EncodeSource(meshdelivery.SourceByBranchValue{Group: "g", Heads: map[string]string{web: webHead, sdk: commitS}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Entry{Bucket: SourceBucket, Key: meshdeliveryclient.SourceMainKey, Value: v, At: t0, Revision: rev, History: true}
|
||||
return Entry{Bucket: SourceBucket, Key: trunkSourceKey, Value: v, At: t0, Revision: rev, History: true}
|
||||
}
|
||||
taken := func(repository, merge string) Entry {
|
||||
key, _ := trunk.RepositoryKey(repository)
|
||||
@@ -72,7 +72,7 @@ func TestTheViewDerivesHowFarEachRepositoryReached(t *testing.T) {
|
||||
// web changed at A (fpOld) and at C (fpNew); mail first appeared at A. The source named A, B, then C as web's head.
|
||||
world := func(t *testing.T) []Entry {
|
||||
cur, _ := meshdeliveryclient.DecodeSource(src(12, commitC).Value)
|
||||
current := Entry{Bucket: SourceBucket, Key: meshdeliveryclient.SourceMainKey, Revision: 12, At: t0}
|
||||
current := Entry{Bucket: SourceBucket, Key: trunkSourceKey, Revision: 12, At: t0}
|
||||
current.Value, _ = meshdeliveryclient.EncodeSource(cur)
|
||||
return []Entry{src(10, commitA), src(11, commitB), src(12, commitC), current,
|
||||
mod("web", fpOld, commitA, 10, true), modIn(fpNew, commitC, 12, in("t1", "mesh-sdk/go"), true), mod("mail", fpMail, commitA, 0, true),
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshwatchdogsclient"
|
||||
"git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshwantsclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
|
||||
@@ -83,7 +82,7 @@ func (r Reader) Tiers(revision uint64) (Tiers, error) {
|
||||
if r.SourceState.GetEntry == nil {
|
||||
return Tiers{}, fmt.Errorf("the gate's source cannot be read here: name the source revision")
|
||||
}
|
||||
_, rev, err := r.SourceState.GetEntry(meshdeliveryclient.SourceMainKey)
|
||||
_, rev, err := r.SourceState.GetEntry(trunkSourceKey)
|
||||
if err != nil {
|
||||
return Tiers{}, fmt.Errorf("the gate's source: %w", err)
|
||||
}
|
||||
|
||||
@@ -621,7 +621,7 @@ func (m *Model) apply(e Entry) {
|
||||
delete(m.taken, e.Key)
|
||||
}
|
||||
case SourceBucket:
|
||||
if e.Key != meshdeliveryclient.SourceMainKey {
|
||||
if e.Key != trunkSourceKey {
|
||||
return
|
||||
}
|
||||
if e.Deleted {
|
||||
|
||||
@@ -61,11 +61,11 @@ func moduleE(t *testing.T, module, fp string) Entry {
|
||||
|
||||
func sourceE(t *testing.T, revision uint64) Entry {
|
||||
t.Helper()
|
||||
v, err := meshdeliveryclient.EncodeSource(meshdelivery.SourceByMainValue{Group: "g", Heads: map[string]string{"novox/web": strings.Repeat("1", 40)}})
|
||||
v, err := meshdeliveryclient.EncodeSource(meshdelivery.SourceByBranchValue{Group: "g", Heads: map[string]string{"novox/web": strings.Repeat("1", 40)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Entry{Bucket: SourceBucket, Key: meshdeliveryclient.SourceMainKey, Value: v, At: t0, Revision: revision}
|
||||
return Entry{Bucket: SourceBucket, Key: trunkSourceKey, Value: v, At: t0, Revision: revision}
|
||||
}
|
||||
|
||||
// 1906/D7 AC7: every want is a row beside the have of its node, with where it stands.
|
||||
|
||||
@@ -51,6 +51,13 @@ type state struct {
|
||||
History int `json:"history,omitempty"`
|
||||
}
|
||||
|
||||
type stream struct {
|
||||
Name string `json:"name"`
|
||||
Events []string `json:"events"`
|
||||
MaxAgeDays int `json:"max-age-days"`
|
||||
MaxMiB int `json:"max-mib"`
|
||||
}
|
||||
|
||||
type manifest struct {
|
||||
State []state `json:"state"`
|
||||
Module string `json:"module"`
|
||||
@@ -63,6 +70,7 @@ type manifest struct {
|
||||
Uses []string `json:"uses"`
|
||||
Tools []string `json:"tools"`
|
||||
Emits []string `json:"emits"`
|
||||
Streams []stream `json:"streams"`
|
||||
OwnSecrets map[string]string `json:"own-secrets"`
|
||||
SecretsOwner string `json:"secrets-owner"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
@@ -117,7 +125,14 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7
|
||||
"seat:node-engine.manifest-reads"}; !reflect.DeepEqual(m.Invokes, want) { // ADR 0618 §3: node_manifest_reads asks the node-engine seat's verb of every node; 1585/T24: view_tiers asks indexer_pending, a verb edge (ADR 0614 §2)
|
||||
t.Errorf("invokes = %v, want %v", m.Invokes, want)
|
||||
}
|
||||
if want := []string{"mesh-delivery-assigner.views.asks", "mesh-delivery-assigner.views.assignments", "mesh-delivery-assigner.views.wants",
|
||||
// design 47 G §387: the keeper's ask for the findings is heard, the finding stream declared (§383), and §368: version is MAJOR.MINOR.
|
||||
if want := []stream{{Name: "mesh-delivery-view-findings", Events: []string{"finding-written.*", "finding-cleared.*", "findings-stated.*"}, MaxAgeDays: 7, MaxMiB: 16}}; !reflect.DeepEqual(m.Streams, want) {
|
||||
t.Errorf("streams = %+v, want %+v", m.Streams, want)
|
||||
}
|
||||
if m.Version != "0.1" {
|
||||
t.Errorf("version = %q, want 0.1", m.Version)
|
||||
}
|
||||
if want := []string{"mesh-conditions.findings-asked", "mesh-delivery-assigner.views.asks", "mesh-delivery-assigner.views.assignments", "mesh-delivery-assigner.views.wants",
|
||||
"mesh-delivery-indexer.views.candidates", "mesh-delivery-indexer.views.modules", "mesh-delivery-indexer.views.taken"}; !reflect.DeepEqual(m.Consumes, want) {
|
||||
t.Errorf("consumes = %v, want %v", m.Consumes, want)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "mesh-delivery-view",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"moved": true,
|
||||
"slug": "view",
|
||||
"tools": [
|
||||
@@ -42,6 +42,7 @@
|
||||
{ "name": "findings", "history": 1 }
|
||||
],
|
||||
"consumes": [
|
||||
"mesh-conditions.findings-asked",
|
||||
"mesh-delivery-assigner.views.asks",
|
||||
"mesh-delivery-assigner.views.assignments",
|
||||
"mesh-delivery-assigner.views.wants",
|
||||
@@ -49,6 +50,14 @@
|
||||
"mesh-delivery-indexer.views.modules",
|
||||
"mesh-delivery-indexer.views.taken"
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"name": "mesh-delivery-view-findings",
|
||||
"events": ["finding-written.*", "finding-cleared.*", "findings-stated.*"],
|
||||
"max-age-days": 7,
|
||||
"max-mib": 16
|
||||
}
|
||||
],
|
||||
"invokes": [
|
||||
"mesh-delivery-assigner.assigner_read_asks",
|
||||
"mesh-delivery-assigner.assigner_read_differences",
|
||||
|
||||
Reference in New Issue
Block a user