diff --git a/modules/mesh-config/cmd/mesh-config/main.go b/modules/mesh-config/cmd/mesh-config/main.go index b3848f0ca..fe2926b28 100644 --- a/modules/mesh-config/cmd/mesh-config/main.go +++ b/modules/mesh-config/cmd/mesh-config/main.go @@ -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 diff --git a/modules/mesh-config/cmd/mesh-config/pullforge_test.go b/modules/mesh-config/cmd/mesh-config/pullforge_test.go index e3d7fab70..e2418e65c 100644 --- a/modules/mesh-config/cmd/mesh-config/pullforge_test.go +++ b/modules/mesh-config/cmd/mesh-config/pullforge_test.go @@ -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") + } } diff --git a/modules/mesh-config/cmd/mesh-config/pulls.go b/modules/mesh-config/cmd/mesh-config/pulls.go index 6fda9171a..50f01da94 100644 --- a/modules/mesh-config/cmd/mesh-config/pulls.go +++ b/modules/mesh-config/cmd/mesh-config/pulls.go @@ -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 `/`, 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 diff --git a/modules/mesh-config/cmd/mesh-config/testdata/captured/README.md b/modules/mesh-config/cmd/mesh-config/testdata/captured/README.md deleted file mode 100644 index 6aa6b7020..000000000 --- a/modules/mesh-config/cmd/mesh-config/testdata/captured/README.md +++ /dev/null @@ -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`. diff --git a/modules/mesh-config/cmd/mesh-config/testdata/captured/get_pull_request_closed_merged_435.json b/modules/mesh-config/cmd/mesh-config/testdata/captured/get_pull_request_closed_merged_435.json deleted file mode 100644 index baff81a7f..000000000 --- a/modules/mesh-config/cmd/mesh-config/testdata/captured/get_pull_request_closed_merged_435.json +++ /dev/null @@ -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" - } -} diff --git a/modules/mesh-config/cmd/mesh-config/testdata/captured/get_pull_request_open_434.json b/modules/mesh-config/cmd/mesh-config/testdata/captured/get_pull_request_open_434.json deleted file mode 100644 index f9975771f..000000000 --- a/modules/mesh-config/cmd/mesh-config/testdata/captured/get_pull_request_open_434.json +++ /dev/null @@ -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" - } -} diff --git a/modules/mesh-config/module.json b/modules/mesh-config/module.json index b57f98b56..827c0f7cb 100644 --- a/modules/mesh-config/module.json +++ b/modules/mesh-config/module.json @@ -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", diff --git a/modules/records/README.md b/modules/records/README.md index 6fb679cd7..3a90323ea 100644 --- a/modules/records/README.md +++ b/modules/records/README.md @@ -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`. diff --git a/modules/records/cmd/records/main.go b/modules/records/cmd/records/main.go index 718120d66..18b2ceda7 100644 --- a/modules/records/cmd/records/main.go +++ b/modules/records/cmd/records/main.go @@ -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 `/` + // (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, `/`. +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 { diff --git a/modules/records/cmd/records/records_test.go b/modules/records/cmd/records/records_test.go index 2dab8135a..d4d13b4a2 100644 --- a/modules/records/cmd/records/records_test.go +++ b/modules/records/cmd/records/records_test.go @@ -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 `/`. +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) + } + } +} diff --git a/modules/records/module.json b/modules/records/module.json index 4a040a3b0..3e23fe06d 100644 --- a/modules/records/module.json +++ b/modules/records/module.json @@ -9,7 +9,7 @@ "git": "${dir:mesh-state}/git.json" }, "consumes": [ - "gitea.pull.merged" + "git.pull-merged" ], "tools": [ "records_search",