Merge a pull request marked ready at its head once its checks pass (issue 722)
mesh/merge-gate out of date: judged against trunk 482232e8c51c, the trunk is now 7e3e62062bef; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk 482232e8c51c, the trunk is now 7e3e62062bef; judged again on a recheck or when it is merged
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate out of date: judged against trunk 482232e8c51c, the trunk is now 7e3e62062bef; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk 482232e8c51c, the trunk is now 7e3e62062bef; judged again on a recheck or when it is merged
mesh/delivery superseded: a newer head of the same pull request
The operator wants a reviewed pull request merged without a session calling the merge tool again after every recheck. The reviewer marks it ready (label mesh/ready and a comment "ready at <full head sha>"); the forge's open-pulls read carries the mark, and mesh-delivery merges through gitea_merge_pull_request at that head, in a group's order once its composed check passed. A push after the mark clears it; a failed check is said on the pull request and as a stalled line.
This commit is contained in:
@@ -396,6 +396,9 @@ type fakeForge struct {
|
||||
// open is what OpenPulls answers; openDown a forge that cannot say.
|
||||
open []OpenPull
|
||||
openDown bool
|
||||
// merges, comments and unlabels are what was asked of the forge for ready marks (novox/hq issue 722).
|
||||
merges, comments, unlabels []string
|
||||
refuse error
|
||||
}
|
||||
|
||||
func (f *fakeForge) OpenPulls() ([]OpenPull, error) {
|
||||
@@ -649,3 +652,25 @@ func aWalk(id, repo, commit string, waits bool) Walk {
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// Merge, Comment and Unlabel record what was asked (novox/hq issue 722); Merge answers refuse when set.
|
||||
func (f *fakeForge) Merge(owner, repo string, number int, head string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.merges = append(f.merges, fmt.Sprintf("%s/%s#%d@%s", owner, repo, number, head))
|
||||
return f.refuse
|
||||
}
|
||||
|
||||
func (f *fakeForge) Comment(owner, repo string, number int, body string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.comments = append(f.comments, fmt.Sprintf("%s/%s#%d: %s", owner, repo, number, body))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeForge) Unlabel(owner, repo string, number, label int) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.unlabels = append(f.unlabels, fmt.Sprintf("%s/%s#%d-%d", owner, repo, number, label))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -69,6 +69,8 @@ type Holder struct {
|
||||
leaveRefused map[string]string
|
||||
// tried counts the owed effects Flush tried since this owner started, by how each ended (novox/hq issue 407).
|
||||
tried owedTries
|
||||
// marks are the open pull requests read with a ready mark, by owner/repo#number (novox/hq issue 722, ready.go).
|
||||
marks map[string]*marked
|
||||
}
|
||||
|
||||
// owedTries is how many owed effects were tried since this owner started, and when the last was: three numbers
|
||||
@@ -120,6 +122,7 @@ func (h *Holder) init() {
|
||||
h.askTried = map[string]time.Time{}
|
||||
h.walkAsked = map[string]time.Time{}
|
||||
h.unchecked = map[string]unchecked{}
|
||||
h.marks = map[string]*marked{}
|
||||
h.walks = map[string]seenWalk{}
|
||||
h.orphans = map[string]time.Time{}
|
||||
h.asking = map[string]bool{}
|
||||
@@ -996,6 +999,12 @@ func (h *Holder) Tick() {
|
||||
for _, ask := range h.leaveStopped() {
|
||||
ask()
|
||||
}
|
||||
h.mu.Lock()
|
||||
asks := h.readyAsks(h.Now())
|
||||
h.mu.Unlock()
|
||||
for _, ask := range asks {
|
||||
ask()
|
||||
}
|
||||
}
|
||||
|
||||
// plan works out, under the lock, what to ask; each ask applies its own answer.
|
||||
|
||||
@@ -76,8 +76,9 @@ func run(h *Holder, l *listening) {
|
||||
logf("[mesh-delivery] the controller's walks cannot be read: %v", err)
|
||||
}
|
||||
}
|
||||
// Every open pull request, from the forge itself, every five minutes (novox/hq issue 347).
|
||||
if now.Sub(looked) >= 5*time.Minute {
|
||||
// Every open pull request, from the forge itself, every minute (novox/hq issues 347, 722): what says a
|
||||
// head nobody checks, and the ready marks a merge is asked on.
|
||||
if now.Sub(looked) >= time.Minute {
|
||||
looked = now
|
||||
h.SeeOpenPulls()
|
||||
}
|
||||
|
||||
@@ -65,7 +65,9 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) {
|
||||
want = append(want, "seat:"+ControllerSeat+"."+v)
|
||||
}
|
||||
want = append(want, "gitea.gitea_note_append", "gitea.gitea_delivery_view", "gitea.gitea_commit_status",
|
||||
"gitea.gitea_commit_statuses", "gitea.gitea_contains", "gitea.gitea_open_pulls")
|
||||
"gitea.gitea_commit_statuses", "gitea.gitea_contains", "gitea.gitea_open_pulls",
|
||||
// The merge on a ready mark (novox/hq issue 722): the merge tool, a comment, a label taken off.
|
||||
"gitea.gitea_merge_pull_request", "gitea.gitea_add_comment", "gitea.gitea_api")
|
||||
if !reflect.DeepEqual(m.Invokes, want) {
|
||||
t.Fatalf("invokes %v, wanted %v", m.Invokes, want)
|
||||
}
|
||||
|
||||
@@ -110,6 +110,9 @@ type Delivery struct {
|
||||
LetGo time.Time `json:"let_go"`
|
||||
LetGoWhy string `json:"let_go_why,omitempty"`
|
||||
|
||||
// Ready is what this owner did with its pull request's ready mark (novox/hq issue 722, ready.go).
|
||||
Ready *ReadyRun `json:"ready,omitempty"`
|
||||
|
||||
Transitions []Transition `json:"transitions,omitempty"`
|
||||
// Owed are the forge's and the bus's effects of its transitions not yet done: said only after the
|
||||
// delivery is kept, retried until done.
|
||||
|
||||
@@ -91,6 +91,13 @@ type Forge interface {
|
||||
// OpenPulls is every open pull request, with what its base requires and what its head has (novox/hq issue
|
||||
// 347). Reads only.
|
||||
OpenPulls() ([]OpenPull, error)
|
||||
// Merge asks the forge's merge tool to merge a pull request at the head named (gitea_merge_pull_request, novox/hq
|
||||
// issue 722): the one path every merge takes. Its refusal is the error, in its words.
|
||||
Merge(owner, repo string, number int, head string) error
|
||||
// Comment adds a comment to a pull request.
|
||||
Comment(owner, repo string, number int, body string) error
|
||||
// Unlabel takes a label off a pull request, by the label's id.
|
||||
Unlabel(owner, repo string, number, label int) error
|
||||
}
|
||||
|
||||
// ForgeChecks is what the forge says of a commit's checks (the gitea module's gitea_commit_statuses).
|
||||
@@ -392,6 +399,19 @@ func (f toolForge) Statuses(owner, repo string, number int, commit, base string)
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (f toolForge) Merge(owner, repo string, number int, head string) error {
|
||||
return f.tool("gitea_merge_pull_request", map[string]any{"owner": owner, "repo": repo, "number": number, "head": head})
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
|
||||
func (f toolForge) Unlabel(owner, repo string, number, label int) error {
|
||||
return f.tool("gitea_api", map[string]any{"method": "DELETE",
|
||||
"path": fmt.Sprintf("/repos/%s/%s/issues/%d/labels/%d", owner, repo, number, label)})
|
||||
}
|
||||
|
||||
func (f toolForge) OpenPulls() ([]OpenPull, error) {
|
||||
raw, err := f.ask("gitea.gitea_open_pulls", map[string]any{})
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Merge on ready (novox/hq issue 722).
|
||||
//
|
||||
// The operator, 2026-10-14: "the reviewer task should merge the request or mark the MR as ready to merge in any case".
|
||||
// Before, a reviewed pull request with green checks waited until a session called the forge's merge tool, and again
|
||||
// after every check the tool asked because the trunk had moved. Now the independent reviewer marks the pull request
|
||||
// ready at the head it approved — the forge label `mesh/ready` and a comment "ready at <full head sha>" (approving
|
||||
// through the forge is the operator's) — and this owner merges it, through the forge's own merge tool
|
||||
// (gitea_merge_pull_request), the one path every merge takes:
|
||||
//
|
||||
// - only at the head the mark names, and only while that is the pull request's head: a push after the mark clears
|
||||
// it — the label taken off, and said on the pull request — and the new head needs a mark of its own;
|
||||
// - only when its delivery is ready here: its check passed against its trunk's head. A verdict out of date is judged
|
||||
// again by the merge tool, which asks the check against the trunk's head itself (novox/hq ADR 0323 §5); the merge
|
||||
// is asked again once a minute, and lands on the passing verdict;
|
||||
// - a group member only at its turn in the group's order, and only once the group's composed check passed for its
|
||||
// members' heads as they are;
|
||||
// - a check that failed merges nothing: it is said once on the pull request, and as a stalled line, which the
|
||||
// controller raises as the condition `delivery.<id>.stalled`. So is a mark the merge tool refused for an hour.
|
||||
//
|
||||
// What this owner does with a mark is kept on the delivery (Ready), said in its log, and the merge's transitions name
|
||||
// it as their author.
|
||||
|
||||
// ReadyMark is a pull request's ready mark as the forge reads it (the gitea module's gitea_open_pulls): the head the
|
||||
// newest "ready at" comment names, who wrote it and when, and the label's id. Head is empty when the label is on and
|
||||
// no comment names a head.
|
||||
type ReadyMark struct {
|
||||
Head string `json:"head"`
|
||||
By string `json:"by,omitempty"`
|
||||
At string `json:"at,omitempty"`
|
||||
LabelID int `json:"label_id"`
|
||||
}
|
||||
|
||||
// ReadyLabel is the label that marks a pull request ready.
|
||||
const ReadyLabel = "mesh/ready"
|
||||
|
||||
// readyTryEvery is how often a marked head's merge is asked while the merge tool refuses it: a check it asked takes
|
||||
// minutes, and a refusal costs the forge a few reads.
|
||||
const readyTryEvery = time.Minute
|
||||
|
||||
// readyRefusedFor is how long the merge tool may refuse a marked head before it is said as stalled: longer than a
|
||||
// check takes (the controller's checkTimeout is 45 minutes), so a check on its way is never said.
|
||||
const readyRefusedFor = time.Hour
|
||||
|
||||
// ReadyRun is what this owner did with a delivery's ready mark: kept on the delivery and shown whole by `show`.
|
||||
type ReadyRun struct {
|
||||
// Head, By and MarkedAt are the mark: the head it names, who marked it, when.
|
||||
Head string `json:"head"`
|
||||
By string `json:"by,omitempty"`
|
||||
MarkedAt string `json:"marked_at,omitempty"`
|
||||
// Seen is when this owner first read the mark.
|
||||
Seen time.Time `json:"seen"`
|
||||
// Waits is what the merge waits on, when it waits: a verdict, the group's turn or its composed check.
|
||||
Waits string `json:"waits,omitempty"`
|
||||
// Tries is how many times the merge was asked; LastTry when, and Answer what the merge tool last answered.
|
||||
Tries int `json:"tries,omitempty"`
|
||||
LastTry time.Time `json:"last_try"`
|
||||
Answer string `json:"answer,omitempty"`
|
||||
// RefusedSince is when the merge tool began refusing it without a break.
|
||||
RefusedSince *time.Time `json:"refused_since,omitempty"`
|
||||
// Merged is when the merge tool merged it.
|
||||
Merged *time.Time `json:"merged,omitempty"`
|
||||
// Failed is why it is not merged and will not be on this head: its check failed. Said once on the pull request.
|
||||
Failed string `json:"failed,omitempty"`
|
||||
}
|
||||
|
||||
// mergedBy is who a merge this owner made on a ready mark is said to be by, in its transitions.
|
||||
func (r *ReadyRun) mergedBy() string {
|
||||
if r == nil || r.Merged == nil {
|
||||
return ""
|
||||
}
|
||||
by := byOwner + ", on the ready mark at " + shortOf(r.Head, 12)
|
||||
if r.By != "" {
|
||||
by += " by " + r.By
|
||||
}
|
||||
return by
|
||||
}
|
||||
|
||||
// marked is a pull request read with a ready mark.
|
||||
type marked struct {
|
||||
pull OpenPull
|
||||
// cleared is when the mark was cleared, the label taken off: not cleared again within readyTryEvery.
|
||||
cleared time.Time
|
||||
}
|
||||
|
||||
// pullKey is a pull request as a key: owner/repo#number.
|
||||
func pullKey(p OpenPull) string {
|
||||
return strings.ToLower(p.Owner+"/"+p.Repo) + fmt.Sprintf("#%d", p.Number)
|
||||
}
|
||||
|
||||
// seeMarks keeps the open pull requests that carry a ready mark; under the lock. A pull request no longer open or
|
||||
// no longer marked drops out.
|
||||
func (h *Holder) seeMarks(open []OpenPull) {
|
||||
seen := map[string]*marked{}
|
||||
for _, p := range open {
|
||||
if p.Ready == nil || p.HeadSHA == "" {
|
||||
continue
|
||||
}
|
||||
k := pullKey(p)
|
||||
m := &marked{pull: p}
|
||||
if was := h.marks[k]; was != nil && was.pull.Ready != nil && was.pull.Ready.Head == p.Ready.Head &&
|
||||
was.pull.HeadSHA == p.HeadSHA {
|
||||
m.cleared = was.cleared
|
||||
}
|
||||
seen[k] = m
|
||||
}
|
||||
h.marks = seen
|
||||
}
|
||||
|
||||
// sameFull is whether a mark names a head: the whole sha, or — read from an older forge module — at least twelve
|
||||
// characters of it.
|
||||
func sameFull(mark, head string) bool {
|
||||
mark, head = strings.ToLower(mark), strings.ToLower(head)
|
||||
return len(mark) >= 12 && strings.HasPrefix(head, mark)
|
||||
}
|
||||
|
||||
// readyAsks works out, under the lock, what each ready mark needs: a mark cleared, a merge asked, a failure said.
|
||||
// The asking happens outside the lock.
|
||||
func (h *Holder) readyAsks(now time.Time) []func() {
|
||||
var asks []func()
|
||||
for k, m := range h.marks {
|
||||
p, mark := m.pull, *m.pull.Ready
|
||||
repository := strings.ToLower(p.Owner + "/" + p.Repo)
|
||||
if !sameFull(mark.Head, p.HeadSHA) {
|
||||
if !m.cleared.IsZero() && now.Sub(m.cleared) < readyTryEvery {
|
||||
continue
|
||||
}
|
||||
m.cleared = now
|
||||
asks = append(asks, h.clearMark(k, p, mark))
|
||||
continue
|
||||
}
|
||||
d := h.deliveries[IDOf(repository, p.HeadSHA)]
|
||||
if d == nil || d.State.Final() || d.MergedAs != "" {
|
||||
continue // not announced yet, or past its merge
|
||||
}
|
||||
if d.Ready == nil || d.Ready.Head != mark.Head {
|
||||
d.Ready = &ReadyRun{Head: mark.Head, By: mark.By, MarkedAt: mark.At, Seen: now}
|
||||
h.Logf("[mesh-delivery] %s is marked ready at %s by %s: merged once its check passes against its trunk's head",
|
||||
d.ID, shortOf(mark.Head, 12), orNone(mark.By))
|
||||
h.keep(d)
|
||||
}
|
||||
r := d.Ready
|
||||
if r.Merged != nil {
|
||||
continue
|
||||
}
|
||||
if d.State == Rejected {
|
||||
if r.Failed == "" {
|
||||
r.Failed = "its check failed"
|
||||
if d.Check != nil {
|
||||
r.Failed += ": " + oneLine(d.Check.Summary)
|
||||
if j := d.Check.Judged(); j != "" {
|
||||
r.Failed += " (" + j + ")"
|
||||
}
|
||||
}
|
||||
r.Waits = ""
|
||||
h.Logf("[mesh-delivery] %s is marked ready and not merged: %s", d.ID, r.Failed)
|
||||
h.keep(d)
|
||||
asks = append(asks, h.sayOn(d, fmt.Sprintf("mesh-delivery: **not merged.** This pull request is marked "+
|
||||
"ready at %s, and %s. A new head, marked ready once reviewed, or a recheck that passes, merges it.",
|
||||
mark.Head, r.Failed)))
|
||||
}
|
||||
continue
|
||||
}
|
||||
r.Failed = ""
|
||||
waits := h.readyWaits(d)
|
||||
if waits != r.Waits {
|
||||
r.Waits = waits
|
||||
if waits != "" {
|
||||
h.Logf("[mesh-delivery] %s is marked ready and waits: %s", d.ID, waits)
|
||||
}
|
||||
h.keep(d)
|
||||
}
|
||||
if waits != "" || (!r.LastTry.IsZero() && now.Sub(r.LastTry) < readyTryEvery) {
|
||||
continue
|
||||
}
|
||||
r.Tries++
|
||||
r.LastTry = now
|
||||
h.keep(d)
|
||||
asks = append(asks, h.askMerge(d.ID, p, mark.Head))
|
||||
}
|
||||
return asks
|
||||
}
|
||||
|
||||
// readyWaits is what a marked delivery's merge waits on; empty when it is asked now. A ready delivery is asked; so is
|
||||
// a proposed one whose verdict went out of date and whose check nobody asked again — the merge tool asks it.
|
||||
func (h *Holder) readyWaits(d *Delivery) string {
|
||||
switch {
|
||||
case d.State == Ready:
|
||||
case d.State == Proposed && d.outOfDate() != "" && d.AskedAgain == nil:
|
||||
case d.State == Proposed && d.AskedAgain != nil:
|
||||
return "its check against the trunk's head is asked and has not answered"
|
||||
default:
|
||||
return "its check has not passed yet (" + string(d.State) + ")"
|
||||
}
|
||||
if d.Group == "" {
|
||||
return ""
|
||||
}
|
||||
return h.groupTurnToMerge(d)
|
||||
}
|
||||
|
||||
// groupTurnToMerge is why a group member may not merge yet; empty at its turn: the group's composed check passed for
|
||||
// its members' heads as they are and is not out of date, and every member before it in the group's order merged.
|
||||
func (h *Holder) groupTurnToMerge(d *Delivery) string {
|
||||
g := h.groups[d.Group]
|
||||
if g == nil {
|
||||
return ""
|
||||
}
|
||||
members := h.membersOf(g)
|
||||
if state, why := GroupState(g, members); state != "ready" && state != "delivering" {
|
||||
return fmt.Sprintf("its group %s is %s: %s", g.ID, state, why)
|
||||
}
|
||||
if c := g.Check; c == nil || (c.Verdict != "pass" && c.Verdict != "warning") || c.OutOfDate != "" ||
|
||||
!sameMembers(c.Commits, commitsOf(members)) {
|
||||
return "its group " + g.ID + "'s composed check has not passed for its members' heads"
|
||||
}
|
||||
if !containsString(g.Order, d.ID) {
|
||||
return "its group " + g.ID + "'s order is not worked out for it yet"
|
||||
}
|
||||
for _, id := range g.Order {
|
||||
if id == d.ID {
|
||||
return ""
|
||||
}
|
||||
if o := h.deliveries[id]; o != nil && o.MergedAs == "" && !o.State.Final() {
|
||||
return "its group " + g.ID + "'s order merges " + id + " first"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// askMerge asks the forge's merge tool to merge a marked head, and keeps its answer.
|
||||
func (h *Holder) askMerge(id string, p OpenPull, head string) func() {
|
||||
return func() {
|
||||
err := h.Forge.Merge(p.Owner, p.Repo, p.Number, head)
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
d := h.deliveries[id]
|
||||
if d == nil || d.Ready == nil {
|
||||
return
|
||||
}
|
||||
r, now := d.Ready, h.Now()
|
||||
if err == nil {
|
||||
r.Merged, r.Answer, r.RefusedSince = &now, "merged", nil
|
||||
h.Logf("[mesh-delivery] %s merged on its ready mark at %s, its check passed against its trunk's head", id,
|
||||
shortOf(head, 12))
|
||||
h.keep(d)
|
||||
return
|
||||
}
|
||||
answer := oneLine(err.Error())
|
||||
if r.RefusedSince == nil {
|
||||
r.RefusedSince = &now
|
||||
}
|
||||
if answer != r.Answer {
|
||||
h.Logf("[mesh-delivery] %s is marked ready, and the merge tool did not merge it (asked again in %s): %s", id,
|
||||
readyTryEvery, answer)
|
||||
}
|
||||
r.Answer = answer
|
||||
h.keep(d)
|
||||
}
|
||||
}
|
||||
|
||||
// clearMark takes a mark off a pull request whose head moved after it, and says so on it.
|
||||
func (h *Holder) clearMark(k string, p OpenPull, mark ReadyMark) func() {
|
||||
return func() {
|
||||
err := h.Forge.Unlabel(p.Owner, p.Repo, p.Number, mark.LabelID)
|
||||
if err == nil {
|
||||
err = h.Forge.Comment(p.Owner, p.Repo, p.Number, 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))
|
||||
}
|
||||
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.mu.Lock()
|
||||
delete(h.marks, k)
|
||||
h.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// sayOn comments on a delivery's pull request.
|
||||
func (h *Holder) sayOn(d *Delivery, body string) func() {
|
||||
owner, repo, number, id := d.Owner(), d.Repo(), d.Number, d.ID
|
||||
return func() {
|
||||
if err := h.Forge.Comment(owner, repo, number, body); err != nil {
|
||||
h.Logf("[mesh-delivery] %s: could not say on its pull request: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readyLines are the marked heads that will not merge: a check that failed at once, a merge refused past its bound;
|
||||
// as stalled lines, which the controller raises as conditions. Under the lock.
|
||||
func (h *Holder) readyLines(now time.Time) []StalledLine {
|
||||
var out []StalledLine
|
||||
for _, d := range h.deliveries {
|
||||
r := d.Ready
|
||||
if r == nil || r.Merged != nil || d.MergedAs != "" || d.State.Final() {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case r.Failed != "" && d.State == Rejected:
|
||||
out = append(out, StalledLine{ID: d.ID, Number: d.Number, State: d.State, For: now.Sub(r.Seen).Round(time.Second).String(),
|
||||
Bound: "0s", H2: "none: a new head marked ready, or a recheck that passes — the operator's",
|
||||
Says: fmt.Sprintf("%s#%d is marked ready at %s and not merged: %s", d.Repository, d.Number,
|
||||
shortOf(r.Head, 12), r.Failed)})
|
||||
case r.RefusedSince != nil && now.Sub(*r.RefusedSince) >= readyRefusedFor:
|
||||
out = append(out, StalledLine{ID: d.ID, Number: d.Number, State: d.State,
|
||||
For: now.Sub(*r.RefusedSince).Round(time.Second).String(), Bound: readyRefusedFor.String(),
|
||||
H2: "none: the merge is asked again each minute — the operator's",
|
||||
Says: fmt.Sprintf("%s#%d is marked ready at %s, and the merge tool has refused it since %s: %s", d.Repository,
|
||||
d.Number, shortOf(r.Head, 12), r.RefusedSince.UTC().Format(time.RFC3339), r.Answer)})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// **A pull request marked ready is merged by this owner** (novox/hq issue 722): before, a reviewed pull request with
|
||||
// green checks waited until a session called the merge tool, and again after every check the tool asked because the
|
||||
// trunk had moved. Each test below fails on a holder without the ready mark: nothing merged it.
|
||||
|
||||
const (
|
||||
readyHead = "1111111111111111111111111111111111111111"
|
||||
pushed = "2222222222222222222222222222222222222222"
|
||||
)
|
||||
|
||||
// markedPull is an open pull request as gitea_open_pulls reads it, labelled mesh/ready at the head named.
|
||||
func markedPull(repo string, number int, head, markedAt string) OpenPull {
|
||||
owner, name, _ := strings.Cut(repo, "/")
|
||||
return OpenPull{Owner: owner, Repo: name, Number: number, Base: "main", HeadSHA: head, Labels: []string{ReadyLabel},
|
||||
Ready: &ReadyMark{Head: markedAt, By: "reviewer", At: "2026-10-14T19:00:00Z", LabelID: 31}}
|
||||
}
|
||||
|
||||
func judgedAt(repo string, number int, sha, gate, trunk string) CheckedEvent {
|
||||
c := verdict(repo, number, sha, gate)
|
||||
c.Trunk = trunk
|
||||
return c
|
||||
}
|
||||
|
||||
func TestAReadyPullRequestMarkedAtItsHeadIsMergedThroughTheMergeTool(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf("novox/app", readyHead)
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 0 {
|
||||
t.Fatalf("an unmarked pull request was merged: %v", w.forge.merges)
|
||||
}
|
||||
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)}
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 1 || w.forge.merges[0] != "novox/app#7@"+readyHead {
|
||||
t.Fatalf("a ready, marked pull request was merged %v; wanted once, at the marked head", w.forge.merges)
|
||||
}
|
||||
d := w.delivery(id)
|
||||
if d.Ready == nil || d.Ready.Merged == nil || d.Ready.By != "reviewer" || d.Ready.Tries != 1 {
|
||||
t.Fatalf("what was done with the mark is not kept: %+v", d.Ready)
|
||||
}
|
||||
// Its merge's transition names who merged it.
|
||||
w.h.PullMerged(merged("novox/app", 7, readyHead, "m7m7m7m7m7m7"))
|
||||
w.h.WalkMoved(w.ctl.put(aWalk("plan-7", "novox/app", "m7m7m7m7m7m7", true)))
|
||||
w.settleAll()
|
||||
d = w.delivery(id)
|
||||
var last Transition
|
||||
for _, tr := range d.Transitions {
|
||||
if tr.Event == EvMerged {
|
||||
last = tr
|
||||
}
|
||||
}
|
||||
if !strings.Contains(last.By, "ready mark at "+readyHead[:12]+" by reviewer") {
|
||||
t.Fatalf("the merge's transition does not name the mark: %+v", last)
|
||||
}
|
||||
if len(w.forge.merges) != 1 {
|
||||
t.Fatalf("merged again: %v", w.forge.merges)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPushAfterTheMarkClearsItAndNothingIsMerged(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
w.h.PullUpdated(pr("novox/app", 7, pushed, "feat/x", "src/a.go"))
|
||||
w.h.Checked(judgedAt("novox/app", 7, pushed, "pass", "t1t1t1t1t1t1"))
|
||||
w.forge.open = []OpenPull{markedPull("novox/app", 7, pushed, readyHead)}
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 0 {
|
||||
t.Fatalf("a head pushed after the mark was merged: %v", w.forge.merges)
|
||||
}
|
||||
if len(w.forge.unlabels) != 1 || w.forge.unlabels[0] != "novox/app#7-31" {
|
||||
t.Fatalf("the stale mark's label was not taken off: %v", w.forge.unlabels)
|
||||
}
|
||||
if len(w.forge.comments) != 1 || !strings.Contains(w.forge.comments[0], "cleared") ||
|
||||
!strings.Contains(w.forge.comments[0], pushed) {
|
||||
t.Fatalf("the cleared mark is not said on the pull request: %v", w.forge.comments)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMovedTrunkIsJudgedAgainByTheMergeToolAndMergedOnItsVerdict(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf("novox/app", readyHead)
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
// Another merge moves the trunk: its verdict is out of date, and it goes back to proposed.
|
||||
w.h.PullMerged(merged("novox/app", 9, "9999999999999999", "t2t2t2t2t2t2"))
|
||||
if w.state(id) != Proposed {
|
||||
t.Fatalf("out of date, it is %s", w.state(id))
|
||||
}
|
||||
// The merge tool asks the check against the trunk's head and refuses: nothing merged.
|
||||
w.forge.refuse = errors.New("the forge refused gitea_merge_pull_request: The check of head merged into trunk was asked")
|
||||
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)}
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 1 {
|
||||
t.Fatalf("the merge tool was not asked once to judge it again: %v", w.forge.merges)
|
||||
}
|
||||
// The tool's ask is the head announced again; it waits for that verdict, asking nothing.
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
w.later(5 * time.Minute)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 1 || !strings.Contains(w.delivery(id).Ready.Waits, "asked") {
|
||||
t.Fatalf("asked again while its check was on its way: %v (%+v)", w.forge.merges, w.delivery(id).Ready)
|
||||
}
|
||||
// It passes against the trunk's head: merged, without anyone asking.
|
||||
w.forge.refuse = nil
|
||||
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t2t2t2t2t2t2"))
|
||||
w.settleAll()
|
||||
if w.state(id) != Ready || len(w.forge.merges) != 2 || w.delivery(id).Ready.Merged == nil {
|
||||
t.Fatalf("on its passing verdict it was not merged: %s %v %+v", w.state(id), w.forge.merges, w.delivery(id).Ready)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMarkedHeadWhoseCheckFailedIsNotMergedAndIsSaid(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf("novox/app", readyHead)
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
w.h.Checked(judgedAt("novox/app", 7, readyHead, "fail", "t1t1t1t1t1t1"))
|
||||
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)}
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
w.later(10 * time.Minute)
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 0 {
|
||||
t.Fatalf("a head whose check failed was merged: %v", w.forge.merges)
|
||||
}
|
||||
if len(w.forge.comments) != 1 || !strings.Contains(w.forge.comments[0], "not merged") {
|
||||
t.Fatalf("the failure is not said once on the pull request: %v", w.forge.comments)
|
||||
}
|
||||
lines := w.h.Stalled()
|
||||
if len(lines) != 1 || lines[0].ID != id || lines[0].State != Rejected || !strings.Contains(lines[0].Says, "marked ready") {
|
||||
t.Fatalf("the failure is not a stalled line, which the controller raises as a condition: %+v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMarkRefusedForAnHourIsSaid(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.forge.refuse = errors.New("the forge refused gitea_merge_pull_request: mesh/repo-check is pending")
|
||||
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)}
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
w.later(30 * time.Second)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 1 {
|
||||
t.Fatalf("asked again within the minute: %v", w.forge.merges)
|
||||
}
|
||||
w.later(readyRefusedFor)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 2 {
|
||||
t.Fatalf("not asked again after a minute: %v", w.forge.merges)
|
||||
}
|
||||
if lines := w.h.Stalled(); len(lines) != 1 || !strings.Contains(lines[0].Says, "refused it since") {
|
||||
t.Fatalf("a mark refused for an hour is not said: %+v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAGroupMergesItsMarkedMembersInOrderOnlyOnceComposed(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
const ctlHead, catHead = "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", "d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2"
|
||||
ctl, cat := IDOf("novox/mesh-controller", ctlHead), IDOf("novox/mesh-catalog", catHead)
|
||||
w.ctl.order = func(ms []Member) Order { return Order{Order: []string{ctl, cat}} }
|
||||
w.h.PullUpdated(pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go"))
|
||||
w.h.PullUpdated(pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json"))
|
||||
w.h.Checked(judgedAt("novox/mesh-controller", 1, ctlHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.h.Checked(judgedAt("novox/mesh-catalog", 2, catHead, "pass", "t3t3t3t3t3t3"))
|
||||
w.forge.open = []OpenPull{markedPull("novox/mesh-controller", 1, ctlHead, ctlHead),
|
||||
markedPull("novox/mesh-catalog", 2, catHead, catHead)}
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 0 {
|
||||
t.Fatalf("a member merged before its group's composed check passed: %v", w.forge.merges)
|
||||
}
|
||||
w.h.Checked(CheckedEvent{Group: "feat/x", ID: "check-1", Verdict: "pass", Summary: "both compose"})
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 1 || w.forge.merges[0] != "novox/mesh-controller#1@"+ctlHead {
|
||||
t.Fatalf("composed, the first member in the group's order was not merged alone: %v", w.forge.merges)
|
||||
}
|
||||
if waits := w.delivery(cat).Ready.Waits; !strings.Contains(waits, "merges "+ctl+" first") {
|
||||
t.Fatalf("the second member does not say it waits its turn: %q", waits)
|
||||
}
|
||||
w.h.PullMerged(merged("novox/mesh-controller", 1, ctlHead, "m1m1m1m1m1m1"))
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 2 || w.forge.merges[1] != "novox/mesh-catalog#2@"+catHead {
|
||||
t.Fatalf("after the first member merged, the next was not: %v", w.forge.merges)
|
||||
}
|
||||
}
|
||||
@@ -498,7 +498,12 @@ func Apply(d *Delivery, ev Event, act bool, f Facts) (Transition, error) {
|
||||
if why == "" {
|
||||
why = r.Guard
|
||||
}
|
||||
t := Transition{At: f.Now, From: d.State, To: r.To, Event: ev, Why: why, By: f.By}
|
||||
by := f.By
|
||||
if by == "" && (ev == EvMerged || ev == EvDone) && d.MergedAs != "" {
|
||||
// A merge this owner made on a ready mark names it as its author (novox/hq issue 722).
|
||||
by = d.Ready.mergedBy()
|
||||
}
|
||||
t := Transition{At: f.Now, From: d.State, To: r.To, Event: ev, Why: why, By: by}
|
||||
d.State, d.Since = r.To, f.Now
|
||||
d.Transitions = append(d.Transitions, t)
|
||||
if len(d.Transitions) > keptTransitions {
|
||||
|
||||
@@ -72,6 +72,9 @@ type OpenPull struct {
|
||||
// than that says none: the head is then read by Statuses alone, as before, and a pending check is not told
|
||||
// from an answered one.
|
||||
Checks map[string]HeadCheck `json:"checks,omitempty"`
|
||||
// Labels are its labels by name, and Ready its ready mark, when it carries one (novox/hq issue 722).
|
||||
Labels []string `json:"labels,omitempty"`
|
||||
Ready *ReadyMark `json:"ready,omitempty"`
|
||||
}
|
||||
|
||||
// HeadCheck is one status on a head as the forge keeps it: pending, success, failure, error or warning.
|
||||
@@ -201,6 +204,7 @@ func (h *Holder) SeeOpenPulls() {
|
||||
seen[id] = u
|
||||
}
|
||||
h.unchecked = seen
|
||||
h.seeMarks(open)
|
||||
}
|
||||
|
||||
// started is whether the head's check started, rather than was never asked: it has any merge check, or a delivery
|
||||
|
||||
@@ -249,6 +249,7 @@ func (h *Holder) Stalled() []StalledLine {
|
||||
}
|
||||
out = append(out, h.withChecks(h.uncheckedLines(now))...)
|
||||
out = append(out, h.orphanLines(now)...)
|
||||
out = append(out, h.readyLines(now)...)
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user