Merge remote-tracking branch 'origin/main' into feat/900-node-files
mesh/merge-gate head d4a9260847ec merged into trunk 8b53a3663244 beside mesh-controller#360@f4ddddd5 — pass: builds new: modules/mesh-files, sent nowhere; …
mesh/repo-check head d4a9260847ec merged into trunk 8b53a3663244 beside mesh-controller#360@f4ddddd5 — pass: its merge-check.sh passed, each part in its to…
mesh/delivery delivered
mesh/delivery-group group feat/900-node-files delivered: every member is delivered

This commit is contained in:
2026-10-16 16:15:45 +02:00
7 changed files with 319 additions and 9 deletions
@@ -404,6 +404,9 @@ type fakeForge struct {
// merges, comments and unlabels are what was asked of the forge for ready marks (novox/hq issue 722). // merges, comments and unlabels are what was asked of the forge for ready marks (novox/hq issue 722).
merges, comments, unlabels []string merges, comments, unlabels []string
refuse error refuse error
// calls are the labels and comments in the order asked (novox/hq issue 913); labelErr fails a Label.
calls []string
labelErr error
} }
func (f *fakeForge) OpenPulls() ([]OpenPull, error) { func (f *fakeForge) OpenPulls() ([]OpenPull, error) {
@@ -670,6 +673,17 @@ func (f *fakeForge) Comment(owner, repo string, number int, body string) error {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
f.comments = append(f.comments, fmt.Sprintf("%s/%s#%d: %s", owner, repo, number, body)) f.comments = append(f.comments, fmt.Sprintf("%s/%s#%d: %s", owner, repo, number, body))
f.calls = append(f.calls, "comment "+body)
return nil
}
func (f *fakeForge) Label(owner, repo string, number int, name string) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.labelErr != nil {
return f.labelErr
}
f.calls = append(f.calls, fmt.Sprintf("label %s/%s#%d %s", owner, repo, number, name))
return nil return nil
} }
@@ -352,5 +352,19 @@ func tools(h *Holder, l *listening) []stdio.Tool {
"and tried again, given up) and when the last was, walks with no delivery yet, whether its events arrive, when " + "and tried again, given up) and when the last was, walks with no delivery yet, whether its events arrive, when " +
"the controller's walks were last read or why they could not be, and the last refusals.", "the controller's walks were last read or why they could not be, and the last refusals.",
Run: func(map[string]any) (any, error) { return h.Status(l.get()), nil }}, Run: func(map[string]any) (any, error) { return h.Status(l.get()), nil }},
{Name: seat + "mark-ready",
Description: "A reviewer's word that a delivery's head is approved: the mesh's own forge account puts the " +
"label mesh/ready on its pull request and then writes \"ready at <head>\", the order the forge counts. " +
"Refused when the delivery's commit is no longer its pull request's head. mesh-delivery then merges it " +
"once its checks pass (novox/hq ADR 0364, issue 913).",
Input: map[string]any{"type": "object", "properties": map[string]any{"id": str("the delivery's id (owner/repository@commit): the head reviewed"),
"reviewed-by": str("who reviewed it, as who@node"), "why": str("the review's verdict in a line")},
"required": []string{"id", "reviewed-by", "why"}},
Run: func(a map[string]any) (any, error) {
if err := need(a, "id", "reviewed-by", "why"); err != nil {
return nil, err
}
return h.MarkReady(strArg(a, "id"), strArg(a, "reviewed-by"), strArg(a, "why"))
}},
} }
} }
@@ -0,0 +1,143 @@
package main
import (
"errors"
"strings"
"testing"
"time"
)
// **A reviewer's word marks the pull request ready in the order the forge counts** (novox/hq issue 913): the label,
// then the comment "ready at <full sha>". Written the other way round the mark's head is empty (issue 873).
const fullHead = "3333333333333333333333333333333333333333"
// readyDelivery is a delivery of a pull request whose head is fullHead, its check passed, and the forge's open pull.
func readyDelivery(t *testing.T, w *world) string {
t.Helper()
w.h.PullUpdated(pr("novox/app", 7, fullHead, "feat/x", "src/a.go"))
w.h.Checked(verdict("novox/app", 7, fullHead, "pass"))
w.settleAll()
w.forge.open = []OpenPull{{Owner: "novox", Repo: "app", Number: 7, Base: "main", HeadSHA: fullHead}}
return IDOf("novox/app", fullHead)
}
func TestMarkReadyLabelsThenCommentsTheFullHead(t *testing.T) {
w := newWorld(t)
id := readyDelivery(t, w)
answer, err := w.h.MarkReady(id, "fix-913@shanks", "the change is right")
if err != nil {
t.Fatal(err)
}
if len(w.forge.calls) != 2 || w.forge.calls[0] != "label novox/app#7 mesh/ready" {
t.Fatalf("the forge was asked %q; wanted the label first", w.forge.calls)
}
c := strings.TrimPrefix(w.forge.calls[1], "comment ")
if first, _, _ := strings.Cut(c, "\n"); first != "ready at "+fullHead || !strings.Contains(c, "\n\nReviewed by fix-913@shanks: the change is right.") ||
!strings.Contains(c, "mark-ready") {
t.Fatalf("the comment is %q", c)
}
d := w.delivery(id)
if d.Ready == nil || d.Ready.Head != fullHead || d.Ready.By != "fix-913@shanks" || d.Ready.Seen.IsZero() {
t.Fatalf("the mark is not kept: %+v", d.Ready)
}
if !strings.Contains(answer, "marked") {
t.Fatalf("answer %q", answer)
}
}
func TestMarkReadyRefusesAMovedHeadAndAsksTheForgeNothing(t *testing.T) {
w := newWorld(t)
id := readyDelivery(t, w)
w.forge.open[0].HeadSHA = pushed
_, err := w.h.MarkReady(id, "fix-913@shanks", "ok")
if err == nil || !strings.Contains(err.Error(), "the head is now "+pushed) {
t.Fatalf("a moved head: %v", err)
}
if len(w.forge.calls) != 0 {
t.Fatalf("the forge was asked %q", w.forge.calls)
}
}
func TestMarkReadyRefusesAnUnknownAndAFinalDelivery(t *testing.T) {
w := newWorld(t)
id := readyDelivery(t, w)
if _, err := w.h.MarkReady("novox/app@nothing", "a@b", "ok"); err == nil || !strings.Contains(err.Error(), "deliveries") {
t.Fatalf("unknown id: %v", err)
}
w.h.mu.Lock()
w.h.deliveries[id].State = Stopped
w.h.mu.Unlock()
if _, err := w.h.MarkReady(id, "a@b", "ok"); err == nil {
t.Fatal("a final delivery was marked")
}
if len(w.forge.calls) != 0 {
t.Fatalf("the forge was asked %q", w.forge.calls)
}
}
func TestMarkReadyWritesNoCommentWhenTheLabelFails(t *testing.T) {
w := newWorld(t)
id := readyDelivery(t, w)
w.forge.labelErr = errors.New("the forge refused gitea_api: no such label")
_, err := w.h.MarkReady(id, "a@b", "ok")
if err == nil || !strings.Contains(err.Error(), "no such label") {
t.Fatalf("label failure: %v", err)
}
if len(w.forge.comments) != 0 {
t.Fatalf("commented: %v", w.forge.comments)
}
if w.delivery(id).Ready != nil {
t.Fatal("marked though the label failed")
}
}
func TestClearMarkWithAnEmptyHeadSaysWhatWasWrongOnceItStayedEmpty(t *testing.T) {
w := newWorld(t)
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, "")}
w.h.SeeOpenPulls()
w.settleAll()
if len(w.forge.comments) != 0 || len(w.forge.unlabels) != 0 {
t.Fatalf("an empty-head mark seen once was cleared: %v", w.forge.comments)
}
w.later(readyTryEvery + time.Second)
w.h.SeeOpenPulls()
w.settleAll()
if len(w.forge.comments) != 1 || !strings.Contains(w.forge.comments[0], "no \"ready at\" comment by a listed account") ||
!strings.Contains(w.forge.comments[0], "the head is now "+readyHead) ||
!strings.Contains(w.forge.comments[0], "mesh-delivery.mark-ready") || strings.Contains(w.forge.comments[0], "pushed after it") {
t.Fatalf("comments: %v", w.forge.comments)
}
}
func TestAMarkReadyJustMadeIsNotClearedByAReadThatPredatesItsComment(t *testing.T) {
w := newWorld(t)
id := readyDelivery(t, w)
if _, err := w.h.MarkReady(id, "a@b", "ok"); err != nil {
t.Fatal(err)
}
w.later(readyTryEvery + time.Second) // the label alone was read long enough ago to count as stale...
w.forge.open = []OpenPull{markedPull("novox/app", 7, fullHead, "")}
w.h.SeeOpenPulls()
w.h.mu.Lock()
w.h.deliveries[id].Ready.Seen = w.now // ...but the mark was made just now
w.h.marks["novox/app#7"].firstEmpty = w.now.Add(-2 * readyTryEvery)
w.h.mu.Unlock()
before := len(w.forge.comments)
w.settleAll()
if len(w.forge.unlabels) != 0 || len(w.forge.comments) != before {
t.Fatalf("a mark just made was cleared: %v %v", w.forge.unlabels, w.forge.comments)
}
}
func TestClearMarkWithAnOlderHeadKeepsTheOldWording(t *testing.T) {
w := newWorld(t)
w.h.PullUpdated(pr("novox/app", 7, pushed, "feat/x", "src/a.go"))
w.forge.open = []OpenPull{markedPull("novox/app", 7, pushed, readyHead)}
w.h.SeeOpenPulls()
w.settleAll()
if len(w.forge.comments) != 1 || !strings.Contains(w.forge.comments[0], "pushed after it") {
t.Fatalf("comments: %v", w.forge.comments)
}
}
@@ -96,6 +96,8 @@ type Forge interface {
Merge(owner, repo string, number int, head string) error Merge(owner, repo string, number int, head string) error
// Comment adds a comment to a pull request. // Comment adds a comment to a pull request.
Comment(owner, repo string, number int, body string) error Comment(owner, repo string, number int, body string) error
// Label puts a label on a pull request, by the label's name (novox/hq issue 913).
Label(owner, repo string, number int, name string) error
// Unlabel takes a label off a pull request, by the label's id. // Unlabel takes a label off a pull request, by the label's id.
Unlabel(owner, repo string, number, label int) error Unlabel(owner, repo string, number, label int) error
} }
@@ -407,6 +409,47 @@ func (f toolForge) Comment(owner, repo string, number int, body string) error {
return f.tool("gitea_add_comment", map[string]any{"owner": owner, "repo": repo, "number": number, "body": body}) return f.tool("gitea_add_comment", map[string]any{"owner": owner, "repo": repo, "number": number, "body": body})
} }
// Label puts a label on a pull request. The forge's add-labels call takes label ids on every version (names only on
// the newer), so the id is read from the repository's labels by name first; a repository without the label is refused,
// not given one.
func (f toolForge) Label(owner, repo string, number int, name string) error {
id := 0
for page := 1; page <= 20 && id == 0; page++ {
raw, err := f.ask("gitea.gitea_api", map[string]any{"method": "GET",
"path": fmt.Sprintf("/repos/%s/%s/labels?limit=50&page=%d", owner, repo, page)})
if err != nil {
return err
}
answer, output, ok := answerOf(raw)
if !ok {
return fmt.Errorf("the forge refused gitea_api: %s", lastLine(output))
}
var got struct {
Result []struct {
ID int `json:"id"`
Name string `json:"name"`
} `json:"result"`
}
if err := json.Unmarshal(answer, &got); err != nil {
return fmt.Errorf("the forge's labels are not readable: %s", clip(string(answer), 200))
}
for _, l := range got.Result {
if l.Name == name {
id = l.ID
}
}
if len(got.Result) < 50 {
break
}
}
if id == 0 {
return fmt.Errorf("%s/%s has no label %s", owner, repo, name)
}
return f.tool("gitea_api", map[string]any{"method": "POST",
"path": fmt.Sprintf("/repos/%s/%s/issues/%d/labels", owner, repo, number),
"body": map[string]any{"labels": []int{id}}})
}
func (f toolForge) Unlabel(owner, repo string, number, label int) error { func (f toolForge) Unlabel(owner, repo string, number, label int) error {
return f.tool("gitea_api", map[string]any{"method": "DELETE", return f.tool("gitea_api", map[string]any{"method": "DELETE",
"path": fmt.Sprintf("/repos/%s/%s/issues/%d/labels/%d", owner, repo, number, label)}) "path": fmt.Sprintf("/repos/%s/%s/issues/%d/labels/%d", owner, repo, number, label)})
@@ -88,6 +88,9 @@ type marked struct {
pull OpenPull pull OpenPull
// cleared is when the mark was cleared, the label taken off: not cleared again within readyTryEvery. // cleared is when the mark was cleared, the label taken off: not cleared again within readyTryEvery.
cleared time.Time cleared time.Time
// firstEmpty is when a mark with no head (a label no "ready at" comment follows yet) was first read so: it is a
// mark not yet complete, and is cleared only once it has stayed so for readyTryEvery (novox/hq issue 873).
firstEmpty time.Time
} }
// pullKey is a pull request as a key: owner/repo#number. // pullKey is a pull request as a key: owner/repo#number.
@@ -109,6 +112,13 @@ func (h *Holder) seeMarks(open []OpenPull) {
was.pull.HeadSHA == p.HeadSHA { was.pull.HeadSHA == p.HeadSHA {
m.cleared = was.cleared m.cleared = was.cleared
} }
if p.Ready.Head == "" {
m.firstEmpty = h.Now()
if was := h.marks[k]; was != nil && was.pull.Ready != nil && was.pull.Ready.Head == "" &&
was.pull.HeadSHA == p.HeadSHA && !was.firstEmpty.IsZero() {
m.firstEmpty = was.firstEmpty
}
}
seen[k] = m seen[k] = m
} }
h.marks = seen h.marks = seen
@@ -129,6 +139,13 @@ func (h *Holder) readyAsks(now time.Time) []func() {
p, mark := m.pull, *m.pull.Ready p, mark := m.pull, *m.pull.Ready
repository := strings.ToLower(p.Owner + "/" + p.Repo) repository := strings.ToLower(p.Owner + "/" + p.Repo)
if !sameFull(mark.Head, p.HeadSHA) { if !sameFull(mark.Head, p.HeadSHA) {
if mark.Head == "" && now.Sub(m.firstEmpty) < readyTryEvery {
continue // the label first, its comment not yet: a mark not complete, not an old one
}
if d := h.deliveries[IDOf(repository, p.HeadSHA)]; d != nil && d.Ready != nil &&
sameFull(d.Ready.Head, p.HeadSHA) && now.Sub(d.Ready.Seen) < readyTryEvery {
continue // marked through mark-ready a moment ago: this read may predate its comment
}
if !m.cleared.IsZero() && now.Sub(m.cleared) < readyTryEvery { if !m.cleared.IsZero() && now.Sub(m.cleared) < readyTryEvery {
continue continue
} }
@@ -270,22 +287,41 @@ func (h *Holder) clearMark(k string, p OpenPull, mark ReadyMark) func() {
return func() { return func() {
err := h.Forge.Unlabel(p.Owner, p.Repo, p.Number, mark.LabelID) err := h.Forge.Unlabel(p.Owner, p.Repo, p.Number, mark.LabelID)
if err == nil { if err == nil {
err = h.Forge.Comment(p.Owner, p.Repo, p.Number, fmt.Sprintf("mesh-delivery: the ready mark at %s is cleared: "+ err = h.Forge.Comment(p.Owner, p.Repo, p.Number, clearedSaying(mark, p))
"the head is now %s, pushed after it. Mark the new head ready once it is reviewed.", mark.Head, p.HeadSHA))
} }
if err != nil { if mark.Head == "" {
h.Logf("[mesh-delivery] %s's ready mark at %s names an older head than %s, and could not be cleared yet: %v", if err != nil {
k, shortOf(mark.Head, 12), shortOf(p.HeadSHA, 12), err) h.Logf("[mesh-delivery] %s's ready label has no \"ready at <head>\" comment of a listed account after it, and could not be taken off yet: %v", k, err)
return return
}
h.Logf("[mesh-delivery] %s's ready label taken off: no \"ready at <head>\" comment of a listed account was written after it; mark-ready writes both in order", k)
} else {
if err != nil {
h.Logf("[mesh-delivery] %s's ready mark at %s names an older head than %s, and could not be cleared yet: %v",
k, shortOf(mark.Head, 12), shortOf(p.HeadSHA, 12), err)
return
}
h.Logf("[mesh-delivery] %s's ready mark at %s cleared: its head is now %s", k, shortOf(mark.Head, 12),
shortOf(p.HeadSHA, 12))
} }
h.Logf("[mesh-delivery] %s's ready mark at %s cleared: its head is now %s", k, shortOf(mark.Head, 12),
shortOf(p.HeadSHA, 12))
h.mu.Lock() h.mu.Lock()
delete(h.marks, k) delete(h.marks, k)
h.mu.Unlock() h.mu.Unlock()
} }
} }
// clearedSaying is what a cleared mark says on its pull request. A mark with no head is a label no "ready at <head>"
// comment of a listed account followed (novox/hq issue 873); one with a head names an older head than the pull's.
func clearedSaying(mark ReadyMark, p OpenPull) string {
if mark.Head == "" {
return "mesh-delivery: the label " + ReadyLabel + " is taken off: no \"ready at\" comment by a listed account " +
"follows both the label's last addition and the last push; the head is now " + p.HeadSHA + ". Review it and " +
"mark that head with `mesh-delivery.mark-ready`, which writes label and comment in the order the forge counts."
}
return fmt.Sprintf("mesh-delivery: the ready mark at %s is cleared: "+
"the head is now %s, pushed after it. Mark the new head ready once it is reviewed.", mark.Head, p.HeadSHA)
}
// sayOn comments on a delivery's pull request. // sayOn comments on a delivery's pull request.
func (h *Holder) sayOn(d *Delivery, body string) func() { func (h *Holder) sayOn(d *Delivery, body string) func() {
owner, repo, number, id := d.Owner(), d.Repo(), d.Number, d.ID owner, repo, number, id := d.Owner(), d.Repo(), d.Number, d.ID
@@ -331,6 +331,65 @@ func (h *Holder) Recheck(id, why, by string) (string, error) {
return fmt.Sprintf("%s is proposed again, its merge check pending; its check was asked (%s)", id, asked), nil return fmt.Sprintf("%s is proposed again, its merge check pending; its check was asked (%s)", id, asked), nil
} }
// MarkReady is a reviewer's word that a delivery's head is approved (novox/hq issue 913): the label mesh/ready on its
// pull request, and only after that call succeeded the comment "ready at <head>", the order the forge counts (a comment
// written before the label does not count, issue 873). Refused for an unknown, final or merged delivery, and when the
// pull request's head is no longer the delivery's commit.
func (h *Holder) MarkReady(id, reviewedBy, why string) (string, error) {
h.mu.Lock()
d := h.deliveries[id]
if d == nil {
h.mu.Unlock()
return "", fmt.Errorf("no delivery %q; `deliveries` lists them", id)
}
if d.State.Final() || d.MergedAs != "" {
h.mu.Unlock()
return "", fmt.Errorf("%s is %s: a delivery past its merge or final is not marked ready", id, d.State)
}
if d.Number == 0 {
h.mu.Unlock()
return "", fmt.Errorf("%s is not of a pull request: only a pull request is marked ready", id)
}
owner, repo, number, commit := d.Owner(), d.Repo(), d.Number, d.Commit
h.mu.Unlock()
open, err := h.Forge.OpenPulls()
if err != nil {
return "", fmt.Errorf("%s is not marked: the forge's open pull requests could not be read: %v", id, err)
}
head := ""
for _, p := range open {
if strings.EqualFold(p.Owner, owner) && strings.EqualFold(p.Repo, repo) && p.Number == number {
head = p.HeadSHA
}
}
if head == "" {
return "", fmt.Errorf("%s#%d is not an open pull request on the forge", owner+"/"+repo, number)
}
if !sameCommit(head, commit) {
return "", fmt.Errorf("the head is now %s; review and mark that one", head)
}
if err := h.Forge.Label(owner, repo, number, ReadyLabel); err != nil {
return "", fmt.Errorf("%s is not marked: the label %s could not be put on: %v", id, ReadyLabel, err)
}
body := fmt.Sprintf("ready at %s\n\nReviewed by %s: %s. Marked through mesh-delivery.mark-ready (novox/hq issue 913).",
head, reviewedBy, strings.TrimRight(strings.TrimSpace(why), "."))
if err := h.Forge.Comment(owner, repo, number, body); err != nil {
return "", fmt.Errorf("%s has the label %s but its comment \"ready at\" could not be written: %v; mark it again", id, ReadyLabel, err)
}
h.mu.Lock()
defer h.mu.Unlock()
now := h.Now()
d.Ready = &ReadyRun{Head: head, By: reviewedBy, MarkedAt: now.UTC().Format(time.RFC3339), Seen: now}
h.Logf("[mesh-delivery] %s is marked ready at %s by %s through mark-ready: %s", id, shortOf(head, 12), reviewedBy, why)
h.keep(d)
waits := h.readyWaits(d)
if waits == "" {
return fmt.Sprintf("%s is marked ready at %s; its merge is asked once the next tick reads the mark", id, shortOf(head, 12)), nil
}
return fmt.Sprintf("%s is marked ready at %s; its merge waits: %s", id, shortOf(head, 12), waits), nil
}
// checkingAgain puts a head's merge check statuses — those a branch's protection may require — back to // checkingAgain puts a head's merge check statuses — those a branch's protection may require — back to
// pending, so the forge refuses the merge until the fresh verdict sets them. The gate always, since every // pending, so the forge refuses the merge until the fresh verdict sets them. The gate always, since every
// verdict sets it; the repository's own check only when the head holds one, since a verdict that says none // verdict sets it; the repository's own check only when the head holds one, since a verdict that says none
+2 -1
View File
@@ -19,7 +19,8 @@
"release", "release",
"stop", "stop",
"close", "close",
"retire-history" "retire-history",
"mark-ready"
] ]
} }
], ],