The gate reads and hears the forge through the seat git (milestone 47/G, W11.18)

The gate named the forge module's tools and events and guessed their answers' shape (operator (d), F14).
Its reads now are the seat git's verbs pull, statuses, open-pulls and contains, typed in the seat's
contract, a failed read an error and never none; its forge events are the seat's pull.updated,
pull.merged, pull.closed, forge.pull, forge.status and repo.writable, read into the gate's own forms
in one place (gitseat.go). What the forge would merge now (the base's protection, its conflict word,
whether the merging account writes) has no verb of the seat yet: it stays the one read of the
forge's statuses tool, named as such (mergeStateTool), until the seat carries it.

The bus tests' runtime now serves the gate's ledger, so the two whole-bundle tests that failed
since W11.1 pass again.

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ

(Joined from 14338514.)
Builds: design 47 G §252, §310, §312, test 51 (GATE14)

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
This commit is contained in:
2026-11-11 14:35:58 +01:00
parent 11daf2f289
commit 30a13c4ebb
22 changed files with 545 additions and 145 deletions
@@ -3,6 +3,10 @@
package main
import (
"git.novox.be/novox/mesh-sdk/go/buscalls"
gitseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/git"
"bufio"
"context"
"encoding/json"
@@ -95,7 +99,7 @@ func newTestBus(t *testing.T) *testBus {
}
// The streams the events are on (mesh.mod.<module>.event.<name>, mesh.seat.<seat>.event.<name>).
if _, err := js.CreateStream(context.Background(), jetstream.StreamConfig{Name: "EVENTS",
Subjects: []string{"mesh.mod.gitea.event.>", "mesh.seat." + oc.Seat + ".event.>",
Subjects: []string{"mesh.seat.git.event.>", "mesh.seat." + oc.Seat + ".event.>",
"mesh.mod." + forgeBridgeModule + ".event.>"}}); err != nil {
t.Fatal(err)
}
@@ -112,6 +116,8 @@ type runtime struct {
next int
wait map[string]chan json.RawMessage
// the gate's ledger, as the tool runner keeps it.
ledger *ledgerFake
// what the bundle subscribed to, and asked of the mesh.
subs []string
asked []askedOfMesh
@@ -345,9 +351,27 @@ func (r *runtime) answer(m rpcMessage) {
switch a.Key {
case "":
return nil, errors.New("an ask with no key")
case "gitea.gitea_open_pulls":
return toolAnswer(map[string]any{"pulls": pulls}), nil
case "gitea.gitea_commit_statuses":
case "seat:git.open-pulls":
var open []gitseat.OpenPull
for _, p := range pulls {
open = append(open, seatOpenPull(p))
}
return gitseat.OpenPullsAnswer{Pulls: open}, nil
case "seat:git.pull":
number, _ := a.Body["number"].(float64)
repository, _ := a.Body["repository"].(string)
for _, p := range pulls {
if p.Owner+"/"+p.Repo == repository && p.Number == int(number) {
return gitseat.PullAnswer{Pull: &gitseat.Pull{Repository: repository, Number: int64(p.Number), Base: p.Base,
Head: p.HeadSHA, State: gitseat.PullStateOpen}}, nil
}
}
return gitseat.PullAnswer{Error: "no such pull request"}, nil
case "seat:git.statuses":
// Green: mesh/check a success at the head asked, as the seat answers.
commit, _ := a.Body["commit"].(string)
return gitseat.StatusesAnswer{Commit: commit, Statuses: []gitseat.Status{{Context: "mesh/check", State: "success"}}}, nil
case mergeStateTool:
// Green: mesh/check a success at the head asked, as the gitea module answers.
sha, _ := a.Body["sha"].(string)
if sha == "" {
@@ -367,6 +391,29 @@ func (r *runtime) answer(m rpcMessage) {
}
return nil, fmt.Errorf("the runtime answers no ask %s in this test", a.Key)
}
switch m.Method {
case buscalls.LedgerApply, buscalls.LedgerGet:
// The gate's ledger `records` (W11.1), as the tool runner keeps it: the record test's ledger in memory.
r.mu.Lock()
defer r.mu.Unlock()
if r.ledger == nil {
r.ledger = newLedgerFake(r.t)
}
if m.Method == buscalls.LedgerGet {
var in buscalls.InstanceRequest
if err := json.Unmarshal(m.Params, &in); err != nil {
return nil, err
}
state, seq, err := r.ledger.Get(in.Instance)
return buscalls.InstanceState{Instance: in.Instance, State: state, Sequence: seq}, err
}
var in buscalls.ApplyRequest
if err := json.Unmarshal(m.Params, &in); err != nil {
return nil, err
}
seq, err := r.ledger.Apply(in.Instance, in.Operation, in.Body)
return buscalls.Applied{Taken: err == nil, Sequence: seq}, err
}
kv := r.b.kvs[p.State]
if kv == nil {
return nil, fmt.Errorf("the state %q is not declared", p.State)
@@ -518,7 +565,7 @@ func TestAPassingVerdictAsksTheApproveAndItsApproveMergesWithinSecondsOverARealB
announced.BaseSHA = "a1a1a1a1a1a1"
announced.Title = "Fix the apostrophe (issue 975)"
announced.Body = "The description, with no review line yet."
b.publish(t, "mesh.mod.gitea.event.pull.updated", announced)
b.publish(t, "mesh.seat.git.event.pull.updated", seatUpdated(announced))
rt.until("the pull request's key to hold its head", eventWithin, func() bool {
e, err := b.kvs["pulls"].Get(ctx, "novox.app.7")
var k PullKey
@@ -576,7 +623,7 @@ func TestAPassingVerdictAsksTheApproveAndItsApproveMergesWithinSecondsOverARealB
b.forgeSays(t, forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: "novox/app", Number: 7, Head: head, Merge: strings.Repeat("b", 40),
Root: "r", Trees: map[string]string{"app": "t"}})
// The forge's own word that the pull request merged follows the bridge's (its webhook): the delivery ends merged.
b.publish(t, "mesh.mod.gitea.event.pull.merged", merged("novox/app", 7, head, strings.Repeat("b", 40)))
b.publish(t, "mesh.seat.git.event.pull.merged", seatMerged(merged("novox/app", 7, head, strings.Repeat("b", 40))))
rt.until("source.main to hold the merge", eventWithin, func() bool {
e, err := b.kvs["source"].Get(ctx, mdc.SourceMainKey)
if err != nil {
@@ -704,7 +751,7 @@ func TestAGroupOfTwoWithGreenChecksGetsOneAskAndOnItsApproveBothMergeOneAfterThe
pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json")} {
p.BaseSHA = "a1a1a1a1a1a1"
p.Body = "Reviewed-by: lead@shanks at " + p.HeadSHA
b.publish(t, "mesh.mod.gitea.event.pull.updated", p)
b.publish(t, "mesh.seat.git.event.pull.updated", seatUpdated(p))
}
// Both members pass at the checker, whose one verdict covers both heads: the group is asked once, no composed check is asked
// of the controller (issue 1318).
@@ -761,8 +808,8 @@ func TestAGroupOfTwoWithGreenChecksGetsOneAskAndOnItsApproveBothMergeOneAfterThe
// The forge's own word that each pull request merged follows the bridge's (its webhook), after the second was handed (the
// checker judges the members still open by the trunk the first merge moved, which this test does not play): both members
// end merged, the group with them, and the controller was never called (R6-C3).
b.publish(t, "mesh.mod.gitea.event.pull.merged", merged(one.Repository, one.Number, one.Head.Head, strings.Repeat("a", 40)))
b.publish(t, "mesh.mod.gitea.event.pull.merged", merged(two.Repository, two.Number, two.Head.Head, strings.Repeat("b", 40)))
b.publish(t, "mesh.seat.git.event.pull.merged", seatMerged(merged(one.Repository, one.Number, one.Head.Head, strings.Repeat("a", 40))))
b.publish(t, "mesh.seat.git.event.pull.merged", seatMerged(merged(two.Repository, two.Number, two.Head.Head, strings.Repeat("b", 40))))
for _, id := range []string{IDOf("novox/mesh-controller", ctlHead), IDOf("novox/mesh-catalog", catHead)} {
id := id
rt.until(id+" to be merged", eventWithin, func() bool {
@@ -3,6 +3,8 @@
package main
import (
gitseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/git"
"context"
"encoding/json"
"testing"
@@ -52,16 +54,7 @@ func (b *testBus) checkPasses(t *testing.T, rt *runtime, group, trunk string, pu
Required: []string{ctxCheck}, Statuses: []string{ctxCheck}, Checks: map[string]HeadCheck{ctxCheck: {State: "success", Description: line}}})
}
rt.mu.Unlock()
b.publish(t, "mesh.mod.gitea.event.forge.status", map[string]any{"owner": owner, "repo": repo, "sha": p.head,
"context": ctxCheck, "state": "success", "description": line})
b.publish(t, "mesh.seat.git.event.forge.status", gitseat.ForgeStatusEvent{Repository: owner + "/" + repo, Commit: p.head,
Context: ctxCheck, State: "success", Description: line})
}
}
func splitRepository(r string) (owner, repo string) {
for i := range r {
if r[i] == '/' {
return r[:i], r[i+1:]
}
}
return "", r
}
@@ -19,7 +19,7 @@ type ForgeStatusEvent struct {
SHA string `json:"sha"`
Context string `json:"context"`
State string `json:"state"`
// Description is the status's words (the gitea module's status event carries them since issue 1302): mesh/check's
// Description is the status's words (the seat git's forge.status carries them, issue 1302): mesh/check's
// line names the head and the trunk it judged.
Description string `json:"description,omitempty"`
}
@@ -1,6 +1,8 @@
package main
import (
gitseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/git"
"encoding/json"
"strings"
"testing"
@@ -55,7 +57,7 @@ func TestAPassingVerdictAsksTheOperatorAtOnceWithNoMark(t *testing.T) {
t.Fatalf("something was written on the forge to ask: %v", w.forge.calls)
}
// Asked once for the head, however often it is looked at again.
hear(t, w, "gitea.forge.status", ForgeStatusEvent{Owner: "novox", Repo: "app", SHA: readyHead, Context: "mesh/merge-gate", State: "success"})
hear(t, w, gitEvent(gitseat.EventForgeStatus), gitseat.ForgeStatusEvent{Repository: "novox/app", Commit: readyHead, Context: "mesh/merge-gate", State: "success"})
if w.op.count() != 1 {
t.Fatalf("asked %d times for one head", w.op.count())
}
@@ -1,6 +1,8 @@
package main
import (
gitseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/git"
"encoding/json"
"errors"
"fmt"
@@ -373,6 +375,45 @@ func pr(repo string, number int, sha, branch string, paths ...string) PullEvent
HTMLURL: fmt.Sprintf("https://forge.invalid/%s/pulls/%d", repo, number), Paths: paths}
}
// seatUpdated is a pull request's new head as the seat git says it (pull.updated).
func seatUpdated(p PullEvent) gitseat.PullUpdatedEvent {
e := gitseat.PullUpdatedEvent{Repository: p.Owner + "/" + p.Repo, Number: int64(p.Number), Base: p.Base, Branch: p.Head,
Head: p.HeadSHA, URL: p.HTMLURL, Title: p.Title, BaseHead: p.BaseSHA, Author: p.Author}
if p.Paths != nil {
e.Change = &gitseat.Change{Paths: p.Paths, PathsTruncated: p.PathsTruncated, Removed: p.Removed, ModuleDirs: p.ModuleDirs}
}
return e
}
// seatMerged is a pull request merged as the seat git says it (pull.merged).
func seatMerged(p PullEvent) gitseat.PullMergedEvent {
e := gitseat.PullMergedEvent{Repository: p.Owner + "/" + p.Repo, Number: int64(p.Number), Base: p.Base, Branch: p.Head,
Head: p.HeadSHA, MergeCommit: p.MergeCommit, URL: p.HTMLURL, HeadChecks: p.HeadChecks}
if at, err := time.Parse(time.RFC3339, p.MergedAt); err == nil {
e.MergedAt = &at
}
if p.Paths != nil {
e.Change = &gitseat.Change{Paths: p.Paths}
}
return e
}
// seatOpenPull is an open pull request as the seat git's open-pulls answers it.
func seatOpenPull(p OpenPull) gitseat.OpenPull {
o := gitseat.OpenPull{Pull: gitseat.Pull{Repository: p.Owner + "/" + p.Repo, Number: int64(p.Number), Base: p.Base,
Head: p.HeadSHA, URL: p.HTMLURL, State: gitseat.PullStateOpen}, Required: p.Required, Checks: map[string]gitseat.Status{}}
for context, c := range p.Checks {
o.Checks[context] = gitseat.Status{Context: context, State: c.State, Description: c.Description}
}
return o
}
// seatClosed is a pull request closed as the seat git says it (pull.closed).
func seatClosed(p PullEvent) gitseat.PullClosedEvent {
return gitseat.PullClosedEvent{Repository: p.Owner + "/" + p.Repo, Number: int64(p.Number), Base: p.Base, Branch: p.Head,
Head: p.HeadSHA}
}
// verdict is the controller's `checked` for a head.
func verdict(repo string, number int, sha, gate string) CheckedEvent {
owner, name, _ := strings.Cut(repo, "/")
@@ -0,0 +1,153 @@
package main
import (
"encoding/json"
"fmt"
"slices"
"strings"
"time"
gitseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/git"
)
// The forge as the seat `git` says it (design 47 *The seat git*): its events heard as the seat's, by the key the runtime
// hands them under (`git.<event>`), each read into the gate's own forms here and nowhere else.
// gitEvent is the key a seat `git` event arrives under.
func gitEvent(event string) string { return gitseat.Seat + "." + event }
// gitEvents are the seat's events the gate hears.
var gitEvents = []string{
gitEvent(gitseat.EventPullUpdated), gitEvent(gitseat.EventPullMerged), gitEvent(gitseat.EventPullClosed),
gitEvent(gitseat.EventForgePull), gitEvent(gitseat.EventForgeStatus), gitEvent(gitseat.EventRepoWritable),
}
// splitRepository is a repository's owner and name.
func splitRepository(repository string) (owner, repo string) {
owner, repo, _ = strings.Cut(repository, "/")
return owner, repo
}
// timeText is a time as the gate keeps it, RFC 3339; empty when not said.
func timeText(t *time.Time) string {
if t == nil || t.IsZero() {
return ""
}
return t.UTC().Format(time.RFC3339)
}
// withChange carries a pull request's changed paths into the gate's form; a list of module directories absent is one the
// forge could not read.
func withChange(p *PullEvent, c *gitseat.Change) {
if c == nil {
return
}
p.Paths, p.PathsTruncated, p.Removed = c.Paths, c.PathsTruncated, c.Removed
p.ModuleDirs, p.ModuleDirsSaid = c.ModuleDirs, c.ModuleDirs != nil
}
// gitPullEvent reads a seat `git` event of a pull request into the gate's PullEvent. The event's head is its commit and its
// branch the head branch, as the gate's Head and HeadSHA name them.
func gitPullEvent(key string, body json.RawMessage) (PullEvent, error) {
var p PullEvent
switch key {
case gitEvent(gitseat.EventPullUpdated):
var e gitseat.PullUpdatedEvent
if err := json.Unmarshal(body, &e); err != nil {
return p, err
}
p = PullEvent{Number: int(e.Number), Title: e.Title, Base: e.Base, Head: e.Branch, HeadSHA: e.Head, HTMLURL: e.URL,
BaseSHA: e.BaseHead, Author: e.Author}
p.Owner, p.Repo = splitRepository(e.Repository)
withChange(&p, e.Change)
case gitEvent(gitseat.EventPullMerged):
var e gitseat.PullMergedEvent
if err := json.Unmarshal(body, &e); err != nil {
return p, err
}
p = PullEvent{Number: int(e.Number), Title: e.Title, Base: e.Base, Head: e.Branch, HeadSHA: e.Head, HTMLURL: e.URL,
MergeCommit: e.MergeCommit, MergedAt: timeText(e.MergedAt), HeadChecks: e.HeadChecks, Merged: true, Author: e.Author}
p.Owner, p.Repo = splitRepository(e.Repository)
withChange(&p, e.Change)
case gitEvent(gitseat.EventPullClosed):
var e gitseat.PullClosedEvent
if err := json.Unmarshal(body, &e); err != nil {
return p, err
}
p = PullEvent{Number: int(e.Number), Title: e.Title, Base: e.Base, Head: e.Branch, HeadSHA: e.Head, HTMLURL: e.URL,
Author: e.Author}
p.Owner, p.Repo = splitRepository(e.Repository)
case gitEvent(gitseat.EventForgePull):
var e gitseat.ForgePullEvent
if err := json.Unmarshal(body, &e); err != nil {
return p, err
}
p = PullEvent{Number: int(e.Number), Base: e.Base, Head: e.Branch, HeadSHA: e.Head, MergeCommit: e.MergeCommit,
Merged: e.Merged, Author: e.Author}
p.Owner, p.Repo = splitRepository(e.Repository)
default:
return p, fmt.Errorf("%s is no pull request event of the seat git", key)
}
if p.Owner == "" || p.Repo == "" {
return p, fmt.Errorf("names no repository <owner>/<name>")
}
return p, nil
}
// forgeAction is the forge's own word in a `forge.pull` event: what it did to the pull request.
func forgeAction(body json.RawMessage) string {
var e struct {
Action string `json:"action"`
}
_ = json.Unmarshal(body, &e)
return e.Action
}
// gitStatusEvent reads the seat's `forge.status` into the gate's ForgeStatusEvent.
func gitStatusEvent(body json.RawMessage) (ForgeStatusEvent, error) {
var e gitseat.ForgeStatusEvent
if err := json.Unmarshal(body, &e); err != nil {
return ForgeStatusEvent{}, err
}
s := ForgeStatusEvent{SHA: e.Commit, Context: e.Context, State: e.State, Description: e.Description}
s.Owner, s.Repo = splitRepository(e.Repository)
if s.Owner == "" || s.Repo == "" || s.SHA == "" {
return s, fmt.Errorf("names no repository <owner>/<name> or no commit")
}
return s, nil
}
// forgePullOf is the seat's pull request in the gate's form: a merged one is closed and merged, as the forge said it before.
func forgePullOf(p gitseat.Pull) *ForgePull {
out := &ForgePull{Number: int(p.Number), Title: p.Title, State: p.State, Base: p.Base, Head: p.Branch, HeadSHA: p.Head,
MergeCommit: p.MergeCommit, MergedAt: timeText(p.MergedAt), HTMLURL: p.URL}
if p.State == gitseat.PullStateMerged {
out.State, out.Merged = gitseat.PullStateClosed, true
}
return out
}
// forgeStatusOf is one status of the seat in the gate's form.
func forgeStatusOf(s gitseat.Status) ForgeStatus {
return ForgeStatus{Context: s.Context, State: s.State, Description: s.Description, TargetURL: s.Target,
UpdatedAt: timeText(s.At), Creator: s.By}
}
// openPullOf is one of the seat's open pull requests in the gate's form.
func openPullOf(p gitseat.OpenPull) OpenPull {
out := OpenPull{Number: int(p.Pull.Number), Base: p.Pull.Base, HeadSHA: p.Pull.Head, HTMLURL: p.Pull.URL,
Required: p.Required, Statuses: []string{}}
out.Owner, out.Repo = splitRepository(p.Pull.Repository)
if out.Required == nil {
out.Required = []string{}
}
if len(p.Checks) > 0 {
out.Checks = map[string]HeadCheck{}
}
for context, s := range p.Checks {
out.Statuses = append(out.Statuses, context)
out.Checks[context] = HeadCheck{State: s.State, Description: s.Description, UpdatedAt: timeText(s.At)}
}
slices.Sort(out.Statuses)
return out
}
@@ -0,0 +1,104 @@
package main
import (
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
gitseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/git"
)
// W11.18 (design 47 *The seat git*): every forge read of the gate is a verb of the seat git, its answer typed in the seat's
// contract, and a failed read is an error, never "none"; every forge event is heard as the seat's.
func TestTheGateReadsAndHearsTheForgeThroughTheSeatGit(t *testing.T) {
head := strings.Repeat("c", 40)
pull := gitseat.Pull{Repository: "novox/app", Number: 7, Base: "main", Branch: "feat/x", Head: head, State: "merged",
MergeCommit: strings.Repeat("d", 40), URL: "https://forge.invalid/novox/app/pulls/7"}
answers := map[string]any{
"seat:git.open-pulls": gitseat.OpenPullsAnswer{Pulls: []gitseat.OpenPull{{Pull: pull, Required: []string{"mesh/check"},
Checks: map[string]gitseat.Status{"mesh/check": {Context: "mesh/check", State: "pending"}}}}},
"seat:git.contains": gitseat.ContainsAnswer{Contains: true},
"seat:git.pull": gitseat.PullAnswer{Pull: &pull},
"seat:git.statuses": gitseat.StatusesAnswer{Commit: head, Combined: "pending",
Statuses: []gitseat.Status{{Context: "mesh/check", State: "pending", By: "mesh"}}},
mergeStateTool: map[string]any{"commit": head, "merge": map[string]any{"branch": "main", "mergeable": true}},
}
for _, c := range []struct {
name string
failed string // the verb whose answer is an error
read func(seatForge) (any, error)
want any
asked []string
}{
{name: "open pull requests", read: func(f seatForge) (any, error) { return f.OpenPulls() },
want: []OpenPull{{Owner: "novox", Repo: "app", Number: 7, Base: "main", HeadSHA: head, HTMLURL: pull.URL,
Required: []string{"mesh/check"}, Statuses: []string{"mesh/check"},
Checks: map[string]HeadCheck{"mesh/check": {State: "pending"}}}},
asked: []string{"seat:git.open-pulls"}},
{name: "open pull requests not read", failed: "seat:git.open-pulls",
read: func(f seatForge) (any, error) { return f.OpenPulls() }, asked: []string{"seat:git.open-pulls"}},
{name: "contains", read: func(f seatForge) (any, error) { return f.Contains("novox", "app", "a", "b") }, want: true,
asked: []string{"seat:git.contains"}},
{name: "contains not read", failed: "seat:git.contains",
read: func(f seatForge) (any, error) { return f.Contains("novox", "app", "a", "b") }, asked: []string{"seat:git.contains"}},
{name: "a pull request by its number", read: func(f seatForge) (any, error) {
c, err := f.Statuses("novox", "app", 7, "", "")
if err != nil {
return nil, err
}
return []any{c.Commit, *c.Pull, c.Statuses, *c.Merge.Mergeable}, nil
}, want: []any{head, ForgePull{Number: 7, State: "closed", Merged: true, Base: "main", Head: "feat/x", HeadSHA: head,
MergeCommit: pull.MergeCommit, HTMLURL: pull.URL}, []ForgeStatus{{Context: "mesh/check", State: "pending", Creator: "mesh"}}, true},
asked: []string{"seat:git.pull", "seat:git.statuses", mergeStateTool}},
{name: "a pull request not read", failed: "seat:git.pull",
read: func(f seatForge) (any, error) { return f.Statuses("novox", "app", 7, "", "") },
asked: []string{"seat:git.pull"}},
{name: "statuses not read", failed: "seat:git.statuses",
read: func(f seatForge) (any, error) { return f.Statuses("novox", "app", 0, head, "main") },
asked: []string{"seat:git.statuses"}},
} {
t.Run(c.name, func(t *testing.T) {
var asked []string
f := seatForge{ask: func(key string, body any) (json.RawMessage, error) {
asked = append(asked, key)
if key == c.failed {
return json.Marshal(map[string]string{"error": "the forge answered 502"})
}
a, ok := answers[key]
if !ok {
return nil, errors.New("no such verb: " + key)
}
return json.Marshal(a)
}}
got, err := c.read(f)
if !reflect.DeepEqual(asked, c.asked) {
t.Fatalf("asked %v, wanted %v", asked, c.asked)
}
if c.failed != "" {
if err == nil || !strings.Contains(err.Error(), "the forge answered 502") {
t.Fatalf("a failed read answered %v, %v; wanted its error", got, err)
}
return
}
if err != nil || !reflect.DeepEqual(got, c.want) {
t.Fatalf("read %#v, %v; wanted %#v", got, err, c.want)
}
})
}
t.Run("a merge heard", func(t *testing.T) {
body, _ := json.Marshal(gitseat.PullMergedEvent{Repository: "novox/app", Number: 7, Base: "main", Branch: "feat/x",
Head: head, MergeCommit: pull.MergeCommit, Author: "jo", Change: &gitseat.Change{Paths: []string{"a.go"}}})
p, err := gitPullEvent(gitEvent(gitseat.EventPullMerged), body)
want := PullEvent{Owner: "novox", Repo: "app", Number: 7, Base: "main", Head: "feat/x", HeadSHA: head,
MergeCommit: pull.MergeCommit, Merged: true, Author: "jo", Paths: []string{"a.go"}}
if err != nil || !reflect.DeepEqual(p, want) {
t.Fatalf("heard %+v, %v; wanted %+v", p, err, want)
}
if _, err := gitPullEvent(gitEvent(gitseat.EventPullMerged), []byte(`{"number":7}`)); err == nil {
t.Fatal("a merge naming no repository was taken")
}
})
}
@@ -313,9 +313,11 @@ type PullEvent struct {
HeadChecks map[string]string `json:"head_checks,omitempty"`
// BaseSHA is the base branch's head when the head was announced (novox/hq ADR 0323).
BaseSHA string `json:"base_sha,omitempty"`
// Merged is the forge's own events' word that the pull request merged (gitea.forge.pull-request.*): its close is no
// Merged is the forge's own events' word that the pull request merged (the seat git's forge.pull): its close is no
// close unmerged (issue 1107).
Merged bool `json:"merged,omitempty"`
// Author is who opened it, as the forge's pull request record says (the seat git's events).
Author string `json:"author,omitempty"`
}
func (p PullEvent) repository() string { return strings.ToLower(p.Owner + "/" + p.Repo) }
@@ -1,6 +1,8 @@
package main
import (
gitseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/git"
"encoding/json"
"testing"
)
@@ -9,8 +11,8 @@ import (
// (gitea.forge.pull-request.closed), not only from the poll, which skips a repository whose update time did not move.
// Its delivery leaves its group and stops; a merged one is ignored (the webhook says merged for it); a close heard
// twice, from the event and the poll, changes nothing the second time.
func forgeClosed(owner, repo string, number int, merged bool) map[string]any {
return map[string]any{"owner": owner, "repo": repo, "number": number, "merged": merged}
func forgeClosed(owner, repo string, number int, merged bool) gitseat.ForgePullEvent {
return gitseat.ForgePullEvent{Action: "closed", Repository: owner + "/" + repo, Number: int64(number), Merged: merged}
}
func TestAForgeClosedEventTakesAGroupMembersDeliveryOutOfItsGroupAndStopsIt(t *testing.T) {
@@ -18,7 +20,7 @@ func TestAForgeClosedEventTakesAGroupMembersDeliveryOutOfItsGroupAndStopsIt(t *t
w.h.PullUpdated(pr(gCtl, 1, gHc, "feat/g", "internal/a.go"))
w.h.PullUpdated(pr(gCat, 2, gHk, "feat/g", "modules/app/module.json"))
id := IDOf(gCtl, gHc)
hear(t, w, "gitea.forge.pull-request.closed", forgeClosed("novox", "mesh-controller", 1, false))
hear(t, w, gitEvent(gitseat.EventForgePull), forgeClosed("novox", "mesh-controller", 1, false))
d := w.delivery(id)
if !d.ClosedUnmerged || !d.State.Final() || inGroup(w, "feat/g", id) {
t.Fatalf("closed %v, state %s, in its group %v; wanted closed, stopped, out of its group", d.ClosedUnmerged, d.State,
@@ -26,8 +28,8 @@ func TestAForgeClosedEventTakesAGroupMembersDeliveryOutOfItsGroupAndStopsIt(t *t
}
// Heard again, from the event and from the poll: nothing changes.
before, _ := json.Marshal(d)
hear(t, w, "gitea.forge.pull-request.closed", forgeClosed("novox", "mesh-controller", 1, false))
hear(t, w, "gitea.pull.closed", map[string]any{"owner": "novox", "repo": "mesh-controller", "number": 1})
hear(t, w, gitEvent(gitseat.EventForgePull), forgeClosed("novox", "mesh-controller", 1, false))
hear(t, w, gitEvent(gitseat.EventPullClosed), gitseat.PullClosedEvent{Repository: "novox/mesh-controller", Number: 1})
if after, _ := json.Marshal(w.delivery(id)); string(after) != string(before) {
t.Fatalf("a second close changed it:\n%s\n%s", before, after)
}
@@ -38,7 +40,7 @@ func TestAForgeClosedEventOfAMergedPullRequestIsIgnored(t *testing.T) {
w.h.PullUpdated(pr(gCtl, 1, gHc, "feat/g", "internal/a.go"))
w.h.PullUpdated(pr(gCat, 2, gHk, "feat/g", "modules/app/module.json"))
id := IDOf(gCtl, gHc)
hear(t, w, "gitea.forge.pull-request.closed", forgeClosed("novox", "mesh-controller", 1, true))
hear(t, w, gitEvent(gitseat.EventForgePull), forgeClosed("novox", "mesh-controller", 1, true))
if d := w.delivery(id); d.ClosedUnmerged || !inGroup(w, "feat/g", id) || d.State.Final() {
t.Fatalf("a merged pull request's close stopped it: closed %v, %s", d.ClosedUnmerged, d.State)
}
@@ -17,6 +17,7 @@ import (
"sync"
"time"
gitseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/git"
stdio "git.novox.be/novox/mesh-sdk/go"
)
@@ -31,11 +32,9 @@ type listening struct {
func (l *listening) set(s string) { l.mu.Lock(); l.now = s; l.mu.Unlock() }
func (l *listening) get() string { l.mu.Lock(); defer l.mu.Unlock(); return l.now }
// The events this owner follows: the forge's pull requests. The forge's status events say that a check answered (events.go).
// The step events the delivery record is built from follow (consumer.go).
var followed = append([]string{"gitea.pull.updated", "gitea.pull.merged", "gitea.pull.closed",
"gitea.forge.status", "gitea.forge.pull-request.edited", "gitea.forge.pull-request.closed",
forgePattern, ConfigChangedEvent, writableEvent}, stepPatterns()...)
// The events this owner follows: the forge's pull requests, as the seat git says them (gitseat.go). The forge's status events
// say that a check answered (events.go). The step events the delivery record is built from follow (consumer.go).
var followed = append(append(append([]string{}, gitEvents...), forgePattern, ConfigChangedEvent), stepPatterns()...)
// forgePattern is the forge bridge's messages on FORGE (novox/hq ADR 0437 §8): the gate follows a merge and the refusal of one.
const forgePattern = "mesh-delivery-forge-bridge.forge.**"
@@ -60,7 +59,7 @@ func main() {
h := &Holder{
Settings: settings,
Store: kvStore{},
Forge: toolForge{ask: stdio.Ask},
Forge: seatForge{ask: stdio.Ask},
Emit: func(event string, body any) error { return stdio.Emit(event, body) },
Logf: logf,
Operator: kvOperator{},
@@ -213,45 +212,42 @@ func take(h *Holder, e stdio.Envelope) error {
return nil
}
switch e.Key {
case "gitea.pull.updated", "gitea.pull.merged", "gitea.pull.closed":
var p PullEvent
if err := json.Unmarshal(e.Body, &p); err != nil {
case gitEvent(gitseat.EventPullUpdated), gitEvent(gitseat.EventPullMerged), gitEvent(gitseat.EventPullClosed):
p, err := gitPullEvent(e.Key, e.Body)
if err != nil {
h.Refuse(fmt.Errorf("%s is not readable: %v", e.Key, err))
return nil
}
switch e.Key {
case "gitea.pull.updated":
case gitEvent(gitseat.EventPullUpdated):
if h.PullUpdated(p) {
h.wantForge() // a head not seen before: the forge shows whether anyone checks it
}
h.KeepPull(p)
case "gitea.pull.merged":
case gitEvent(gitseat.EventPullMerged):
h.PullMerged(p)
default:
h.PullClosed(p)
}
case "gitea.forge.pull-request.edited":
// The description edited after a head was announced (issue 1079): the lines in it are read again.
var p PullEvent
if err := json.Unmarshal(e.Body, &p); err != nil {
case gitEvent(gitseat.EventForgePull):
action := forgeAction(e.Body)
if action != "edited" && action != "closed" {
return nil // a head and a merge are the seat's pull.updated and pull.merged
}
p, err := gitPullEvent(e.Key, e.Body)
if err != nil {
h.Refuse(fmt.Errorf("%s is not readable: %v", e.Key, err))
return nil
}
h.PullEdited(p)
case "gitea.forge.pull-request.closed":
// The forge's own close event (issue 1107): the poll's gitea.pull.closed skips a repository whose update time did not
// move, and a close does not move it. A merged pull request is the webhook's merged event, never this one.
var p PullEvent
if err := json.Unmarshal(e.Body, &p); err != nil {
h.Refuse(fmt.Errorf("%s is not readable: %v", e.Key, err))
return nil
}
if !p.Merged {
if action == "edited" {
h.PullEdited(p) // an edit after a head was announced takes over a stopped record (issue 1114)
} else if !p.Merged {
// The forge's own close (issue 1107): a merged pull request is the merged event, never this one.
h.PullClosed(p)
}
case "gitea.forge.status":
var s ForgeStatusEvent
if err := json.Unmarshal(e.Body, &s); err != nil {
case gitEvent(gitseat.EventForgeStatus):
s, err := gitStatusEvent(e.Body)
if err != nil {
h.Refuse(fmt.Errorf("a commit status is not readable: %v", err))
return nil
}
@@ -111,9 +111,11 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) {
if !reflect.DeepEqual(m.Reads, []string{tellerModule + ".answers", checkerVerdicts, viewModule + ".reach"}) {
t.Fatalf("reads %v", m.Reads)
}
// It calls no controller verb and reads the forge with three tools; the forge's writes are the bridge's (ADR 0437 §8). It
// uses operator-channel only to say its conditions (ADR 0437 §9, issue 1275): the teller asks (ADR 0437 §6).
want := []string{"gitea.gitea_commit_statuses", "gitea.gitea_contains", "gitea.gitea_open_pulls"}
// It calls no controller verb and reads the forge through the seat git's verbs (design 47 *The seat git*), and the forge's
// merge state through the one statuses tool the seat does not carry yet (seatForge.mergeState); the forge's writes are the
// bridge's (ADR 0437 §8). It uses operator-channel only to say its conditions (ADR 0437 §9, issue 1275): the teller asks
// (ADR 0437 §6).
want := []string{"seat:git.pull", "seat:git.statuses", "seat:git.open-pulls", "seat:git.contains", mergeStateTool}
if !reflect.DeepEqual(m.Uses, []string{"operator-channel"}) {
t.Fatalf("uses %v: operator-channel, for its conditions alone", m.Uses)
}
@@ -600,7 +600,7 @@ var trunkHead = regexp.MustCompile(`^[0-9a-f]{40}$`)
// seedTrunks gives source.main the trunk head of a repository it names none of (issue 1422): one never merged through the
// gate, whose open pull requests the checker could otherwise never judge. The head is the base_sha of the latest announcement of
// any open pull request into main (gitea.pull.updated; ADR 0323; lastAnnounced), the trunk's freshest head the gate heard. It is
// any open pull request into main (the seat git's pull.updated; ADR 0323; lastAnnounced), the trunk's freshest head the gate heard. It is
// added in one compare-and-set write that never replaces a head source.main holds, so a merge always wins; the first merge
// through the gate moves it as any other. source.main is per repository and names main's head only (ADR 0484 §11, MergeBranch):
// a pull request into another base seeds nothing. A repository is weighed once per start after it was found or seeded.
@@ -50,7 +50,7 @@ type Delivery struct {
// Check is its own check's verdict.
Check *Verdict `json:"check,omitempty"`
// Required are the statuses its base requires, as the forge last said them (gitea_open_pulls): mesh/check makes it
// Required are the statuses its base requires, as the forge last said them (the seat git's open-pulls): mesh/check makes it
// the checker's (issue 1302). Empty until the forge said them.
Required []string `json:"required,omitempty"`
// CheckState is its head's mesh/check as the forge last said it (pending, success, failure, error): a pending one
@@ -3,6 +3,8 @@
package main
import (
gitseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/git"
"context"
"encoding/json"
"strings"
@@ -38,7 +40,7 @@ func TestADeliveryOnMeshCheckGoesReadyAskedAndMergedOverARealBus(t *testing.T) {
rt.mu.Unlock()
announced := pr("novox/app", 8, head, "fix/y", "src/b.go")
announced.Body = "Reviewed-by: lead@shanks at " + head
b.publish(t, "mesh.mod.gitea.event.pull.updated", announced)
b.publish(t, "mesh.seat.git.event.pull.updated", seatUpdated(announced))
// The checker's verdict at the head, then the forge's word that its bridge set mesh/check.
hash, err := mdc.HeadsHash([]mdc.Head{{Repository: "novox/app", Number: 8, Head: head}})
@@ -55,8 +57,8 @@ func TestADeliveryOnMeshCheckGoesReadyAskedAndMergedOverARealBus(t *testing.T) {
rt.openPulls[0].Statuses = []string{ctxCheck}
rt.openPulls[0].Checks = map[string]HeadCheck{ctxCheck: {State: "success", Description: line}}
rt.mu.Unlock()
set := b.publish(t, "mesh.mod.gitea.event.forge.status", map[string]any{"owner": "novox", "repo": "app", "sha": head,
"context": ctxCheck, "state": "success", "description": line})
set := b.publish(t, "mesh.seat.git.event.forge.status", gitseat.ForgeStatusEvent{Repository: "novox/app", Commit: head,
Context: ctxCheck, State: "success", Description: line})
var key string
rt.until("the ask to be written on mesh/check alone", 10*time.Second, func() bool { key = onlyAsk(b); return key != "" })
@@ -7,12 +7,13 @@ import (
"sort"
"strings"
gitseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/git"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// What mesh-delivery asks of others, and where it keeps what it owns. **It never sends to a machine, writes
// a declaration, a grant or a bus object, and never registers a build** (novox/hq ADR 0239 decision 6): it
// asks the controller, through the controller's seat, and the forge's holder, through its tools.
// asks the controller, through the controller's seat, and the forge, through the seat `git`.
// Forge is what the forge's holder does for a delivery.
type Forge interface {
@@ -26,7 +27,7 @@ type Forge interface {
OpenPulls() ([]OpenPull, error)
}
// ForgeChecks is what the forge says of a commit's checks (the gitea module's gitea_commit_statuses).
// ForgeChecks is what the forge says of a commit's checks (the seat git's pull and statuses, and the merge state).
type ForgeChecks struct {
Commit string `json:"commit"`
Pull *ForgePull `json:"pull,omitempty"`
@@ -48,7 +49,7 @@ type ForgePull struct {
// MergedAt is when it merged, as the forge says it (RFC 3339); empty when not merged or not said.
MergedAt string `json:"merged_at,omitempty"`
HTMLURL string `json:"html_url,omitempty"`
// Mergeable is the forge's own word on whether it merges into its base now (gitea's mergeable): false while it is in
// Mergeable is the forge's own word on whether it merges into its base now (its mergeable): false while it is in
// conflict with its base, or while the forge works that out after the base moved; nil when the forge did not say.
Mergeable *bool `json:"mergeable,omitempty"`
}
@@ -137,80 +138,130 @@ func lastLine(s string) string {
return strings.TrimSpace(lines[len(lines)-1])
}
// toolForge asks the forge's holder through its tools.
type toolForge struct{ ask asking }
// seatForge reads the forge through the seat `git` (design 47 *The seat git*): every read a verb of the seat, its answer
// typed in the seat's contract, a failed read an error and never "none". The forge module is never named.
type seatForge struct{ ask asking }
func (f toolForge) tool(name string, args map[string]any) error {
raw, err := f.ask("gitea."+name, args)
// gitAsk calls a verb of the seat `git` and reads its typed answer; an answer carrying `error` is a failed read.
func gitAsk[A any](f seatForge, verb string, in any, errOf func(*A) string) (*A, error) {
raw, err := f.ask("seat:"+gitseat.Seat+"."+verb, in)
if err != nil {
return err
return nil, err
}
if _, output, ok := answerOf(raw); !ok {
return fmt.Errorf("the forge refused %s: %s", name, lastLine(output))
answer, output, ok := answerOf(raw)
if !ok {
return nil, fmt.Errorf("the seat git refused %s: %s", verb, lastLine(output))
}
return nil
var a A
if err := json.Unmarshal(answer, &a); err != nil {
return nil, fmt.Errorf("the seat git's %s is not readable: %s", verb, clip(string(answer), 200))
}
if why := errOf(&a); why != "" {
return nil, fmt.Errorf("the seat git could not read %s: %s", verb, why)
}
return &a, nil
}
func (f toolForge) Statuses(owner, repo string, number int, commit, base string) (*ForgeChecks, error) {
args := map[string]any{"owner": owner, "repo": repo}
// Statuses reads a pull request by its number through `pull` (its head when no commit is named) and the commit's statuses
// through `statuses`. What the forge would merge now (the base's protection, its conflict word, whether the merging
// account writes) is read by mergeState.
func (f seatForge) Statuses(owner, repo string, number int, commit, base string) (*ForgeChecks, error) {
repository := owner + "/" + repo
c := &ForgeChecks{Commit: commit}
if number > 0 {
a, err := gitAsk(f, gitseat.VerbPull, gitseat.PullInput{Repository: repository, Number: int64(number)},
func(a *gitseat.PullAnswer) string { return a.Error })
if err != nil {
return nil, err
}
if a.Pull == nil {
return nil, fmt.Errorf("the seat git's pull named no pull request for %s#%d", repository, number)
}
c.Pull = forgePullOf(*a.Pull)
if c.Commit == "" {
c.Commit = a.Pull.Head
}
if base == "" {
base = a.Pull.Base
}
}
if c.Commit == "" {
return nil, fmt.Errorf("the seat git named no head of %s#%d", repository, number)
}
s, err := gitAsk(f, gitseat.VerbStatuses, gitseat.StatusesInput{Repository: repository, Commit: c.Commit},
func(a *gitseat.StatusesAnswer) string { return a.Error })
if err != nil {
return nil, err
}
c.Combined = s.Combined
for _, st := range s.Statuses {
c.Statuses = append(c.Statuses, forgeStatusOf(st))
}
merge, mergeable, err := f.mergeState(owner, repo, number, c.Commit, base)
if err != nil {
return nil, err
}
c.Merge = merge
if c.Pull != nil {
c.Pull.Mergeable = mergeable
}
return c, nil
}
// mergeState is what the forge would merge now: the base's protection and its required statuses, the forge's own word on
// a conflict, and whether the merging account writes the repository. The seat `git` names none of them yet (dev-G-record's
// finding on W11.18): until it does, they are read through the forge holder's statuses tool, the one read left there.
func (f seatForge) mergeState(owner, repo string, number int, commit, base string) (ForgeMerge, *bool, error) {
args := map[string]any{"owner": owner, "repo": repo, "sha": commit}
if number > 0 {
args["number"] = number
}
if commit != "" {
args["sha"] = commit
}
if base != "" {
args["base"] = base
}
raw, err := f.ask("gitea.gitea_commit_statuses", args)
raw, err := f.ask(mergeStateTool, args)
if err != nil {
return nil, err
return ForgeMerge{}, nil, err
}
answer, output, ok := answerOf(raw)
if !ok {
return nil, fmt.Errorf("the forge refused gitea_commit_statuses: %s", lastLine(output))
return ForgeMerge{}, nil, fmt.Errorf("the forge refused its merge state: %s", lastLine(output))
}
var c ForgeChecks
if err := json.Unmarshal(answer, &c); err != nil || c.Commit == "" {
return nil, fmt.Errorf("the forge's gitea_commit_statuses is not readable: %s", clip(string(answer), 200))
return ForgeMerge{}, nil, fmt.Errorf("the forge's merge state is not readable: %s", clip(string(answer), 200))
}
return &c, nil
var mergeable *bool
if c.Pull != nil {
mergeable = c.Pull.Mergeable
}
return c.Merge, mergeable, nil
}
func (f toolForge) OpenPulls() ([]OpenPull, error) {
raw, err := f.ask("gitea.gitea_open_pulls", map[string]any{})
// mergeStateTool is the one forge read the seat `git` does not carry yet (see mergeState).
const mergeStateTool = "gitea.gitea_commit_statuses"
// OpenPulls reads every open pull request through `open-pulls`.
func (f seatForge) OpenPulls() ([]OpenPull, error) {
a, err := gitAsk(f, gitseat.VerbOpenPulls, gitseat.OpenPullsInput{}, func(a *gitseat.OpenPullsAnswer) string { return a.Error })
if err != nil {
return nil, err
}
answer, output, ok := answerOf(raw)
if !ok {
return nil, fmt.Errorf("the forge refused gitea_open_pulls: %s", lastLine(output))
out := make([]OpenPull, 0, len(a.Pulls))
for _, p := range a.Pulls {
out = append(out, openPullOf(p))
}
var open struct {
Pulls []OpenPull `json:"pulls"`
}
if err := json.Unmarshal(answer, &open); err != nil || open.Pulls == nil {
return nil, fmt.Errorf("the forge's gitea_open_pulls is not readable: %s", clip(string(answer), 200))
}
return open.Pulls, nil
return out, nil
}
func (f toolForge) Contains(owner, repo, commit, in string) (bool, error) {
raw, err := f.ask("gitea.gitea_contains", map[string]any{"owner": owner, "repo": repo, "commit": commit, "in": in})
// Contains reads through `contains` whether in contains commit.
func (f seatForge) Contains(owner, repo, commit, in string) (bool, error) {
a, err := gitAsk(f, gitseat.VerbContains, gitseat.ContainsInput{Repository: owner + "/" + repo, Commit: commit, In: in},
func(a *gitseat.ContainsAnswer) string { return a.Error })
if err != nil {
return false, err
}
answer, output, ok := answerOf(raw)
if !ok {
return false, fmt.Errorf("the forge refused gitea_contains: %s", lastLine(output))
}
var a struct {
Contains *bool `json:"contains"`
}
if err := json.Unmarshal(answer, &a); err != nil || a.Contains == nil {
return false, fmt.Errorf("the forge's gitea_contains is not readable: %s", clip(string(answer), 200))
}
return *a.Contains, nil
return a.Contains, nil
}
// carriedKey is a key an earlier owner's carry-over wrote into the bucket `deliveries`: no delivery, never read as one.
@@ -14,7 +14,7 @@ import (
// It writes a new head when the forge says one (`pull.updated`, which carries the head and the trunk commit it was
// announced against), and a new trunk commit only when the check is asked again against one — the merge tool's ask is
// that same announcement. A merge of a group's member, and a trunk move that leaves an inactive pull request unasked,
// are announced as nothing (the gitea module announces `pull.updated` only for a head and for the merge tool's ask), so
// are announced as nothing (the forge announces `pull.updated` only for a head and for the merge tool's ask), so
// they write nothing. A verdict for that head and trunk commit writes the verdict. The build seat reads the key.
// PullKey is what the bus keeps of one pull request.
@@ -61,7 +61,7 @@ const UncheckedAfter = 10 * time.Minute
// requiring any is one the mesh is meant to check; only those a base requires are waited on.
var mergeChecks = []string{ctxCheck, "mesh/merge-gate", "mesh/repo-check"}
// OpenPull is an open pull request as the forge says it (the gitea module's gitea_open_pulls): its head, its base,
// OpenPull is an open pull request as the forge says it (the seat git's open-pulls): its head, its base,
// the contexts the base's protection requires, the contexts its head has a status of, in any state, and each of
// those statuses with its state.
type OpenPull struct {
@@ -183,7 +183,7 @@ func (h *Holder) SeeOpenPulls() error {
return nil
}
// openPullsMost is the most open pull requests the forge's gitea_open_pulls answers: it reads 20 pages of 50 and says
// openPullsMost is the most open pull requests the seat git's open-pulls answers: it reads 20 pages of 50 and says
// nothing of the rest (mesh-catalog modules/gitea client.ts searchOpenPulls). A list that long may be cut, so what is
// missing from it is not taken as closed.
const openPullsMost = 20 * 50
@@ -1,6 +1,8 @@
package main
import (
gitseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/git"
"testing"
"time"
)
@@ -9,18 +11,18 @@ import (
// the forge's open pull requests are read when this owner starts and when an event says something they show may have
// changed — a merge check's status, a new head, a ready mark — and once more at the bound a head's check is said.
func statusEvent(sha, context, state string) ForgeStatusEvent {
return ForgeStatusEvent{Owner: "novox", Repo: "hq", SHA: sha, Context: context, State: state}
func statusEvent(sha, context, state string) gitseat.ForgeStatusEvent {
return gitseat.ForgeStatusEvent{Repository: "novox/hq", Commit: sha, Context: context, State: state}
}
func TestAMergeCheckStatusReadsTheOpenPullRequestsAndAnotherContextDoesNot(t *testing.T) {
w := newWorld(t)
before := w.forge.openCalls
hear(t, w, "gitea.forge.status", statusEvent("cccc", "mesh/delivery", "success"))
hear(t, w, gitEvent(gitseat.EventForgeStatus), statusEvent("cccc", "mesh/delivery", "success"))
if w.forge.openCalls != before {
t.Fatalf("a status of a context that is no merge check read the forge %d times", w.forge.openCalls-before)
}
hear(t, w, "gitea.forge.status", statusEvent("cccc", "mesh/merge-gate", "pending"))
hear(t, w, gitEvent(gitseat.EventForgeStatus), statusEvent("cccc", "mesh/merge-gate", "pending"))
if w.forge.openCalls != before+1 {
t.Fatalf("a merge check's status read the forge %d times, wanted once", w.forge.openCalls-before)
}
@@ -31,7 +33,7 @@ func TestAHeadWhoseCheckNeverAnsweredIsSaidFromTheEventsAndOneReadAtTheBound(t *
w.forge.open = []OpenPull{{Owner: "novox", Repo: "hq", Number: 1, Base: "main", HeadSHA: "cccc",
Required: []string{"mesh/merge-gate"}, Statuses: []string{"mesh/merge-gate"},
Checks: map[string]HeadCheck{"mesh/merge-gate": {State: "pending", UpdatedAt: "2026-10-06T12:00:00Z"}}}}
hear(t, w, "gitea.forge.status", statusEvent("cccc", "mesh/merge-gate", "pending"))
hear(t, w, gitEvent(gitseat.EventForgeStatus), statusEvent("cccc", "mesh/merge-gate", "pending"))
if len(w.h.Stalled()) != 0 {
t.Fatal("a head just seen waiting is said already")
}
@@ -55,11 +57,11 @@ func TestAHeadWhoseCheckNeverAnsweredIsSaidFromTheEventsAndOneReadAtTheBound(t *
func TestANewlyAnnouncedHeadReadsTheForgeAndAKnownOneDoesNot(t *testing.T) {
w := newWorld(t)
before := w.forge.openCalls
hear(t, w, "gitea.pull.updated", pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
hear(t, w, gitEvent(gitseat.EventPullUpdated), seatUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go")))
if w.forge.openCalls != before+2 { // the unchecked heads, and the head's standing verdict before its check is asked (issue 1115)
t.Fatalf("a new head read the forge %d times, wanted twice", w.forge.openCalls-before)
}
hear(t, w, "gitea.pull.updated", pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
hear(t, w, gitEvent(gitseat.EventPullUpdated), seatUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go")))
if w.forge.openCalls != before+3 { // the look of a delivery awaiting its verdict reads its standing one
t.Fatalf("the same head announced again read the forge again (%d reads)", w.forge.openCalls-before)
}
@@ -88,7 +90,7 @@ func TestAClosedOrMergedPullRequestIsNoLongerSaidUnchecked(t *testing.T) {
t.Fatal("not said")
}
closed := pr("novox/hq", 1, "cccc", "feat/x")
hear(t, w, "gitea.pull.closed", closed)
hear(t, w, gitEvent(gitseat.EventPullClosed), seatClosed(closed))
if lines := w.h.Stalled(); len(lines) != 0 {
t.Fatalf("a closed pull request is still said: %+v", lines)
}
@@ -5,24 +5,26 @@ import (
"fmt"
"strings"
"time"
gitseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/git"
)
// **The operator is asked only for a merge the forge account can carry out** (novox/hq issue 1945). car-hunter #2, a
// repository of a user, was approved, and its first act after the Approve, the commit status, was refused 403: the mesh's
// merging account (the gitea module's tools, mesh-tools) only read that repository. Before the gate asks the operator's
// Approve it reads, through the forge holder's gitea_commit_statuses, whether that account writes each repository the
// Approve it reads, with the forge's merge state (seatForge.mergeState), whether that account writes each repository the
// ask covers (merge.writes), as the ask is about to be written. Where it does not, nothing is asked: the delivery waits,
// saying "the forge account cannot write <repo>: gitea_grant_write", and the gate raises it as a condition at once.
//
// A repository not written is kept so, and the ask is not composed again, until the gitea module's event
// gitea.repo.writable (its grant, read back from the forge) or, as a safety net, writeReadLasts after the read. A forge
// A repository not written is kept so, and the ask is not composed again, until the seat git's event
// repo.writable (its grant, read back from the forge) or, as a safety net, writeReadLasts after the read. A forge
// that does not say merge.writes (an older gitea module) is taken as writing: the merge says its own refusal, as before.
// writeReadLasts is how long a read of whether the forge account writes a repository holds.
const writeReadLasts = 10 * time.Minute
// writableEvent is the gitea module's event of an account given write on a repository.
const writableEvent = "gitea.repo.writable"
// writableEvent is the seat git's event of an account given write on a repository.
var writableEvent = gitEvent(gitseat.EventRepoWritable)
// writeRead is what the forge last said of the merging account on one repository it cannot write.
type writeRead struct {
@@ -88,17 +90,14 @@ func (h *Holder) cannotWrite(reads []memberRead) string {
return ""
}
// WriteGranted takes the gitea module's gitea.repo.writable: the repository's read is dropped, so the next look reads it
// WriteGranted takes the seat git's repo.writable: the repository's read is dropped, so the next look reads it
// again, and an approved merge of it that the forge refused is asked again, its cause gone.
func (h *Holder) WriteGranted(body json.RawMessage) error {
var e struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
}
if err := json.Unmarshal(body, &e); err != nil || e.Owner == "" || e.Repo == "" {
var e gitseat.RepoWritableEvent
if err := json.Unmarshal(body, &e); err != nil || !strings.Contains(strings.Trim(e.Repository, "/"), "/") {
return fmt.Errorf("%s names no repository: %s", writableEvent, string(body))
}
repo := strings.ToLower(e.Owner + "/" + e.Repo)
repo := strings.ToLower(e.Repository)
h.mu.Lock()
defer h.mu.Unlock()
delete(h.writeReads, repo)
@@ -1,6 +1,8 @@
package main
import (
gitseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/git"
"strings"
"testing"
)
@@ -33,7 +35,7 @@ func TestAnUnwritableRepositoryIsNotAskedAndSaysSoUntilTheWriteIsGranted(t *test
}
delete(w.forge.unwritable, "novox/app")
hear(t, w, writableEvent, map[string]string{"owner": "novox", "repo": "app", "account": "mesh-tools"})
hear(t, w, writableEvent, gitseat.RepoWritableEvent{Repository: "novox/app", Account: "mesh-tools"})
w.h.React()
if w.op.count() != 1 {
t.Fatalf("after the write was granted the operator was asked %d times, wanted once", w.op.count())
+1
View File
@@ -7,6 +7,7 @@ require (
git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/contracts/go/seats/git v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-indexer/takein v0.0.0-00010101000000-000000000000
+11 -10
View File
@@ -20,15 +20,14 @@
}
],
"consumes": [
"gitea.pull.updated",
"gitea.pull.merged",
"gitea.pull.closed",
"gitea.forge.status",
"gitea.forge.pull-request.edited",
"gitea.forge.pull-request.closed",
"git.pull.updated",
"git.pull.merged",
"git.pull.closed",
"git.forge.pull",
"git.forge.status",
"git.repo.writable",
"mesh-delivery-forge-bridge.forge.**",
"mesh-config.config-changed",
"gitea.repo.writable",
"mesh-delivery-indexer.take-in.taken"
],
"emits": [
@@ -254,9 +253,11 @@
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg": "0.1"
},
"invokes": [
"gitea.gitea_commit_statuses",
"gitea.gitea_contains",
"gitea.gitea_open_pulls"
"seat:git.pull",
"seat:git.statuses",
"seat:git.open-pulls",
"seat:git.contains",
"gitea.gitea_commit_statuses"
],
"state": [
"deliveries",