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:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
Vendored
-17
@@ -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"
|
||||
}
|
||||
}
|
||||
-15
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"git": "${dir:mesh-state}/git.json"
|
||||
},
|
||||
"consumes": [
|
||||
"gitea.pull.merged"
|
||||
"git.pull-merged"
|
||||
],
|
||||
"tools": [
|
||||
"records_search",
|
||||
|
||||
Reference in New Issue
Block a user