diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bus_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bus_test.go index a9a2847a8..428e57a4a 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bus_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bus_test.go @@ -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..event., mesh.seat..event.). 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 { diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/checkerbus_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/checkerbus_test.go index daf800108..c4ab1125b 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/checkerbus_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/checkerbus_test.go @@ -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 -} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events.go index be95a0c7e..5b3e5b39e 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events.go @@ -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"` } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events_test.go index 45a65698c..63609fe7d 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events_test.go @@ -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()) } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/fakes_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/fakes_test.go index 9b1e1f2a1..28842c05c 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/fakes_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/fakes_test.go @@ -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, "/") diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/gitseat.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/gitseat.go new file mode 100644 index 000000000..421aa7c58 --- /dev/null +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/gitseat.go @@ -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.`), 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 /") + } + 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 / 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 +} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/gitseat_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/gitseat_test.go new file mode 100644 index 000000000..328b8de0c --- /dev/null +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/gitseat_test.go @@ -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") + } + }) +} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go index 470b68fde..64df2e448 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go @@ -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) } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1107_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1107_test.go index f9dea49d5..0c99878bf 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1107_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1107_test.go @@ -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) } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go index e463c1359..068c09d19 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go @@ -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 } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/manifest_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/manifest_test.go index 69acc6236..3c35d1126 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/manifest_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/manifest_test.go @@ -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) } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/merges.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/merges.go index 35e2649a2..2cbf862af 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/merges.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/merges.go @@ -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. diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/model.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/model.go index 5ad6a565f..3b5b444ff 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/model.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/model.go @@ -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 diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/onestatus_bus_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/onestatus_bus_test.go index de48c147a..d5dfaa0c0 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/onestatus_bus_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/onestatus_bus_test.go @@ -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 != "" }) diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ports.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ports.go index 76b40c283..5d57538e4 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ports.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ports.go @@ -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. diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/pullkey.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/pullkey.go index 4b2b77017..e208c0fac 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/pullkey.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/pullkey.go @@ -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. diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/unchecked.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/unchecked.go index 368b3271f..1990de8cf 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/unchecked.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/unchecked.go @@ -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 diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/unchecked_events_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/unchecked_events_test.go index b2999e7c5..4ed5fb1db 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/unchecked_events_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/unchecked_events_test.go @@ -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) } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/writes.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/writes.go index cb142cbe4..4421db95f 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/writes.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/writes.go @@ -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 : 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) diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/writes_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/writes_test.go index 286242307..e198541c3 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/writes_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/writes_test.go @@ -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()) diff --git a/modules/mesh-delivery-gate/go.mod b/modules/mesh-delivery-gate/go.mod index 11b37039e..e352605ad 100644 --- a/modules/mesh-delivery-gate/go.mod +++ b/modules/mesh-delivery-gate/go.mod @@ -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 diff --git a/modules/mesh-delivery-gate/module.json b/modules/mesh-delivery-gate/module.json index d7aa47e5f..a619a7d8e 100644 --- a/modules/mesh-delivery-gate/module.json +++ b/modules/mesh-delivery-gate/module.json @@ -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",