diff --git a/modules/mesh-issues/README.md b/modules/mesh-issues/README.md index dbd6ff012..a51028bfd 100644 --- a/modules/mesh-issues/README.md +++ b/modules/mesh-issues/README.md @@ -89,7 +89,7 @@ still read as that issue's verdict. **Ready.** An issue is ready when it is not resolved or wontfix and every issue it depends on is wontfix, or resolved with every pull request its `fixed-by` names (`#`) delivered. The module consumes `mesh-delivery-gate.transition` and marks a pull request delivered on every issue naming it; at its own `moved` to -resolved it asks `seat:mesh-delivery.checks` once for what it has not heard. An issue made ready gains a note and +resolved it asks `seat:git.pull` once for what it has not heard. An issue made ready gains a note and the event `ready`. `get` answers `waits-for`, `needed-by` and `ready`; `list` filters on `severity`, `needs` and `ready` — an agent looking for work asks `severity=minor`, `needs=none`, `ready=true`. `set` changes severity, needs and suggested owner as a person decides. `link` refuses a `depends-on` that would close a cycle. @@ -106,8 +106,8 @@ key-value state has no schema to migrate. ## Whether the fix is delivered (hq ADR 0281) For each pull request an issue's `fixed-by` names, the module keeps what `mesh-delivery-gate.transition` last said — -state, why, merged and when — or asks `seat:mesh-delivery.checks` once (at its own `moved`) for one it has not -heard of. Nothing sets it by hand. `get` and every `list` row carry `delivery`, one line derived from it, worst +state, why, merged and when — or asks `seat:git.pull` once (at its own `moved`) for one it has not +heard of. Nothing sets it by hand. `get` also carries `pipelines`: one row per module's pipeline of each live fix's head commit with its steps, `no run` where the runner's list answers none. `get` and every `list` row carry `delivery`, one line derived from it, worst first: "fix not delivered: …", "held: …", "fix merged, not delivered: …", "fix not merged: …", "nothing heard from the delivery of …", "delivered to every machine it should reach" (covered by a later merge says so). @@ -163,8 +163,8 @@ core one, counted from the move to `fixing`), `over`, the fix delivered last (`f | phase | ends at | read from | |---|---|---| | implementing | its pull request opened | mesh-delivery's `opened` (the pull request's first head heard) | -| review | the first approval at its head | the forge's reviews (`gitea_pull_reviews`): the forge's own approval, or a comment review whose first line is `Verdict: approve at `, the sha the head it was posted at or a prefix of at least seven characters (the forge refuses an approval of one's own pull request, so the mesh's independent reviews are comments) | -| checks | its merge, required checks and any wait for the merge included | mesh-delivery's `merged_at` | +| review | the first review that passed at its head | the runner: `seat:mesh-pipeline-runner.list` given the head commit `seat:git.pull` answers, and `show` of each pipeline, the review step's `ended`; the forge's own reviews are read nowhere | +| checks | its merge, required checks and any wait for the merge included | the gate's `merged_at`, or `seat:git.pull`'s `merged-at` | | merge to running | delivered | the delivery's `delivered` (its delivery time) | A phase whose end came before the end of the phase before it counts zero; a phase whose end is not known (no diff --git a/modules/mesh-issues/cmd/mesh-issues/delivery.go b/modules/mesh-issues/cmd/mesh-issues/delivery.go index d5efd72bb..3a00d1afc 100644 --- a/modules/mesh-issues/cmd/mesh-issues/delivery.go +++ b/modules/mesh-issues/cmd/mesh-issues/delivery.go @@ -528,28 +528,25 @@ func (t *Tracker) fillMerge(n uint64, f *Fix, pr string) { } } -// busContains asks the forge's gitea_contains over the bus: whether commit is an ancestor of in, or it. +// busContains asks the seat `git`'s `contains` over the bus: whether commit is an ancestor of in, or it. func busContains(owner, repo, commit, in string) (bool, error) { - raw, err := stdio.Ask("gitea.gitea_contains", map[string]any{"owner": owner, "repo": repo, "commit": commit, "in": in}) + raw, err := stdio.Ask("seat:git.contains", map[string]any{"repository": owner + "/" + repo, "commit": commit, "in": in}) if err != nil { return false, err } - var out struct { - Contains *bool `json:"contains"` - Answer struct { - Contains *bool `json:"contains"` - } `json:"answer"` + return parseContains(raw) +} + +func parseContains(raw json.RawMessage) (bool, error) { + m, err := answerOf(raw, "git.contains") + if err != nil { + return false, err } - if json.Unmarshal(raw, &out) != nil { - return false, errors.New("the forge's gitea_contains is not readable") + var yes *bool + if json.Unmarshal(m["contains"], &yes) != nil || yes == nil { + return false, errors.New("git.contains answered no verdict") } - switch { - case out.Answer.Contains != nil: - return *out.Answer.Contains, nil - case out.Contains != nil: - return *out.Contains, nil - } - return false, errors.New("the forge's gitea_contains answered no verdict") + return *yes, nil } // coveredBy says whether a merged fix's merge is an ancestor of, or equal to, the head a delivered revision holds for its @@ -612,7 +609,7 @@ func sameCommitIn(a, b string) bool { return len(a) >= 7 && strings.HasPrefix(b, a) } -// Delivery is a pull request's delivery as mesh-delivery's `checks` answers it; a test stubs the asking. +// Delivery is a pull request's delivery as the tracker learns it (from the seat `git`'s `pull` and from the gate's events); a test stubs the asking. type Delivery struct { State string Why string @@ -649,42 +646,9 @@ func nonZero(t *time.Time) *time.Time { return &u } -// DeliveryState asks mesh-delivery for one pull request's delivery. +// DeliveryState reads one pull request's facts from the seat `git`. type DeliveryState func(repository string, number string) (Delivery, error) -// busDeliveryState asks mesh-delivery's checks over the bus. -func busDeliveryState(owner string) DeliveryState { - return func(repository, number string) (Delivery, error) { - raw, err := stdio.Ask("seat:mesh-delivery.checks", map[string]any{"repository": owner + "/" + repository, "number": number}) - if err != nil { - return Delivery{}, err - } - var out struct { - Delivery struct { - State string `json:"state"` - Waits string `json:"waits"` - Since time.Time `json:"since"` - Class string `json:"class"` - Opened *time.Time `json:"opened"` - MergedAt *time.Time `json:"merged_at"` - Group string `json:"group"` - MergedAs string `json:"merged_as"` - } `json:"delivery"` - Pull *pullFacts `json:"pull"` - } - if err := json.Unmarshal(raw, &out); err != nil { - return Delivery{}, fmt.Errorf("mesh-delivery's checks answered no delivery: %.200s", raw) - } - d := Delivery{State: out.Delivery.State, Why: out.Delivery.Waits, Since: out.Delivery.Since.UTC(), - Class: out.Delivery.Class, Opened: nonZero(out.Delivery.Opened), MergedAt: nonZero(out.Delivery.MergedAt), - Repository: strings.ToLower(owner + "/" + repository), Group: out.Delivery.Group, MergedAs: out.Delivery.MergedAs} - if out.Pull != nil { - d.fromPull(*out.Pull, owner+"/"+repository) - } - return d, nil - } -} - // OnMoved is the handler of the tracker's own `moved` event: each pull request of the issue's fix not heard of // yet is asked of mesh-delivery, at most a few per handling, for a delivery that moved before the issue named // it; then what follows its fixes, and an issue closed lets go what waited on it. diff --git a/modules/mesh-issues/cmd/mesh-issues/fixtime.go b/modules/mesh-issues/cmd/mesh-issues/fixtime.go index 9d6f5c961..f5ac38d77 100644 --- a/modules/mesh-issues/cmd/mesh-issues/fixtime.go +++ b/modules/mesh-issues/cmd/mesh-issues/fixtime.go @@ -14,8 +14,6 @@ import ( "sort" "strings" "time" - - stdio "git.novox.be/novox/mesh-sdk/go" ) // The classes of a fix (ADR 0282 §1, ADR 0313 §2) and the class of a fix that moves no module. @@ -76,8 +74,8 @@ type FixPhase struct { Said string `json:"said,omitempty"` } -// Approval is when a pull request was first approved at its head, as the forge says it; a test stubs the asking. -// found is false when no approval at its head is on the forge. +// Approval is when a pull request's review step first passed, as the runner says it; a test stubs the asking. +// found is false when no pipeline of its head commit passed its review. type Approval func(repository string, number int) (at time.Time, found bool, err error) // classOfFixes is the class of an issue's fixes: that of the slowest-budgeted module they move — core when any @@ -252,7 +250,7 @@ func (t *Tracker) fixTimeOf(is *Issue) (*FixTime, string) { why string } var approved *time.Time - approvalWhy := "no approval at its head is on the forge: review and checks are counted together" + approvalWhy := "no review of its head commit passed in the runner: review and checks are counted together" if t.Approval != nil { repo, number, _ := strings.Cut(last.PullRequest, "#") var n int @@ -260,14 +258,14 @@ func (t *Tracker) fixTimeOf(is *Issue) (*FixTime, string) { at, found, err := t.Approval(repo, n) switch { case err != nil: - approvalWhy = "the forge could not be read for its approval (" + cut(err.Error(), 200) + "): review and checks are counted together" + approvalWhy = "the runner could not be read for its review (" + cut(err.Error(), 200) + "): review and checks are counted together" case found: a := at.UTC() approved = &a } } } else { - approvalWhy = "the forge is not asked here: review and checks are counted together" + approvalWhy = "the runner is not asked here: review and checks are counted together" } openedWhy := "the delivery did not say when its pull request opened: implementing is counted in review" mergedWhy := "the delivery did not say when it merged: checks are counted in merge to running" @@ -570,83 +568,15 @@ func (t *Tracker) FixTimes(days int) (*FixTimes, error) { return a, nil } -// firstApproval is when a pull request was first approved at its head among the forge's reviews: the forge's own -// approval, or — the forge refusing an approval of one's own pull request, so the mesh's independent reviews are -// posted as comments by the account that opened it — a comment review whose first line is "Verdict: approve at -// ", the sha the head it was posted at or a prefix of it of at least seven characters. An approval dismissed, -// stale, or of an older head does not count. -func firstApproval(raw json.RawMessage) (time.Time, bool, error) { - var a struct { - Reviews []struct { - State string `json:"state"` - AtHead bool `json:"at_head"` - Commit string `json:"commit"` - Body string `json:"body"` - Submitted string `json:"submitted"` - Stale bool `json:"stale"` - Dismissed bool `json:"dismissed"` - } `json:"reviews"` - } - var s string - if json.Unmarshal(raw, &s) == nil { - raw = json.RawMessage(s) - } - var wrapped struct { - Content []struct { - Text string `json:"text"` - } `json:"content"` - } - if json.Unmarshal(raw, &wrapped) == nil && len(wrapped.Content) > 0 { - raw = json.RawMessage(wrapped.Content[0].Text) - } - if err := json.Unmarshal(raw, &a); err != nil || a.Reviews == nil { - return time.Time{}, false, fmt.Errorf("the forge's gitea_pull_reviews answered no reviews: %.200s", raw) - } - var first time.Time - for _, r := range a.Reviews { - if !r.AtHead || r.Stale || r.Dismissed { - continue - } - switch r.State { - case "APPROVED": - case "COMMENT": - if !verdictApproves(r.Body, r.Commit) { - continue - } - default: - continue - } - at, err := time.Parse(time.RFC3339, r.Submitted) - if err != nil { - continue - } - if first.IsZero() || at.Before(first) { - first = at - } - } - return first, !first.IsZero(), nil -} - -// verdictApproves says whether a review's body opens with the mesh's approving verdict on the commit it was posted -// at: "Verdict: approve at ", the sha that commit or a prefix of it of at least seven characters. -func verdictApproves(body, commit string) bool { - line, _, _ := strings.Cut(strings.TrimLeft(body, " \t\r\n"), "\n") - rest, ok := strings.CutPrefix(strings.TrimSpace(line), "Verdict: approve at ") - if !ok { - return false - } - sha := strings.ToLower(strings.TrimRight(strings.TrimSpace(rest), ".")) - commit = strings.ToLower(strings.TrimSpace(commit)) - return len(sha) >= 7 && commit != "" && strings.Trim(sha, "0123456789abcdef") == "" && strings.HasPrefix(commit, sha) -} - -// busApproval asks the forge's reviews of a pull request over the bus (gitea_pull_reviews). +// busApproval is when a pull request's review step first passed among its pipelines, read from the runner (`list` given +// the head commit, `show` of each); the forge's own reviews are read nowhere. func busApproval(owner func() string) Approval { return func(repository string, number int) (time.Time, bool, error) { - raw, err := stdio.Ask("gitea.gitea_pull_reviews", map[string]any{"owner": owner(), "repo": repository, "number": number}) + rows, err := busPipelines(owner())(repository, fmt.Sprint(number)) if err != nil { return time.Time{}, false, err } - return firstApproval(raw) + at, ok := firstReview(rows) + return at, ok, nil } } diff --git a/modules/mesh-issues/cmd/mesh-issues/fixtime_test.go b/modules/mesh-issues/cmd/mesh-issues/fixtime_test.go index 551b891a1..8de5cfe94 100644 --- a/modules/mesh-issues/cmd/mesh-issues/fixtime_test.go +++ b/modules/mesh-issues/cmd/mesh-issues/fixtime_test.go @@ -441,50 +441,33 @@ func TestAFixWhoseStartCameFromTheLogIsNeverWarnedOn(t *testing.T) { } } -// The forge's reviews: the first approval at the head, never one dismissed, stale or of an older head. -func TestTheReviewEndsAtTheFirstApprovalAtItsHead(t *testing.T) { - raw := json.RawMessage(`{"reviews":[ - {"state":"APPROVED","at_head":false,"submitted":"2026-10-12T09:01:00Z"}, - {"state":"REQUEST_CHANGES","at_head":true,"submitted":"2026-10-12T09:02:00Z"}, - {"state":"APPROVED","at_head":true,"dismissed":true,"submitted":"2026-10-12T09:03:00Z"}, - {"state":"APPROVED","at_head":true,"submitted":"2026-10-12T09:05:00Z"}, - {"state":"APPROVED","at_head":true,"submitted":"2026-10-12T09:04:00Z"}]}`) - at, found, err := firstApproval(raw) - if err != nil || !found || at.Format(time.RFC3339) != "2026-10-12T09:04:00Z" { - t.Fatalf("%s %v %v", at, found, err) - } - // The mesh's own reviews: comments by the account that opened the pull request, whose first line is the verdict. - head := "43df7ebc5aa21c0bceacd1580f01f67d83d3495d" - comments := json.RawMessage(`{"reviews":[ - {"state":"COMMENT","at_head":true,"commit":"` + head + `","body":"Verdict: request changes at ` + head + `\n\nBlocking: …","submitted":"2026-10-12T09:01:00Z"}, - {"state":"COMMENT","at_head":true,"commit":"` + head + `","body":"Looks fine. Verdict: approve at ` + head + `","submitted":"2026-10-12T09:02:00Z"}, - {"state":"COMMENT","at_head":true,"commit":"` + head + `","body":"Verdict: approve at 43df7eb\n\nAll clauses hold.","submitted":"2026-10-12T09:07:00Z"}, - {"state":"COMMENT","at_head":true,"commit":"` + head + `","body":"Verdict: approve at ` + head + `\n\nAgain.","submitted":"2026-10-12T09:08:00Z"}]}`) - at, found, err = firstApproval(comments) - if err != nil || !found || at.Format(time.RFC3339) != "2026-10-12T09:07:00Z" { - t.Fatalf("a comment's verdict: %s %v %v", at, found, err) - } - for _, c := range []struct { - body, commit string - approves bool - }{ - {"Verdict: approve at " + head, head, true}, - {"Verdict: approve at 43df7eb", head, true}, - {"Verdict: approve at 43df7e", head, false}, // shorter than seven characters - {"Verdict: approve at 0123456789", head, false}, // another commit - {"Verdict: request changes at " + head, head, false}, // not an approval - {"Not yet.\nVerdict: approve at " + head, head, false}, // not the first line - {"Verdict: approve at 43df7ebc", "", false}, // no commit to hold it to - } { - if got := verdictApproves(c.body, c.commit); got != c.approves { - t.Errorf("%q at %q approves %v, want %v", c.body, c.commit, got, c.approves) +// The runner's review step: the first review that passed among a pull request's pipelines, never one that failed or waits. +func TestTheReviewEndsAtTheFirstPassedReviewStepOfTheRunner(t *testing.T) { + show := func(outcome, ended string) PipelineRow { + step := `{"step":"lint","outcome":"passed"},{"step":"review","outcome":"` + outcome + `"` + if ended != "" { + step += `,"ended":"` + ended + `"` } + row, err := parsePipelineRow(json.RawMessage(`{"pipeline":{"pipeline":"b.1.m","module":"m","outcome":"passed","steps":[`+step+`}]}}`), "r#1") + if err != nil { + t.Fatal(err) + } + return row } - if _, found, err := firstApproval(json.RawMessage(`{"reviews":[]}`)); err != nil || found { - t.Fatalf("no reviews: %v %v", found, err) + rows := []PipelineRow{show("failed", "2026-10-12T09:01:00Z"), show("passed", "2026-10-12T09:05:00Z"), + show("accepted", "2026-10-12T09:04:00Z"), show("waiting", "")} + at, found := firstReview(rows) + if !found || at.Format(time.RFC3339) != "2026-10-12T09:04:00Z" { + t.Fatalf("%s %v", at, found) } - if _, _, err := firstApproval(json.RawMessage(`"nothing"`)); err == nil { - t.Fatal("an answer with no reviews read as none") + if _, found := firstReview(rows[:1]); found { + t.Fatal("a failed review read as passed") + } + if _, found := firstReview([]PipelineRow{{Outcome: NoRun}}); found { + t.Fatal("no run read as reviewed") + } + if _, err := parsePipelineRow(json.RawMessage(`{"error":"no such pipeline"}`), "r#1"); err == nil { + t.Fatal("a failed show read as a pipeline") } } diff --git a/modules/mesh-issues/cmd/mesh-issues/learn.go b/modules/mesh-issues/cmd/mesh-issues/learn.go index 1c39ed045..b86ac8c23 100644 --- a/modules/mesh-issues/cmd/mesh-issues/learn.go +++ b/modules/mesh-issues/cmd/mesh-issues/learn.go @@ -2,8 +2,7 @@ package main // A fix whose pull request the tracker keeps no record for (merged before the tracker heard the delivery, or linked after it // moved) learns its merge here, so the view's `delivered` event can cover it like any recorded fix (issue 1950). The delivery -// is asked first (its `checks` carries the forge's own pull request beside its delivery); the forge is asked when the delivery -// cannot say. One that neither can say is left for the next time and logged; a pull request closed without merging becomes +// is read from the seat `git`'s `pull`. One that neither can say is left for the next time and logged; a pull request closed without merging becomes // the withdrawn state the tracker already knows. import ( @@ -16,26 +15,27 @@ import ( stdio "git.novox.be/novox/mesh-sdk/go" ) -// pullFacts reads the forge's pull request block, as gitea_get_pull_request and mesh-delivery's checks both carry it. +// pullFacts reads the seat `git`'s pull request block (the verb `pull`: state open, merged or closed, the merge commit +// and when it merged, and the head commit). type pullFacts struct { - Merged bool `json:"merged"` - State string `json:"state"` - MergeCommitSHA string `json:"merge_commit_sha"` - MergedAt *time.Time `json:"merged_at"` + State string `json:"state"` + Head string `json:"head"` + MergeCommit string `json:"merge-commit"` + MergedAt *time.Time `json:"merged-at"` } // fromPull completes a delivery with what the forge's pull request says: whether it is merged or closed, the commit its // merge landed as and when. func (d *Delivery) fromPull(p pullFacts, repository string) { d.Pulled = true - d.Merged = p.Merged - d.Closed = p.State == "closed" && !p.Merged + d.Merged = p.State == "merged" + d.Closed = p.State == "closed" if d.Repository == "" { d.Repository = strings.ToLower(repository) } - if p.Merged { + if d.Merged { if d.MergedAs == "" { - d.MergedAs = p.MergeCommitSHA + d.MergedAs = p.MergeCommit } if d.MergedAt == nil { d.MergedAt = nonZero(p.MergedAt) @@ -43,36 +43,61 @@ func (d *Delivery) fromPull(p pullFacts, repository string) { } } -// busPull asks the forge for one pull request (gitea_get_pull_request). +// answerOf is a seat verb's answer body: the object itself, or the one an `answer` field wraps. A verb that failed +// answers `error` (and `error-kind`) alone, which a caller reads as a failure, never as none. +func answerOf(raw json.RawMessage, verb string) (map[string]json.RawMessage, error) { + var m map[string]json.RawMessage + if json.Unmarshal(raw, &m) != nil { + return nil, fmt.Errorf("%s is not readable: %.200s", verb, raw) + } + if w, ok := m["answer"]; ok { + var inner map[string]json.RawMessage + if json.Unmarshal(w, &inner) == nil { + m = inner + } + } + if e, ok := m["error"]; ok { + var why string + _ = json.Unmarshal(e, &why) + return nil, fmt.Errorf("%s failed: %.200s", verb, why) + } + return m, nil +} + +// parsePull reads the answer of the seat `git`'s `pull` into a delivery, and answers the pull request's head commit. +func parsePull(raw json.RawMessage, repository string) (Delivery, string, error) { + m, err := answerOf(raw, "git.pull") + if err != nil { + return Delivery{}, "", err + } + var p pullFacts + if json.Unmarshal(m["pull"], &p) != nil || p.State == "" { + return Delivery{}, "", errors.New("git.pull answered no pull request") + } + var d Delivery + d.fromPull(p, repository) + return d, p.Head, nil +} + +// askPull asks the seat `git` for one pull request by its repository (owner/name) and number. +func askPull(repository string, number string) (Delivery, string, error) { + var n int + if _, err := fmt.Sscanf(number, "%d", &n); err != nil || n <= 0 { + return Delivery{}, "", fmt.Errorf("%q is not a pull request number", number) + } + raw, err := stdio.Ask("seat:git.pull", map[string]any{"repository": repository, "number": n}) + if err != nil { + return Delivery{}, "", err + } + return parsePull(raw, repository) +} + +// busPull asks the seat `git` for one pull request (`pull`): what the delivery of a fix is learned from, now that +// the delivery's own `checks` is retired. func busPull(owner string) DeliveryState { return func(repository, number string) (Delivery, error) { - var n int - if _, err := fmt.Sscanf(number, "%d", &n); err != nil || n <= 0 { - return Delivery{}, fmt.Errorf("%q is not a pull request number", number) - } - raw, err := stdio.Ask("gitea.gitea_get_pull_request", map[string]any{"owner": owner, "repo": repository, "number": n}) - if err != nil { - return Delivery{}, err - } - var out struct { - Pull *pullFacts `json:"pull"` - Answer struct { - Pull *pullFacts `json:"pull"` - } `json:"answer"` - } - if json.Unmarshal(raw, &out) != nil { - return Delivery{}, errors.New("the forge's gitea_get_pull_request is not readable") - } - p := out.Pull - if p == nil { - p = out.Answer.Pull - } - if p == nil { - return Delivery{}, errors.New("the forge's gitea_get_pull_request answered no pull request") - } - var d Delivery - d.fromPull(*p, owner+"/"+repository) - return d, nil + d, _, err := askPull(owner+"/"+repository, number) + return d, err } } @@ -178,5 +203,5 @@ func (t *Tracker) LearnFixes(n uint64) (map[string]any, error) { out[pr] = f.State } } - return map[string]any{"number": n, "fixes": out, "delivery": DeliveryLine(is)}, nil + return map[string]any{"number": n, "fixes": out, "delivery": DeliveryLine(is), "pipelines": t.pipelinesOf(is)}, nil } diff --git a/modules/mesh-issues/cmd/mesh-issues/learn_test.go b/modules/mesh-issues/cmd/mesh-issues/learn_test.go index b73b27b5c..d02489f66 100644 --- a/modules/mesh-issues/cmd/mesh-issues/learn_test.go +++ b/modules/mesh-issues/cmd/mesh-issues/learn_test.go @@ -8,10 +8,10 @@ import ( "time" ) -// The forge's answer for novox/mesh-delivery#5, read from gitea.gitea_get_pull_request on 2026-10-27 (shortened): merged, closed, -// its merge commit and moment. mesh-delivery's checks carries the same `pull` block and no `delivery` block for it. -const forgePullMerged = `{"pull":{"base":"main","head":"feat/delivery-parts","merged":true,"merged_at":"2026-10-19T18:05:16Z",` + - `"merge_commit_sha":"1325c8b452c8fa65c478af6815887870a1f42e7b","number":5,"state":"closed"}}` +// The seat git's answer for novox/mesh-delivery#5 (shortened): merged, its merge commit and moment. +const forgePullMerged = `{"pull":{"repository":"novox/mesh-delivery","number":5,"base":"main","branch":"feat/delivery-parts",` + + `"head":"4444444444444444444444444444444444444444","state":"merged","merged-at":"2026-10-19T18:05:16Z",` + + `"merge-commit":"1325c8b452c8fa65c478af6815887870a1f42e7b"}}` func TestAPullRequestWithNoFixRecordLearnsItsMerge(t *testing.T) { mergedAt := time.Date(2026, 10, 19, 18, 5, 16, 0, time.UTC) @@ -110,19 +110,72 @@ func TestAFixMergedBeforeTheTrackerHeardDeliveriesIsDeliveredByTheNextEventOfIts } } -func TestTheForgesRealPullAnswerIsReadAsMerged(t *testing.T) { - var out struct { - Pull pullFacts `json:"pull"` +func TestTheSeatGitsPullAnswerIsReadAsMerged(t *testing.T) { + d, head, err := parsePull(json.RawMessage(forgePullMerged), "novox/mesh-delivery") + if err != nil || head != "4444444444444444444444444444444444444444" { + t.Fatalf("%v %s", err, head) } - if err := json.Unmarshal([]byte(forgePullMerged), &out); err != nil { - t.Fatal(err) - } - var d Delivery - d.fromPull(out.Pull, "novox/mesh-delivery") if !d.Pulled || !d.Merged || d.Closed || d.MergedAs != "1325c8b452c8fa65c478af6815887870a1f42e7b" || d.MergedAt == nil || d.MergedAt.Format(time.RFC3339) != "2026-10-19T18:05:16Z" || d.Repository != "novox/mesh-delivery" { t.Fatalf("%+v", d) } + if d, _, _ := parsePull(json.RawMessage(`{"pull":{"state":"closed","head":"x"}}`), "novox/r"); !d.Closed || d.Merged { + t.Fatalf("closed: %+v", d) + } + if d, _, _ := parsePull(json.RawMessage(`{"pull":{"state":"open","head":"x"}}`), "novox/r"); d.Closed || d.Merged { + t.Fatalf("open: %+v", d) + } + for _, bad := range []string{`{"error":"no such pull request","error-kind":"not-found"}`, `{"pull":{}}`, `nothing`} { + if _, _, err := parsePull(json.RawMessage(bad), "novox/r"); err == nil { + t.Fatalf("%s read as a pull request", bad) + } + } +} + +func TestTheSeatGitsContainsAnswerIsReadAndAFailureIsNeverNo(t *testing.T) { + for raw, want := range map[string]bool{`{"commit":"a","in":"b","contains":true}`: true, `{"contains":false,"ahead":2}`: false} { + if got, err := parseContains(json.RawMessage(raw)); err != nil || got != want { + t.Fatalf("%s: %v %v", raw, got, err) + } + } + for _, bad := range []string{`{"error":"forge down","error-kind":"failed"}`, `{}`} { + if _, err := parseContains(json.RawMessage(bad)); err == nil { + t.Fatalf("%s read as an answer", bad) + } + } +} + +// design 47 G §654: a repository of two modules shows two rows with their steps; a commit the runner's list answers none for +// shows "no run"; an unreadable runner is said, never read as none. +func TestAPullRequestShowsOnePipelineRowPerModuleAndNoRunWhereThereIsNone(t *testing.T) { + keys, err := parsePipelineKeys(json.RawMessage(`{"pipelines":[{"pipeline":"b.1.a","module":"a"},{"pipeline":"b.1.b","module":"b"}]}`)) + if err != nil || len(keys) != 2 { + t.Fatalf("%v %v", keys, err) + } + if keys, err := parsePipelineKeys(json.RawMessage(`{"pipelines":[]}`)); err != nil || len(keys) != 0 { + t.Fatalf("%v %v", keys, err) + } + h := newHarness(t) + n := fixIssue(t, h, "mesh-catalog#50") + h.Pipelines = func(repo, number string) ([]PipelineRow, error) { + return []PipelineRow{ + {PullRequest: repo + "#" + number, Module: "a", Outcome: "passed", Steps: []StepRow{{"unit", "passed"}, {"review", "passed"}}}, + {PullRequest: repo + "#" + number, Module: "b", Outcome: "failed", Steps: []StepRow{{"unit", "failed"}}}}, nil + } + v, err := h.View(n) + if err != nil || len(v.Pipelines) != 2 || v.Pipelines[1].Module != "b" || len(v.Pipelines[0].Steps) != 2 { + t.Fatalf("%+v %v", v, err) + } + h.Pipelines = func(repo, number string) ([]PipelineRow, error) { + return []PipelineRow{{PullRequest: repo + "#" + number, Outcome: NoRun}}, nil + } + if v, _ := h.View(n); len(v.Pipelines) != 1 || v.Pipelines[0].Outcome != "no run" { + t.Fatalf("%+v", v.Pipelines) + } + h.Pipelines = func(string, string) ([]PipelineRow, error) { return nil, errors.New("runner down") } + if v, _ := h.View(n); len(v.Pipelines) != 1 || !strings.HasPrefix(v.Pipelines[0].Outcome, "not readable") { + t.Fatalf("%+v", v.Pipelines) + } } // A merge learned late is kept as of the moment it was learned: the stuck bound runs from then, so an old merge raises no diff --git a/modules/mesh-issues/cmd/mesh-issues/main.go b/modules/mesh-issues/cmd/mesh-issues/main.go index e4602f43e..712cf4173 100644 --- a/modules/mesh-issues/cmd/mesh-issues/main.go +++ b/modules/mesh-issues/cmd/mesh-issues/main.go @@ -103,12 +103,12 @@ func main() { return "novox" } t.Deliveries = func(repository, number string) (Delivery, error) { - return busDeliveryState(forgeOwner())(repository, number) - } - t.Pull = func(repository, number string) (Delivery, error) { return busPull(forgeOwner())(repository, number) } t.Approval = busApproval(forgeOwner) + t.Pipelines = func(repository, number string) ([]PipelineRow, error) { + return busPipelines(forgeOwner())(repository, number) + } t.Contains = busContains t.Reached = busReach if len(os.Args) > 1 && os.Args[1] == "dry-run" { diff --git a/modules/mesh-issues/cmd/mesh-issues/manifest_test.go b/modules/mesh-issues/cmd/mesh-issues/manifest_test.go index 7468d3616..9cd24d027 100644 --- a/modules/mesh-issues/cmd/mesh-issues/manifest_test.go +++ b/modules/mesh-issues/cmd/mesh-issues/manifest_test.go @@ -111,8 +111,8 @@ func TestItDeclaresAndHoldsTheIssueTrackerSeatOnceForTheMesh(t *testing.T) { // The duplicate check hears the tracker's own opens and asks the agent module on its node (ADR 0278 §1, §5); // the import and a reservation read the record through the records module (issue 424). if !reflect.DeepEqual(m.Consumes, []string{"mesh-issues." + EventOpened, "mesh-issues." + EventMoved, "mesh-delivery-gate.transition", DeliveredEvent}) || - !reflect.DeepEqual(m.Invokes, []string{"claude-code.claude_code_judge", "seat:mesh-delivery.checks", "seat:operator-channel.notify", - "gitea.gitea_pull_reviews", "gitea.gitea_contains", "gitea.gitea_get_pull_request", "records.records_status", "records.records_list", "records.records_read", "mesh-knowledge.knowledge_highest"}) { + !reflect.DeepEqual(m.Invokes, []string{"claude-code.claude_code_judge", "seat:operator-channel.notify", + "seat:git.pull", "seat:git.contains", "seat:mesh-pipeline-runner.list", "seat:mesh-pipeline-runner.show", "records.records_status", "records.records_list", "records.records_read", "mesh-knowledge.knowledge_highest"}) { t.Fatalf("consumes %v, invokes %v", m.Consumes, m.Invokes) } // The tools bundle, and the archive the build packs files/ into for the templates (novox/hq ADR 0349 §2). diff --git a/modules/mesh-issues/cmd/mesh-issues/pipelines.go b/modules/mesh-issues/cmd/mesh-issues/pipelines.go new file mode 100644 index 000000000..78465a7a4 --- /dev/null +++ b/modules/mesh-issues/cmd/mesh-issues/pipelines.go @@ -0,0 +1,155 @@ +package main + +// A pull request's pipelines, read from the runner (design 47 G §654, A-75): the seat `mesh-pipeline-runner`'s `list`, +// given the pull request's head commit, answers that commit's pipelines, one per module of the repository it builds, and +// `show` of each answers it whole with each step's result, the review step's among them. The forge's own reviews are read +// nowhere: G keeps no review record. + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// NoRun is a pull request's head commit the runner's list answers no pipeline for. +const NoRun = "no run" + +// StepRow is one step of a pipeline: the step and its outcome. +type StepRow struct { + Step string `json:"step"` + Outcome string `json:"outcome"` +} + +// PipelineRow is one module's pipeline of a pull request's head commit, with its steps; a commit with no pipeline is one +// row whose outcome is NoRun. +type PipelineRow struct { + PullRequest string `json:"pull-request"` + Module string `json:"module,omitempty"` + Pipeline string `json:"pipeline,omitempty"` + Outcome string `json:"outcome"` + Steps []StepRow `json:"steps,omitempty"` + // reviewed is when the review step ended in a pass or an acceptance; zero when it did not. + reviewed time.Time +} + +// Pipelines reads a pull request's pipeline rows; a test stubs the asking. repository is the name in the forge's owner. +type Pipelines func(repository string, number string) ([]PipelineRow, error) + +// parsePipelineKeys reads the keys of the pipelines a `list` answers. +func parsePipelineKeys(raw json.RawMessage) ([]string, error) { + m, err := answerOf(raw, "mesh-pipeline-runner.list") + if err != nil { + return nil, err + } + var l []struct { + Pipeline string `json:"pipeline"` + } + if json.Unmarshal(m["pipelines"], &l) != nil { + return nil, fmt.Errorf("mesh-pipeline-runner.list answered no pipelines: %.200s", raw) + } + var keys []string + for _, p := range l { + if p.Pipeline != "" { + keys = append(keys, p.Pipeline) + } + } + return keys, nil +} + +// parsePipelineRow reads the answer of `show` into a row of the pull request. +func parsePipelineRow(raw json.RawMessage, pr string) (PipelineRow, error) { + m, err := answerOf(raw, "mesh-pipeline-runner.show") + if err != nil { + return PipelineRow{}, err + } + var p struct { + Pipeline string `json:"pipeline"` + Module string `json:"module"` + Outcome string `json:"outcome"` + Steps []struct { + Step string `json:"step"` + Outcome string `json:"outcome"` + Ended *time.Time `json:"ended"` + } `json:"steps"` + } + if json.Unmarshal(m["pipeline"], &p) != nil || p.Module == "" { + return PipelineRow{}, fmt.Errorf("mesh-pipeline-runner.show answered no pipeline: %.200s", raw) + } + row := PipelineRow{PullRequest: pr, Module: p.Module, Pipeline: p.Pipeline, Outcome: p.Outcome} + for _, s := range p.Steps { + row.Steps = append(row.Steps, StepRow{Step: s.Step, Outcome: s.Outcome}) + if s.Step == "review" && (s.Outcome == "passed" || s.Outcome == "accepted") && s.Ended != nil { + row.reviewed = s.Ended.UTC() + } + } + return row, nil +} + +// busPipelines reads a pull request's head from the seat `git`, then its pipelines from the runner: `list` given the head +// commit, `show` of each. +func busPipelines(owner string) Pipelines { + return func(repository, number string) ([]PipelineRow, error) { + pr := repository + "#" + number + _, head, err := askPull(owner+"/"+repository, number) + if err != nil { + return nil, err + } + raw, err := stdio.Ask("seat:mesh-pipeline-runner.list", map[string]any{"commit": head}) + if err != nil { + return nil, err + } + keys, err := parsePipelineKeys(raw) + if err != nil { + return nil, err + } + if len(keys) == 0 { + return []PipelineRow{{PullRequest: pr, Outcome: NoRun}}, nil + } + var rows []PipelineRow + for _, k := range keys { + raw, err := stdio.Ask("seat:mesh-pipeline-runner.show", map[string]any{"pipeline": k}) + if err != nil { + return nil, err + } + row, err := parsePipelineRow(raw, pr) + if err != nil { + return nil, err + } + rows = append(rows, row) + } + return rows, nil + } +} + +// firstReview is when the earliest review step passed among the rows. +func firstReview(rows []PipelineRow) (time.Time, bool) { + var first time.Time + for _, r := range rows { + if !r.reviewed.IsZero() && (first.IsZero() || r.reviewed.Before(first)) { + first = r.reviewed + } + } + return first, !first.IsZero() +} + +// pipelinesOf is the rows of every live fix of an issue: one per module's pipeline, with its steps, and NoRun where the +// runner knows none. A fix whose runner could not be read is one row saying so. +func (t *Tracker) pipelinesOf(is *Issue) []PipelineRow { + if t.Pipelines == nil { + return nil + } + var out []PipelineRow + for _, pr := range liveFixes(is) { + repo, number, _ := strings.Cut(pr, "#") + rows, err := t.Pipelines(repo, number) + if err != nil { + out = append(out, PipelineRow{PullRequest: pr, Outcome: "not readable: " + cut(err.Error(), 200)}) + continue + } + out = append(out, rows...) + } + return out +} diff --git a/modules/mesh-issues/cmd/mesh-issues/ready.go b/modules/mesh-issues/cmd/mesh-issues/ready.go index 717377390..78b6f48d5 100644 --- a/modules/mesh-issues/cmd/mesh-issues/ready.go +++ b/modules/mesh-issues/cmd/mesh-issues/ready.go @@ -298,6 +298,9 @@ type View struct { Ready bool `json:"ready"` // Delivery is whether its fix is delivered, in words (ADR 0281). Delivery string `json:"delivery,omitempty"` + // Pipelines is each live fix's pipelines from the runner: one row per module with its steps, "no run" where the runner + // knows none (design 47 G A-75). + Pipelines []PipelineRow `json:"pipelines,omitempty"` // WorkerStale says nothing was done on it by its worker, or anyone, for worker-stale-after (ADR 0284 §4). WorkerStale bool `json:"worker-stale,omitempty"` // DeliveredNotResolved and Abandoned are list's flags, said of this issue (novox/hq issue 483). @@ -317,7 +320,7 @@ func (t *Tracker) View(n uint64) (*View, error) { } now, staleAfter := t.now(), t.workerStaleAfter() return &View{Issue: is, NeededBy: neededBy(all, n), WaitsFor: waitsFor(all, is), Ready: isReady(all, is), - Delivery: DeliveryLine(is), WorkerStale: is.Worker != nil && is.Worker.stale(now, staleAfter), + Delivery: DeliveryLine(is), Pipelines: t.pipelinesOf(is), WorkerStale: is.Worker != nil && is.Worker.stale(now, staleAfter), DeliveredNotResolved: deliveredNotResolved(is), Abandoned: abandoned(is, now, staleAfter)}, nil } diff --git a/modules/mesh-issues/cmd/mesh-issues/tracker.go b/modules/mesh-issues/cmd/mesh-issues/tracker.go index 64ebd3c82..36ff789c7 100644 --- a/modules/mesh-issues/cmd/mesh-issues/tracker.go +++ b/modules/mesh-issues/cmd/mesh-issues/tracker.go @@ -37,12 +37,14 @@ type Tracker struct { Judge Judgement // Deliveries answers a pull request's delivery (ADR 0279 §3, ADR 0281); nil asks nothing. Deliveries DeliveryState - // Pull reads one pull request from the forge (gitea_get_pull_request) for a fix the delivery cannot say anything of; nil asks nothing. + // Pull reads one pull request from the seat `git` (pull) for a fix the delivery cannot say anything of; nil asks nothing. Pull DeliveryState // askedAt is when each pull request without a record was last asked by the sweep, so the least recently asked goes first. askedMu sync.Mutex askedAt map[string]time.Time - // Contains asks the forge whether a commit is contained in another (gitea_contains); nil asks nothing, and a delivered + // Pipelines reads a pull request's pipelines from the runner (design 47 G A-75); nil asks nothing. + Pipelines Pipelines + // Contains asks the seat `git` whether a commit is contained in another (contains); nil asks nothing, and a delivered // revision then marks only the fixes it names (OnDelivered). Contains func(owner, repo, commit, in string) (bool, error) // Reached asks the delivery view what reached the machines of one repository now (view_reach); nil asks nothing. A delivered diff --git a/modules/mesh-issues/module.json b/modules/mesh-issues/module.json index f35a86906..2ae2304be 100644 --- a/modules/mesh-issues/module.json +++ b/modules/mesh-issues/module.json @@ -74,11 +74,11 @@ ], "invokes": [ "claude-code.claude_code_judge", - "seat:mesh-delivery.checks", "seat:operator-channel.notify", - "gitea.gitea_pull_reviews", - "gitea.gitea_contains", - "gitea.gitea_get_pull_request", + "seat:git.pull", + "seat:git.contains", + "seat:mesh-pipeline-runner.list", + "seat:mesh-pipeline-runner.show", "records.records_status", "records.records_list", "records.records_read",