RDR6: mesh-config and records hear the seat git's pull-merged and pull-closed and ask seat git's pull

Builds: design 47 G §654
This commit is contained in:
2026-11-12 05:00:18 +01:00
parent f17001d920
commit 177115d6cd
11 changed files with 106 additions and 95 deletions
+4 -4
View File
@@ -251,17 +251,17 @@ func hearing(c *Config) {
time.Sleep(wait)
}
}()
for _, pattern := range []string{"gitea.pull.merged", "gitea.pull.closed", "gitea.forge.pull-request.closed"} {
for _, pattern := range []string{"git.pull-merged", "git.pull-closed"} {
go func(pattern string) {
<-stdio.Served() // subscribed as soon as the runtime serves, never after a guessed wait: an event sent meanwhile is heard
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
err := stdio.Subscribe(pattern, func(e stdio.Envelope) error {
var p PullEvent
if json.Unmarshal(e.Body, &p) != nil {
p, ok := pullEventOf(e.Body)
if !ok {
logf("[mesh-config] %s is not readable", pattern)
return nil
}
for _, s := range c.HeardPull(p, pattern == "gitea.pull.merged") {
for _, s := range c.HeardPull(p, pattern == "git.pull-merged") {
logf("[mesh-config] %s", s)
}
return nil
@@ -2,34 +2,53 @@ package main
import (
"encoding/json"
"os"
"testing"
)
// toolForge.Pull reads gitea_get_pull_request as the forge answers it (testdata/captured), bare or in the wrappings the
// runtime puts around a tool's answer.
func TestToolForgePullReadsTheCapturedAnswers(t *testing.T) {
// toolForge.Pull reads the seat git's answer of the verb pull (design 47 G §654), bare or in the wrappings the runtime puts
// around an answer; pullEventOf reads the seat's pull-merged and pull-closed bodies.
func TestToolForgePullReadsTheSeatGitAnswersAndEventsAreRead(t *testing.T) {
var asked struct {
key string
body map[string]any
}
for _, tc := range []struct {
file string
number int
answer string
open, merged bool
head string
}{
{"get_pull_request_closed_merged_435.json", 435, false, true, "7b5f7f84acb73aaf8189200548ff69fd9aac04b2"},
{"get_pull_request_open_434.json", 434, true, false, "c948f3a61c74257e6e3147effb396d9d6434f491"},
{`{"pull":{"repository":"novox/mesh-catalog","number":435,"state":"merged","head":"7b5f7f84acb73aaf8189200548ff69fd9aac04b2"}}`, false, true, "7b5f7f84acb73aaf8189200548ff69fd9aac04b2"},
{`{"pull":{"repository":"novox/mesh-catalog","number":434,"state":"open","head":"c948f3a61c74257e6e3147effb396d9d6434f491"}}`, true, false, "c948f3a61c74257e6e3147effb396d9d6434f491"},
{`{"pull":{"state":"closed","head":"c948f3a61c74257e6e3147effb396d9d6434f491"}}`, false, false, "c948f3a61c74257e6e3147effb396d9d6434f491"},
} {
raw, err := os.ReadFile("testdata/captured/" + tc.file)
if err != nil {
t.Fatal(err)
}
content, _ := json.Marshal(map[string]any{"content": []map[string]any{{"type": "text", "text": string(raw)}}})
ok, _ := json.Marshal(map[string]any{"ok": true, "output": "", "answer": json.RawMessage(raw)})
raw := json.RawMessage(tc.answer)
content, _ := json.Marshal(map[string]any{"content": []map[string]any{{"type": "text", "text": tc.answer}}})
ok, _ := json.Marshal(map[string]any{"ok": true, "output": "", "answer": raw})
for name, answer := range map[string]json.RawMessage{"bare": raw, "content list": content, "ok answer": ok} {
f := toolForge{ask: func(key string, body any) (json.RawMessage, error) { return answer, nil }}
open, merged, head, err := f.Pull("novox", "mesh-catalog", tc.number)
f := toolForge{ask: func(key string, body any) (json.RawMessage, error) {
asked.key, asked.body = key, body.(map[string]any)
return answer, nil
}}
open, merged, head, err := f.Pull("novox", "mesh-catalog", 434)
if err != nil || open != tc.open || merged != tc.merged || head != tc.head {
t.Errorf("%s (%s): open %v merged %v head %q, err %v", tc.file, name, open, merged, head, err)
t.Errorf("%s (%s): open %v merged %v head %q, err %v", tc.answer, name, open, merged, head, err)
}
if asked.key != "seat:git.pull" || asked.body["repository"] != "novox/mesh-catalog" || asked.body["number"] != 434 {
t.Errorf("asked %+v", asked)
}
}
}
failed := toolForge{ask: func(string, any) (json.RawMessage, error) {
return json.RawMessage(`{"error":"no such pull request","error-kind":"not-found"}`), nil
}}
if _, _, _, err := failed.Pull("novox", "mesh-catalog", 9); err == nil {
t.Error("an error answer was read as a pull request")
}
p, ok := pullEventOf([]byte(`{"repository":"novox/mesh-catalog","number":435,"branch":"fix/a","head":"7b5f7f84acb73aaf8189200548ff69fd9aac04b2","base":"main"}`))
if !ok || p.Owner != "novox" || p.Repo != "mesh-catalog" || p.Number != 435 || p.HeadSHA != "7b5f7f84acb73aaf8189200548ff69fd9aac04b2" {
t.Errorf("%+v %v", p, ok)
}
if _, ok := pullEventOf([]byte(`{"repository":"norepo","number":1}`)); ok {
t.Error("a repository with no owner was read")
}
}
+34 -17
View File
@@ -480,14 +480,29 @@ func (c *Config) settlePull(pull string, merged bool, head string) []string {
return said
}
// PullEvent is the forge's `pull.merged` or `pull.closed`, as far as this module reads it.
// PullEvent is a pull request merged or closed, as far as this module reads it: the seat `git`'s `pull-merged` or `pull-closed`
// (design 47 G §654), read by pullEventOf.
type PullEvent struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Number int `json:"number"`
Merged bool `json:"merged,omitempty"`
// HeadSHA is the head the pull request merged at (the forge's own word).
HeadSHA string `json:"head_sha,omitempty"`
Owner string
Repo string
Number int
Merged bool
// HeadSHA is the head the pull request merged at.
HeadSHA string
}
// pullEventOf reads the seat `git`'s `pull-merged` or `pull-closed` body: the repository `<owner>/<name>`, the number and the head.
func pullEventOf(body []byte) (PullEvent, bool) {
var e struct {
Repository string `json:"repository"`
Number int `json:"number"`
Head string `json:"head"`
}
if json.Unmarshal(body, &e) != nil {
return PullEvent{}, false
}
owner, repo, ok := strings.Cut(e.Repository, "/")
return PullEvent{Owner: owner, Repo: repo, Number: e.Number, HeadSHA: e.Head}, ok
}
func (p PullEvent) name() string { return fmt.Sprintf("%s/%s#%d", p.Owner, p.Repo, p.Number) }
@@ -568,30 +583,32 @@ type toolForge struct {
ask func(key string, body any) (json.RawMessage, error)
}
// Pull asks the forge's gitea_get_pull_request whether a pull request is open, and whether it merged.
// Pull asks the seat `git`'s verb `pull` whether a pull request is open, and whether it merged.
func (f toolForge) Pull(owner, repo string, number int) (bool, bool, string, error) {
raw, err := f.ask("gitea.gitea_get_pull_request", map[string]any{"owner": owner, "repo": repo, "number": number})
raw, err := f.ask(GitPullAsk, map[string]any{"repository": owner + "/" + repo, "number": number})
if err != nil {
return false, false, "", err
}
answer, output, ok := unwrapAnswer(raw)
if !ok {
return false, false, "", fmt.Errorf("the forge refused gitea_get_pull_request: %s", output)
return false, false, "", fmt.Errorf("the seat git refused pull: %s", output)
}
var a struct {
Pull struct {
State string `json:"state"`
Merged bool `json:"merged"`
// head is the branch name; the commit is head_sha (the forge module's mapPull).
HeadSHA string `json:"head_sha"`
State string `json:"state"`
Head string `json:"head"`
} `json:"pull"`
Error string `json:"error"`
}
if err := json.Unmarshal(answer, &a); err != nil || a.Pull.State == "" {
return false, false, "", fmt.Errorf("the forge's gitea_get_pull_request is not readable: %s", output)
if err := json.Unmarshal(answer, &a); err != nil || a.Error != "" || a.Pull.State == "" {
return false, false, "", fmt.Errorf("the seat git's pull is not readable: %s", output)
}
return a.Pull.State == "open", a.Pull.Merged, a.Pull.HeadSHA, nil
return a.Pull.State == "open", a.Pull.State == "merged", a.Pull.Head, nil
}
// GitPullAsk is the seat `git`'s verb that answers one pull request.
const GitPullAsk = "seat:git.pull"
// unwrapAnswer reads a tool's answer whatever wraps it: the protocol's content list, or {output, ok, answer}.
func unwrapAnswer(raw json.RawMessage) (json.RawMessage, string, bool) {
var s string
@@ -1,10 +0,0 @@
Captured answers of the gitea module's `gitea_get_pull_request`, taken 2026-10-22 by fix-1826-catalog@shanks with
`mesh_call gitea.gitea_get_pull_request {"owner":"novox","repo":"mesh-catalog","number":N}` through the mesh MCP server.
- get_pull_request_closed_merged_435.json: pull request 435, closed and merged.
- get_pull_request_open_434.json: pull request 434, open.
Each file is the answer as the MCP server printed it. The runtime's own wrapping of the answer was not visible there
(the server prints the answer alone), so the tests also feed the files through the wrappings unwrapAnswer reads.
The `body` text is shortened to its first sentence and its Reviewed-by line (long prose of no interest here); every
other field is as returned. Note: `head` is the branch name, the commit is `head_sha`.
@@ -1,17 +0,0 @@
{
"pull": {
"base": "main",
"body": "Part of design 47's milestone R4c-9 (ADR 0487): the image build module. A container image becomes one more artifact kind the builder builds through a build module, like Go and Node: mesh-build-image builds rootless with buildah, a candidate's check publishes nothing, a trunk build publishes exactly one digest and its build key names the reference. build-agent keeps building and delivering every image until R4g; nothing the operator sees changes. One delivery group on branch `feat/r4c9-image-build` in mesh-catalog and mesh-delivery. After the delivery the lead assigns buildah and mesh-build-image to shanks and ace.\n\nReviewed-by: reviewer-r4c9@shanks at 7b5f7f84acb73aaf8189200548ff69fd9aac04b2",
"head": "feat/r4c9-image-build",
"head_sha": "7b5f7f84acb73aaf8189200548ff69fd9aac04b2",
"html_url": "http://127.0.0.1:20000/novox/mesh-catalog/pulls/435",
"merge_commit_sha": "5a6d082e8c55603e47f074deb96da80fc9240ab4",
"merged": true,
"merged_at": "2026-10-22T08:57:10Z",
"number": 435,
"state": "closed",
"title": "Add the image build module: buildah and mesh-build-image, rootless, publishing only from the trunk (issues 1702, 1703, 1704, 1707)",
"updated_at": "2026-10-22T08:57:10Z",
"user": "mesh-tools"
}
}
@@ -1,15 +0,0 @@
{
"pull": {
"base": "main",
"body": "Part of design 47's milestone R4d: n8n and the bus run pinned upstream images (ADR 0480).\n\nReviewed-by: reviewer-r4d@shanks at c948f3a61c74257e6e3147effb396d9d6434f491\n",
"head": "feat/r4d",
"head_sha": "c948f3a61c74257e6e3147effb396d9d6434f491",
"html_url": "http://127.0.0.1:20000/novox/mesh-catalog/pulls/434",
"merged": false,
"number": 434,
"state": "open",
"title": "n8n and the bus run their upstream images with the mesh's programs mounted (issues 1639, 1640, 1641, 1642, ADR 0480 §1-§3, §5-§8; ADR 0497 §5)",
"updated_at": "2026-10-22T08:57:45Z",
"user": "mesh-tools"
}
}
+3 -4
View File
@@ -19,9 +19,8 @@
"operator-channel"
],
"consumes": [
"gitea.pull.merged",
"gitea.pull.closed",
"gitea.forge.pull-request.closed",
"git.pull-merged",
"git.pull-closed",
"mesh-delivery-view.delivered",
"node-engine.manifest-reads-changed",
"mesh-catalog.manifest-accepted",
@@ -37,7 +36,7 @@
"invokes": [
"seat:mesh-hand-acts.hand-act",
"seat:mesh-hand-acts.warranted",
"gitea.gitea_get_pull_request",
"seat:git.pull",
"mesh-delivery-view.node_haves",
"seat:node-engine.manifest-reads",
"mesh-delivery-indexer.indexer_config_readers",
+1 -1
View File
@@ -8,7 +8,7 @@ whoever holds the console sees `records_search` beside every other tool and a sy
in the design record without knowing it is there.
**A checkout, not a copy.** The same bytes the repository holds, at a commit every answer names,
brought up to date on every merge the forge announces (`gitea.pull.merged`) and every ten minutes
brought up to date on every merge the forge announces (`git.pull-merged`) and every ten minutes
besides. Nothing is indexed, transformed or summarised, so nothing can drift from the source except by
lagging behind it, and the lag is in `records_status`.
+12 -9
View File
@@ -109,16 +109,11 @@ func keep(r *Records) {
}
}()
// A merge on the forge into the repository this reads: pull now. The event names the repository by
// owner and name (gitea's `pull.merged`); anything else is somebody else's merge.
// A merge into the repository this reads: pull now. The seat `git`'s `pull-merged` names the repository `<owner>/<name>`
// (design 47 G §654); anything else is somebody else's merge.
subscribe := func() error {
return stdio.Subscribe("gitea.pull.merged", func(e stdio.Envelope) error {
var body struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
}
_ = json.Unmarshal(e.Body, &body)
if body.Owner+"/"+body.Repo != r.Repository {
return stdio.Subscribe("git.pull-merged", func(e stdio.Envelope) error {
if !mergedInto(e.Body, r.Repository) {
return nil
}
fmt.Fprintf(os.Stderr, "[records] %s merged; syncing\n", r.Repository)
@@ -136,6 +131,14 @@ func keep(r *Records) {
}
}
// mergedInto is whether a `pull-merged` body names this repository, `<owner>/<name>`.
func mergedInto(body []byte, repository string) bool {
var e struct {
Repository string `json:"repository"`
}
return json.Unmarshal(body, &e) == nil && e.Repository != "" && e.Repository == repository
}
func main() {
r, err := FromEnv(os.Getenv)
if err != nil {
@@ -187,3 +187,18 @@ func TestRecordsDecisionsNamesTheKnowledgeBaseAndReadsNothingOfHq(t *testing.T)
t.Errorf("its description does not say where the decision records are: %s", decisions.Description)
}
}
// Design 47 G §654: a merge is heard from the seat git's pull-merged, which names the repository `<owner>/<name>`.
func TestAMergeIntoThisRepositoryIsRecognisedFromTheSeatGitEvent(t *testing.T) {
for body, want := range map[string]bool{
`{"repository":"novox/hq","number":3}`: true,
`{"repository":"novox/mesh-catalog"}`: false,
`{"owner":"novox","repo":"hq"}`: false,
`not json`: false,
`{"repository":""}`: false,
} {
if got := mergedInto([]byte(body), "novox/hq"); got != want {
t.Errorf("%s: %v, want %v", body, got, want)
}
}
}
+1 -1
View File
@@ -9,7 +9,7 @@
"git": "${dir:mesh-state}/git.json"
},
"consumes": [
"gitea.pull.merged"
"git.pull-merged"
],
"tools": [
"records_search",