Say a trunk merge on FORGE with the merge commit's trees (issue 1236)

This commit is contained in:
2026-10-19 14:53:09 +02:00
parent 1f9606654c
commit 455b902332
6 changed files with 446 additions and 28 deletions
@@ -0,0 +1,91 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/nats-io/nats.go"
"mesh-delivery-forge-bridge/internal/bridge"
)
// callTimeout bounds a call to a tool (mesh-sdk go/process/call.go CallTimeout).
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,
// 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
// {"result", "error", "node"} and a non-empty error is the call's failure (mesh-tools node-tools/internal/bus/bus.go
// answerOf). The grant to publish on these subjects comes from the manifest's invokes alone (mesh-control
// internal/broker/nats.go invokedSubjects).
func callerOn(nc *nats.Conn, user string) caller {
token := strings.ReplaceAll(user, ".", "~")
request := func(ctx context.Context, subject string, data []byte) (json.RawMessage, error) {
ctx, cancel := context.WithTimeout(ctx, callTimeout)
defer cancel()
msg, err := nc.RequestWithContext(ctx, subject, data)
if err != nil {
return nil, err
}
var r struct {
Result json.RawMessage `json:"result"`
Error string `json:"error"`
Node string `json:"node"`
}
if err := json.Unmarshal(msg.Data, &r); err != nil {
return nil, fmt.Errorf("%s answered something that is not a tool's answer: %w", subject, err)
}
if r.Error != "" {
return nil, errors.New(r.Error)
}
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)
}
data, err := json.Marshal(body)
if err != nil {
return nil, err
}
result, err := request(ctx, "mesh.mod."+module+".call."+tool+"."+token, data)
if errors.Is(err, nats.ErrNoResponders) {
result, err = request(ctx, "mesh.mod."+module+".tool."+tool, data)
}
return result, err
}
}
// 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"
// treesVia asks the forge for a commit's trees through call.
func treesVia(ctx context.Context, call caller) bridge.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})
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 a.Root, a.Trees, nil
}
}
@@ -63,10 +63,10 @@ func startBus(t *testing.T) *server.Server {
return srv
}
// TestBridgeWritesPullMessages starts the program the way the node does (its entry, run, with MESH_BROKER_FILE) against
// an embedded bus with the stream EVENTS, the bridge's stream FORGE and its durable consumer <node>_<module> on EVENTS,
// as the controller makes them (mesh-control internal/broker streams.go:192, nats.go:1387-1396, modstreams.go).
func TestBridgeWritesPullMessages(t *testing.T) { // 1235/AC2
// startBridge runs the program against an embedded bus with the stream EVENTS, the bridge's stream FORGE and its durable
// consumer <node>_<module> on EVENTS, as the controller makes them (mesh-control internal/broker streams.go:192,
// nats.go:1387-1396, modstreams.go). It returns a control connection, its JetStream and a context.
func startBridge(t *testing.T) (*nats.Conn, jetstream.JetStream, context.Context) {
srv := startBus(t)
ctl, err := nats.Connect(srv.ClientURL(), nats.UserInfo("ctl", "pw"))
if err != nil {
@@ -78,7 +78,7 @@ func TestBridgeWritesPullMessages(t *testing.T) { // 1235/AC2
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
t.Cleanup(cancel)
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "EVENTS", Subjects: []string{"mesh.mod.*.event.>"}}); err != nil {
t.Fatal(err)
}
@@ -102,6 +102,12 @@ func TestBridgeWritesPullMessages(t *testing.T) { // 1235/AC2
done := make(chan error, 1)
go func() { done <- run(runCtx, func(k string) string { return env[k] }, io.Discard) }()
t.Cleanup(func() { stop(); <-done })
return ctl, js, ctx
}
// TestBridgeWritesPullMessages starts the program the way the node does (its entry, run, with MESH_BROKER_FILE).
func TestBridgeWritesPullMessages(t *testing.T) { // 1235/AC2
_, js, ctx := startBridge(t)
// 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",
@@ -152,3 +158,83 @@ func TestBridgeWritesPullMessages(t *testing.T) { // 1235/AC2
ci, _ := cons.Info(ctx)
t.Errorf("the event is not acknowledged: %+v", ci)
}
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}).
func TestBridgeWritesMergedMessage(t *testing.T) { // 1236/AC4
ctl, js, ctx := startBridge(t)
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 {
t.Fatal(err)
}
forge, err := js.Stream(ctx, "FORGE")
if err != nil {
t.Fatal(err)
}
var info *jetstream.StreamInfo
for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
if info, err = forge.Info(ctx); err == nil && info.State.Msgs >= 1 {
break
}
}
if info.State.Msgs != 1 {
t.Fatalf("FORGE holds %d messages within 5 s, want 1", info.State.Msgs)
}
raw, err := forge.GetLastMsgForSubject(ctx, "mesh.stream.mesh-delivery-forge-bridge.event.forge.novox.mesh-delivery.merged")
if err != nil {
t.Fatal(err)
}
f, err := delivery.DecodeForge(raw.Data)
if err != nil {
t.Fatal(err)
}
want := delivery.Forge{V: delivery.Version, Kind: "merged", Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12, Head: head,
Merge: merge, Root: "r0", Trees: map[string]string{"": "t0", "modules/a": "t1"}}
if !delivery.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)
}
default:
t.Error("the stand-in was not asked")
}
if len(asked) != 0 {
t.Error("the tool was asked more than once")
}
cons, err := js.Consumer(ctx, "EVENTS", "n1_mesh-delivery-forge-bridge")
if err != nil {
t.Fatal(err)
}
for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
ci, err := cons.Info(ctx)
if err == nil && ci.NumAckPending == 0 && ci.NumPending == 0 && ci.Delivered.Consumer >= 1 {
return
}
}
ci, _ := cons.Info(ctx)
t.Errorf("the event is not acknowledged: %+v", ci)
}
@@ -19,6 +19,7 @@ import (
"os/signal"
"strings"
"syscall"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
@@ -98,8 +99,13 @@ type delivered interface {
Headers() nats.Header
Ack() error
Term() error
NakWithDelay(delay time.Duration) error
}
// retryDelay is how long the server waits before it delivers again an event the forge could not be asked about (the
// server's redelivery is the only clock; issue 1236/AC3).
const retryDelay = 10 * time.Second
// publisher writes to FORGE.
type publisher interface {
Publish(ctx context.Context, subject string, payload []byte, opts ...jetstream.PublishOpt) (*jetstream.PubAck, error)
@@ -108,9 +114,17 @@ type publisher interface {
// handle turns one event into its message on FORGE and acknowledges it. An event it cannot read is terminated, never
// redelivered, and the log names its subject and why. A message it could not write stays unacknowledged: the server
// delivers it again (the server's ack wait is the only clock).
func handle(ctx context.Context, m delivered, pub publisher, logger *log.Logger) {
msg, err := bridge.Convert(m.Subject(), m.Data())
func handle(ctx context.Context, m delivered, pub publisher, trees bridge.Trees, logger *log.Logger) {
msg, err := bridge.Convert(m.Subject(), m.Data(), trees)
switch {
case errors.Is(err, bridge.ErrRetry):
logger.Printf("%v; heard again in %s", err, retryDelay)
_ = m.NakWithDelay(retryDelay)
return
case errors.Is(err, bridge.ErrNoModules):
logger.Printf("%v; no merged message", err)
_ = m.Ack()
return
case errors.Is(err, bridge.ErrNotHandled):
logger.Printf("%v; nothing written", err)
_ = m.Ack()
@@ -166,7 +180,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
if err != nil {
return fmt.Errorf("the consumer %s_%s on %s: %w", cred.Node, cred.Module, eventsStream, err)
}
cc, err := cons.Consume(func(m jetstream.Msg) { handle(ctx, m, js, logger) },
cc, err := cons.Consume(func(m jetstream.Msg) { handle(ctx, m, js, treesVia(ctx, callerOn(conn, cred.User)), logger) },
jetstream.ConsumeErrHandler(func(_ jetstream.ConsumeContext, err error) { logger.Printf("hearing the bus: %v", err) }))
if err != nil {
return err
@@ -8,6 +8,7 @@ import (
"log"
"strings"
"testing"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
@@ -21,13 +22,15 @@ type fakeMsg struct {
subject string
data []byte
acked, termd int
nak []time.Duration
}
func (f *fakeMsg) Subject() string { return f.subject }
func (f *fakeMsg) Data() []byte { return f.data }
func (f *fakeMsg) Headers() nats.Header { return nats.Header{"X-Event-Id": {"ev-1"}} }
func (f *fakeMsg) Ack() error { f.acked++; return nil }
func (f *fakeMsg) Term() error { f.termd++; return nil }
func (f *fakeMsg) Subject() string { return f.subject }
func (f *fakeMsg) Data() []byte { return f.data }
func (f *fakeMsg) Headers() nats.Header { return nats.Header{"X-Event-Id": {"ev-1"}} }
func (f *fakeMsg) Ack() error { f.acked++; return nil }
func (f *fakeMsg) NakWithDelay(d time.Duration) error { f.nak = append(f.nak, d); return nil }
func (f *fakeMsg) Term() error { f.termd++; return nil }
type fakePub struct {
subjects []string
@@ -61,7 +64,7 @@ func body(m map[string]any) []byte {
func TestReadableEventIsWrittenOnForgeAndAcked(t *testing.T) { // 1235/AC2 (unit part; the bus is the integration test)
var logs bytes.Buffer
m, pub := &fakeMsg{subject: "mesh.mod.gitea.event.pull.updated", data: body(nil)}, &fakePub{}
handle(context.Background(), m, pub, log.New(&logs, "", 0))
handle(context.Background(), m, pub, 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)
}
@@ -77,7 +80,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{}
handle(context.Background(), m, pub, log.New(&logs, "", 0))
handle(context.Background(), m, pub, nil, log.New(&logs, "", 0))
if len(pub.subjects) != 0 {
t.Errorf("a message was written: %v", pub.subjects)
}
@@ -92,7 +95,7 @@ func TestUnreadableEventIsTerminatedAndLogged(t *testing.T) { // 1235/AC5
func TestUnwrittenMessageIsNotAcked(t *testing.T) { // 1235 (a failed write is redelivered by the server, not retried here)
var logs bytes.Buffer
m, pub := &fakeMsg{subject: "mesh.mod.gitea.event.pull.updated", data: body(nil)}, &fakePub{err: errors.New("no stream")}
handle(context.Background(), m, pub, log.New(&logs, "", 0))
handle(context.Background(), m, pub, nil, log.New(&logs, "", 0))
if m.acked != 0 || m.termd != 0 {
t.Errorf("acked %d terminated %d, want neither", m.acked, m.termd)
}
@@ -100,3 +103,71 @@ func TestUnwrittenMessageIsNotAcked(t *testing.T) { // 1235 (a failed write is r
t.Errorf("log %q", logs.String())
}
}
// mergedEvent is gitea.pull.merged as mesh-catalog modules/gitea/index.ts:186-207 writes it (the fields the bridge reads).
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"})
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{}
call := func(context.Context, string, any) (json.RawMessage, error) {
return nil, errors.New("the tree listing of novox/mesh-delivery was cut")
}
handle(context.Background(), m, pub, treesVia(context.Background(), call), log.New(&logs, "", 0))
if len(pub.subjects) != 0 {
t.Errorf("a message was written: %v", pub.subjects)
}
if m.acked != 0 || m.termd != 0 || len(m.nak) != 1 || m.nak[0] < 10*time.Second {
t.Errorf("acked %d terminated %d naks %v, want one nak of at least 10 s", m.acked, m.termd, m.nak)
}
for _, w := range []string{"novox/mesh-delivery", "89abcdef0123456789abcdef0123456789abcdef", "the tree listing of novox/mesh-delivery was cut"} {
if !strings.Contains(logs.String(), w) {
t.Errorf("log %q does not name %q", logs.String(), w)
}
}
}
func TestMergeCallsTheToolOnceAndWritesTheMergedMessage(t *testing.T) { // 1236/AC1 (through the tool's request and answer)
var logs bytes.Buffer
m, pub := &fakeMsg{subject: "mesh.mod.gitea.event.pull.merged", data: mergedEvent()}, &fakePub{}
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(`{"root":"r0","trees":{"":"t0","modules/a":"t1"}}`), nil
}
handle(context.Background(), m, pub, treesVia(context.Background(), call), log.New(&logs, "", 0))
if len(keys) != 1 || keys[0] != "gitea.gitea_commit_trees" {
t.Fatalf("calls %v", keys)
}
if got, _ := json.Marshal(args[0]); string(got) != `{"owner":"novox","repo":"mesh-delivery","sha":"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 := delivery.DecodeForge(pub.data[0])
if err != nil || f.Root != "r0" || f.Trees[""] != "t0" || f.Trees["modules/a"] != "t1" || f.Merge == "" {
t.Errorf("message %+v err %v", f, err)
}
if m.acked != 1 {
t.Errorf("acked %d", m.acked)
}
}
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")
}
}