DLV-FB1: the forge bridge says a branch's end as branch-deleted and answers bridge_tags

The bridge consumes the forge module's forge.delete and writes a branch-deleted message on FORGE for a deleted
branch (never the trunk, never a tag), so the builder removes the branch's tagged versions (design 51 *The forge
bridge says a branch's end*). Only the forge module's account may say forge.delete: the bus refuses it from
another (a case of the bridge's whole-module test). bridge_tags, served by a new tools bundle, answers a
package's git tags by prefix through the forge module's gitea_api, for the builder's floor.

One unit test merged into another, so the tags test adds no function (test budget).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
2026-11-04 11:44:53 +01:00
parent 2274155766
commit bd13302ecf
7 changed files with 319 additions and 21 deletions
@@ -0,0 +1,42 @@
// Command mesh-delivery-forge-bridge-tools serves the forge bridge's tools (novox/hq design 51): bridge_tags, a
// package's git tags read from the forge through the forge module's gitea_api, for the builder's floor. The runtime
// launches it for a call; the bridge's program serves no request subject (ADR 0437 §8).
package main
import (
"encoding/json"
"fmt"
"os"
"strings"
stdio "git.novox.be/novox/mesh-sdk/go"
"mesh-delivery-forge-bridge/internal/bridge"
)
// tools are the bridge's tools over api.
func tools(api bridge.API) []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).",
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"))
},
}}
}
func main() {
api := func(path string) (json.RawMessage, error) {
return stdio.Ask("gitea.gitea_api", map[string]any{"path": path, "method": "GET"})
}
if err := stdio.Serve("", tools(api)); err != nil {
fmt.Fprintf(os.Stderr, "[mesh-delivery-forge-bridge-tools] %v\n", err)
os.Exit(1)
}
}
@@ -118,6 +118,7 @@ accounts {
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"
@@ -235,7 +236,7 @@ func startBed(t *testing.T, openReply string, prepare func(ctx context.Context,
}
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"},
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"},
}); err != nil {
t.Fatal(err)
}
@@ -293,7 +294,8 @@ func startBed(t *testing.T, openReply string, prepare func(ctx context.Context,
// 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, `{"result":{"pulls":[]}}`)
ctl, js, ctx, _ := startBridge(t, `{"result":{"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",
@@ -346,6 +348,42 @@ 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.
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 {
t.Fatal(err)
}
forge, err := js.Stream(ctx, "FORGE")
if err != nil {
t.Fatal(err)
}
subject := "mesh.stream.mesh-delivery-forge-bridge.event.forge.novox.mesh-sdk.branch-deleted"
var raw *jetstream.RawStreamMsg
for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
if raw, err = forge.GetLastMsgForSubject(ctx, subject); err == nil {
break
}
}
if err != nil || !strings.Contains(string(raw.Data), `"branch":"feat/x"`) {
t.Fatalf("no branch-deleted message on FORGE within 20 s: %v", err)
}
other, err := nats.Connect(srv, nats.UserInfo("n1.mesh-delivery-forge-bridge", "pw"))
if err != nil {
t.Fatal(err)
}
defer other.Close()
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")
}
}
const merge = "89abcdef0123456789abcdef0123456789abcdef"
// TestBridgeWritesMergedMessage has a stand-in answer the forge's gitea_commit_trees on its tool subject
@@ -68,8 +68,62 @@ 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"
)
// ForgeBranchDeleted is the kind of a branch-deleted message on FORGE (novox/hq design 51 *The forge bridge says a
// branch's end*): the builder removes the branch's tagged versions on it, as on a pull request's merged or closed.
// Every existing consumer of FORGE ignores a kind it does not know.
const ForgeBranchDeleted = "branch-deleted"
// BranchDeleted is the body of a branch-deleted message.
type BranchDeleted struct {
V int `json:"v"`
Kind string `json:"kind"`
Repository string `json:"repository"`
Branch string `json:"branch"`
// Head is the commit the branch named when it was deleted, when the forge said it.
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 {
return Message{}, unreadable(subject, "the body is not json: %v", err)
}
if d.RefType != "branch" {
return Message{}, fmt.Errorf("%w: %s of a %s (%s)", ErrNotHandled, subject, d.RefType, d.Ref)
}
branch := strings.TrimPrefix(d.Ref, "refs/heads/")
switch {
case d.Owner == "" || d.Repo == "" || strings.ContainsAny(d.Owner+d.Repo, "./ *>"):
return Message{}, unreadable(subject, "no owner or repo a forge event can name (%q/%q)", d.Owner, d.Repo)
case branch == "":
return Message{}, unreadable(subject, "no branch")
case branch == Trunk:
return Message{}, fmt.Errorf("%w: %s of the trunk %s", ErrNotHandled, subject, Trunk)
}
body, err := json.Marshal(BranchDeleted{V: delivery.Version, Kind: ForgeBranchDeleted, Repository: d.Owner + "/" + d.Repo, Branch: branch, Head: d.SHA})
if err != nil {
return Message{}, unreadable(subject, "%v", err)
}
return Message{Event: "forge." + d.Owner + "." + d.Repo + "." + ForgeBranchDeleted, Data: body,
Said: fmt.Sprintf("branch %s of %s/%s deleted: its tagged versions end", branch, d.Owner, d.Repo)}, nil
}
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
@@ -115,6 +169,8 @@ func ConvertKept(subject string, data []byte, trees Trees, files Files, kept Kep
action = delivery.PullClosed
case MergedSubject:
action = delivery.PullClosed // unless it merged into the trunk, below
case DeletedSubject:
return branchDeleted(subject, data)
default:
return Message{}, fmt.Errorf("%w: %s", ErrNotHandled, subject)
}
@@ -63,14 +63,13 @@ func TestUpdatedBecomesAPullMessage(t *testing.T) { // 1235/AC2 (the conversion;
if !delivery.SameForge(f, want) {
t.Errorf("message = %+v, want %+v", f, want)
}
}
func TestDirsNotSaidAreNotKnown(t *testing.T) { // 1235/AC3
msg, err := Convert(updatedSubject, updated(map[string]any{"module_dirs": nil, "module_dirs_said": nil}), nil, anyFiles)
// 1235/AC3, merged into this test for the test budget: dirs not said are not known.
msg, err = Convert(updatedSubject, updated(map[string]any{"module_dirs": nil, "module_dirs_said": nil}), nil, anyFiles)
if err != nil {
t.Fatal(err)
}
f, _ := delivery.DecodeForge(msg.Data)
f, _ = delivery.DecodeForge(msg.Data)
if f.DirsKnown || len(f.Dirs) != 0 {
t.Errorf("dirs = %v known = %v, want not known", f.Dirs, f.DirsKnown)
}
@@ -171,11 +170,31 @@ func mustDrop(body []byte, key string) []byte {
}
func TestOtherSubjectsAreNotHandled(t *testing.T) { // 1235
for _, s := range []string{"mesh.mod.gitea.event.something.else", "mesh.mod.other.event.pull.updated"} {
for _, s := range []string{"mesh.mod.gitea.event.something.else", "mesh.mod.other.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
// 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})
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 == "" {
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"}} {
if _, err := Convert(DeletedSubject, del(c.refType, c.ref), nil, anyFiles); !errors.Is(err, ErrNotHandled) {
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) {
t.Errorf("forge.delete naming no repository: err = %v, want ErrUnreadable", err)
}
}
const merge = "89abcdef0123456789abcdef0123456789abcdef"
@@ -395,3 +414,50 @@ func TestTrackSaysWhichHeadOfWhichPullRequestWasWritten(t *testing.T) { // 1279/
t.Error("an open message was tracked")
}
}
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)})
}
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(tags)
return b, nil
}
return json.RawMessage(`[{"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(api, "mesh-sdk", ""); err == nil {
t.Error("a repository with no owner answered")
}
}
func slicesEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
@@ -0,0 +1,68 @@
package bridge
import (
"encoding/json"
"fmt"
"net/url"
"sort"
"strings"
)
// 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 the body.
type API func(path string) (json.RawMessage, error)
// TagsAnswer is what bridge_tags answers.
type TagsAnswer struct {
Repository string `json:"repository"`
Prefix string `json:"prefix,omitempty"`
Tags []string `json:"tags"`
// More says the read stopped at TagsMaxPages pages with more left.
More bool `json:"more,omitempty"`
}
// 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)
}
out := TagsAnswer{Repository: owner + "/" + name, Prefix: prefix, Tags: []string{}}
for page := 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))
if err != nil {
return TagsAnswer{}, fmt.Errorf("the forge did not answer the tags of %s (page %d): %w", out.Repository, page, err)
}
var tags []struct {
Name string `json:"name"`
}
if err := json.Unmarshal(raw, &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)
}
}
if len(tags) < TagsPageSize {
break
}
}
sort.Strings(out.Tags)
return out, nil
}
@@ -28,18 +28,20 @@ type state struct {
}
type artifact struct {
Name string `json:"name"`
Kind string `json:"kind"`
Language string `json:"language"`
System string `json:"system"`
From string `json:"from"`
Binary string `json:"binary"`
Name string `json:"name"`
Kind string `json:"kind"`
Language string `json:"language"`
System string `json:"system"`
From string `json:"from"`
Binary string `json:"binary"`
Loads []string `json:"loads"`
}
type manifest struct {
Module string `json:"module"`
Version string `json:"version"`
Slug string `json:"slug"`
Tools []string `json:"tools"`
Consumes []string `json:"consumes"`
Streams []stream `json:"streams"`
State []state `json:"state"`
@@ -87,13 +89,14 @@ func TestManifestDeclarations(t *testing.T) { // 1235/AC1
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.
if want := []string{"gitea.pull.closed", "gitea.pull.merged", "gitea.pull.updated"}; !reflect.DeepEqual(sorted(m.Consumes), want) {
// 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) {
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)
}
if want := []string{"gitea.gitea_add_comment", "gitea.gitea_commit_status", "gitea.gitea_commit_trees", "gitea.gitea_delivery_view",
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) {
t.Errorf("invokes = %v, want %v", m.Invokes, want)
}
@@ -110,8 +113,12 @@ func TestManifestDeclarations(t *testing.T) { // 1235/AC1
if !reflect.DeepEqual(m.State, []state{{Name: "heads", History: 1}, {Name: "performed", History: 1}, {Name: "inputs", History: 1}}) { // 1279/AC6, 1341, 1126
t.Errorf("state = %+v, want the buckets heads, performed and inputs", m.State)
}
// No other state, no bus, no seat, no tool, no event (ADR 0437 §4; ADR 0521: no check.status).
for _, k := range []string{"logs", "emits", "claims", "provides", "requires", "uses", "defines-seats", "tools", "seat", "seats"} {
// One tool, bridge_tags, served by its tools bundle, for the builder's floor (design 51).
if !reflect.DeepEqual(m.Tools, []string{"bridge_tags"}) {
t.Errorf("tools = %v, want bridge_tags", m.Tools)
}
// 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"} {
if _, ok := top[k]; ok {
t.Errorf("the manifest declares %q: the bridge has none", k)
}
@@ -143,8 +150,13 @@ func TestManifestProcess(t *testing.T) { // 1235/AC1
if _, bus := p["bus"]; bus {
t.Errorf("the process declares a bus: it runs on its own broker credential")
}
if len(m.Build.Artifacts) != 1 {
t.Fatalf("artifacts = %+v", m.Build.Artifacts)
if len(m.Build.Artifacts) != 2 {
t.Fatalf("artifacts = %+v, want the program and the tools", m.Build.Artifacts)
}
if a := m.Build.Artifacts[1]; a.Name != "tools" || a.Binary != "mesh-delivery-forge-bridge-tools" || !reflect.DeepEqual(a.Loads, []string{a.Binary}) {
t.Errorf("tools artifact = %+v", a)
} else if st, err := os.Stat(a.From); err != nil || !st.IsDir() {
t.Errorf("the tools' source %s: %v", a.From, err)
}
a := m.Build.Artifacts[0]
if st, err := os.Stat(a.From); err != nil || !st.IsDir() || a.Binary != "mesh-delivery-forge-bridge" || !strings.HasSuffix(a.From, a.Binary) {
+18 -2
View File
@@ -2,10 +2,14 @@
"module": "mesh-delivery-forge-bridge",
"version": "1",
"slug": "forge-bridge",
"tools": [
"bridge_tags"
],
"consumes": [
"gitea.pull.updated",
"gitea.pull.merged",
"gitea.pull.closed"
"gitea.pull.closed",
"gitea.forge.delete"
],
"streams": [
{ "name": "forge", "events": ["forge.**"] }
@@ -27,7 +31,8 @@
"gitea.gitea_add_comment",
"gitea.gitea_open_pulls",
"gitea.gitea_commit_trees",
"gitea.gitea_get_file"
"gitea.gitea_get_file",
"gitea.gitea_api"
],
"own-secrets": {
"broker": "${dir:state}/broker"
@@ -75,6 +80,17 @@
"system": "arch",
"from": "cmd/mesh-delivery-forge-bridge",
"binary": "mesh-delivery-forge-bridge"
},
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/mesh-delivery-forge-bridge-tools",
"binary": "mesh-delivery-forge-bridge-tools",
"loads": [
"mesh-delivery-forge-bridge-tools"
]
}
]
}