Merge branch 'feat/47-G-issues6' into feat/47-G-join8
This commit is contained in:
@@ -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 (`<repository>#<number>`) 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 <sha>`, 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
// <sha>", 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 <sha>", 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user