The issue tracker learns the merge of a fix it never heard, so an old resolved issue no longer reads 'nothing heard from the delivery' (issue 1950) #517
@@ -32,6 +32,7 @@ what it refuses.
|
||||
| `tracking` | optionally `conditions` (condition keys) | for every condition key an issue links (link kind `condition`), the issues that link it, those not closed first: status, severity, needs, worker with since, active and stale, and where the fix is (`fix`); the operator's channel says it in a condition's message (hq ADR 0285) |
|
||||
| `reserve` | `kind` (`decision` or `research`), optionally `for` (one line: what the record is about) | the next number of that kind, spent forever; answers `kind` and `number` (and `prefix`, the number as the record's name starts with it); see below |
|
||||
| `fix-times` | optionally `days` (1 to 90, default 7) | each class's fix budget, the median and worst fix time of the issues whose last fix was delivered in the window, within and over, and every issue over its budget with its longest phase; see *A fix's time* |
|
||||
| `learn-fixes` | `number` | asks again for the fixes of one issue the tracker keeps no record of (a pull request merged before it heard deliveries): the delivery, then the forge, say whether each is merged (and the commit it landed as) or closed without merging; answers what each is kept as and the delivery line |
|
||||
|
||||
**The cycle's rules, at the write** (hq playbook 03, which `00-META/checks/cycle.py` enforced on the
|
||||
folders until the import, and hq ADR 0284): a status is one of `open`,
|
||||
|
||||
@@ -18,6 +18,7 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -112,46 +113,60 @@ func (t *Tracker) clearNotResolved(is *Issue) error {
|
||||
// askUnheard asks mesh-delivery for a few fixes of issues not yet resolved that the tracker never heard of: a pull
|
||||
// request linked after its delivery moved hears no transition again, and OnMoved asks only at a move.
|
||||
func (t *Tracker) askUnheard() error {
|
||||
if t.Deliveries == nil {
|
||||
if t.Deliveries == nil && t.Pull == nil {
|
||||
return nil
|
||||
}
|
||||
all, err := t.allIssues()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
asked := 0
|
||||
// Issues not resolved first, then the others: a resolved issue's fix merged before the tracker heard deliveries is learned
|
||||
// too, so its delivery line stops saying nothing was heard (issue 1950). The asks are bounded, and the pull request asked
|
||||
// longest ago goes first, so ones that cannot be learned (still open, gone) never starve the rest.
|
||||
type cand struct {
|
||||
n uint64
|
||||
pr string
|
||||
open bool
|
||||
at time.Time
|
||||
}
|
||||
var cs []cand
|
||||
t.askedMu.Lock()
|
||||
for n, is := range all {
|
||||
if !unresolved(is) {
|
||||
for _, pr := range unheardOf(is) {
|
||||
cs = append(cs, cand{n, pr, unresolved(is), t.askedAt[pr]})
|
||||
}
|
||||
}
|
||||
t.askedMu.Unlock()
|
||||
sort.Slice(cs, func(i, j int) bool {
|
||||
a, b := cs[i], cs[j]
|
||||
if a.open != b.open {
|
||||
return a.open
|
||||
}
|
||||
if !a.at.Equal(b.at) {
|
||||
return a.at.Before(b.at)
|
||||
}
|
||||
if a.n != b.n {
|
||||
return a.n < b.n
|
||||
}
|
||||
return a.pr < b.pr
|
||||
})
|
||||
for i, c := range cs {
|
||||
if i == MostSweepAsks {
|
||||
break
|
||||
}
|
||||
t.askedMu.Lock()
|
||||
if t.askedAt == nil {
|
||||
t.askedAt = map[string]time.Time{}
|
||||
}
|
||||
t.askedAt[c.pr] = t.now()
|
||||
t.askedMu.Unlock()
|
||||
kept, err := t.learnFix(c.n, c.pr)
|
||||
if err != nil {
|
||||
t.Logf("[mesh-issues] the delivery of %s (issue %d) could not be asked (the next sweep asks the others first): %v", c.pr, c.n, err)
|
||||
continue
|
||||
}
|
||||
heard := false
|
||||
for _, pr := range fixPullRequests(is) {
|
||||
if fixOf(is, pr) != nil {
|
||||
continue
|
||||
}
|
||||
if asked == MostSweepAsks {
|
||||
return nil
|
||||
}
|
||||
asked++
|
||||
repo, number, _ := strings.Cut(pr, "#")
|
||||
d, err := t.Deliveries(repo, number)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the delivery of %s (issue %d): %v", pr, n, err)
|
||||
}
|
||||
if d.State == "" {
|
||||
continue
|
||||
}
|
||||
since := d.Since
|
||||
if since.IsZero() {
|
||||
since = t.now()
|
||||
}
|
||||
if _, err := t.recordFix(pr, d.heard(since), n); err != nil {
|
||||
return err
|
||||
}
|
||||
heard = true
|
||||
}
|
||||
if heard {
|
||||
if err := t.afterFix(n); err != nil {
|
||||
if kept {
|
||||
if err := t.afterFix(c.n); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -159,6 +174,17 @@ func (t *Tracker) askUnheard() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// unheardOf is the pull requests an issue names that the tracker keeps no record for.
|
||||
func unheardOf(is *Issue) []string {
|
||||
var out []string
|
||||
for _, pr := range fixPullRequests(is) {
|
||||
if fixOf(is, pr) == nil {
|
||||
out = append(out, pr)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// openPullRequest says whether an issue names a pull request not merged yet, or one whose delivery the tracker
|
||||
// never heard of: work in review is work, and a fix unheard of is not taken for none. One closed without merging
|
||||
// is not open.
|
||||
|
||||
@@ -454,9 +454,22 @@ func (t *Tracker) OnDelivered(e stdio.Envelope) error {
|
||||
at = t.now()
|
||||
}
|
||||
var changed []uint64
|
||||
learned := 0
|
||||
for n, is := range all {
|
||||
for _, pr := range liveFixes(is) {
|
||||
f := fixOf(is, pr)
|
||||
if f == nil && learned < MostSweepAsks {
|
||||
// A pull request named and kept no record for (merged before the tracker heard deliveries): it learns its
|
||||
// merge now, so this very event can cover it (issue 1950).
|
||||
learned++
|
||||
if ok, err := t.learnFix(n, pr); err != nil {
|
||||
t.Logf("[mesh-issues] the merge of %s (issue %d) could not be learned (asked again at the next event): %v", pr, n, err)
|
||||
} else if ok {
|
||||
if fresh, _, err := read(t.Store, n); err == nil {
|
||||
is, f = fresh, fixOf(fresh, pr)
|
||||
}
|
||||
}
|
||||
}
|
||||
if f != nil && f.Merged && !contains(is.Delivered, pr) && f.MergedAs == "" {
|
||||
t.fillMerge(n, f, pr)
|
||||
}
|
||||
@@ -624,6 +637,10 @@ type Delivery struct {
|
||||
Repository string
|
||||
Group string
|
||||
MergedAs string
|
||||
// Pulled says the answer carried the forge's pull request (merged, closed, the commit its merge landed as); Closed that it
|
||||
// was closed without merging.
|
||||
Pulled bool
|
||||
Closed bool
|
||||
}
|
||||
|
||||
// heard is what a delivery answered, as a fix keeps it.
|
||||
@@ -662,16 +679,18 @@ func busDeliveryState(owner string) DeliveryState {
|
||||
Group string `json:"group"`
|
||||
MergedAs string `json:"merged_as"`
|
||||
} `json:"delivery"`
|
||||
Pull struct {
|
||||
Merged bool `json:"merged"`
|
||||
} `json:"pull"`
|
||||
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)
|
||||
}
|
||||
return Delivery{State: out.Delivery.State, Why: out.Delivery.Waits, Since: out.Delivery.Since.UTC(), Merged: out.Pull.Merged,
|
||||
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}, nil
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -695,34 +714,12 @@ func (t *Tracker) OnMoved(e stdio.Envelope) error {
|
||||
if t.Deliveries != nil {
|
||||
// Each ask up to the bus's 30 seconds, under the runtime's two minutes; one that fails, or more left to
|
||||
// ask, has the event offered again, so a delivery never asked ends in max-deliveries, not in silence.
|
||||
asked := 0
|
||||
for _, pr := range fixPullRequests(is) {
|
||||
if fixOf(is, pr) != nil {
|
||||
continue
|
||||
}
|
||||
if asked == MostDeliveryAsks {
|
||||
return fmt.Errorf("issue %d names more pull requests than are asked at once; the rest at the next offer", body.Number)
|
||||
}
|
||||
asked++
|
||||
repo, number, _ := strings.Cut(pr, "#")
|
||||
d, err := t.Deliveries(repo, number)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the delivery of %s (issue %d) could not be asked: %v", pr, body.Number, err)
|
||||
}
|
||||
state := d.State
|
||||
if state == "" {
|
||||
// The delivery knows nothing of it yet: not kept, so it is asked again at the next move, and its own
|
||||
// events say it when they come.
|
||||
continue
|
||||
}
|
||||
// The delivery's own time, so a transition stamped the same moment is not taken as older.
|
||||
since := d.Since
|
||||
if since.IsZero() {
|
||||
since = t.now()
|
||||
}
|
||||
if _, err := t.recordFix(pr, d.heard(since), body.Number); err != nil {
|
||||
return err
|
||||
}
|
||||
asked, _, err := t.learnUnheard(body.Number, is, MostDeliveryAsks)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if asked == MostDeliveryAsks && len(unheardOf(is)) > asked {
|
||||
return fmt.Errorf("issue %d names more pull requests than are asked at once; the rest at the next offer", body.Number)
|
||||
}
|
||||
}
|
||||
return t.afterFix(body.Number)
|
||||
@@ -794,7 +791,7 @@ func (t *Tracker) Sweep() (int, error) {
|
||||
named := liveFixes(is)
|
||||
for _, f := range is.Fixes {
|
||||
if !contains(named, f.PullRequest) || contains(is.Delivered, f.PullRequest) || !f.Merged || f.MergedAt == nil ||
|
||||
reached(f.State) || f.StuckSaid || now.Sub(*f.MergedAt) < bound {
|
||||
reached(f.State) || f.StuckSaid || now.Sub(*f.MergedAt) < bound || now.Sub(f.Since) < bound {
|
||||
continue
|
||||
}
|
||||
state := f.State
|
||||
|
||||
@@ -320,6 +320,11 @@ func (t *Tracker) timeFix(n uint64) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if closedStatus(is.Status) && is.FixTime == nil {
|
||||
// An issue closed before its fix was timed keeps none: measuring now would end its fix time at a delivery learned
|
||||
// late ("fixed in N weeks") and could raise a false over-budget warning (issue 1950).
|
||||
return nil
|
||||
}
|
||||
ft, _ := t.fixTimeOf(is)
|
||||
if ft == nil || sameFixTime(ft, is.FixTime) {
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
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
|
||||
// the withdrawn state the tracker already knows.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
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.
|
||||
type pullFacts struct {
|
||||
Merged bool `json:"merged"`
|
||||
State string `json:"state"`
|
||||
MergeCommitSHA string `json:"merge_commit_sha"`
|
||||
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
|
||||
if d.Repository == "" {
|
||||
d.Repository = strings.ToLower(repository)
|
||||
}
|
||||
if p.Merged {
|
||||
if d.MergedAs == "" {
|
||||
d.MergedAs = p.MergeCommitSHA
|
||||
}
|
||||
if d.MergedAt == nil {
|
||||
d.MergedAt = nonZero(p.MergedAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// busPull asks the forge for one pull request (gitea_get_pull_request).
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// learnFix gives a pull request an issue names and keeps no Fix record for its record: from the delivery's own state when it has
|
||||
// one, else from the pull request itself (the delivery's, then the forge's). It answers whether a record was kept. A pull request
|
||||
// still open is not kept (its own events say it when they come); one neither can say anything of is left for next time.
|
||||
func (t *Tracker) learnFix(n uint64, pr string) (bool, error) {
|
||||
repo, number, _ := strings.Cut(pr, "#")
|
||||
var d Delivery
|
||||
var derr error
|
||||
if t.Deliveries != nil {
|
||||
d, derr = t.Deliveries(repo, number)
|
||||
}
|
||||
if derr == nil && d.State != "" {
|
||||
since := d.Since
|
||||
if since.IsZero() {
|
||||
since = t.now()
|
||||
}
|
||||
ch, err := t.recordFix(pr, d.heard(since), n)
|
||||
return len(ch) > 0, err
|
||||
}
|
||||
if derr != nil || !d.Pulled {
|
||||
if t.Pull == nil {
|
||||
return false, derr
|
||||
}
|
||||
f, ferr := t.Pull(repo, number)
|
||||
if ferr != nil {
|
||||
if derr != nil {
|
||||
return false, derr
|
||||
}
|
||||
t.Logf("[mesh-issues] the pull request %s (issue %d) could not be read from the forge (asked again later): %v", pr, n, ferr)
|
||||
return false, nil
|
||||
}
|
||||
d = f
|
||||
}
|
||||
var h heardFix
|
||||
switch {
|
||||
case d.Merged:
|
||||
// Kept as of now, the moment it was learned: the fix's state did not change days ago for the tracker, and the stuck
|
||||
// sweep measures from it. MergedAt stays the forge's own moment.
|
||||
h = heardFix{State: "merged", Merged: true, At: t.now(), MergedAt: d.MergedAt, Repository: d.Repository, MergedAs: d.MergedAs}
|
||||
case d.Closed:
|
||||
h = heardFix{State: "closed", Why: "closed without merging", At: t.now()}
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
ch, err := t.recordFix(pr, h, n)
|
||||
return len(ch) > 0, err
|
||||
}
|
||||
|
||||
// learnUnheard learns, for at most most pull requests, each fix of issue n with no record; it answers whether any was kept.
|
||||
func (t *Tracker) learnUnheard(n uint64, is *Issue, most int) (int, bool, error) {
|
||||
asked, kept := 0, false
|
||||
for _, pr := range fixPullRequests(is) {
|
||||
if fixOf(is, pr) != nil {
|
||||
continue
|
||||
}
|
||||
if asked == most {
|
||||
break
|
||||
}
|
||||
asked++
|
||||
ok, err := t.learnFix(n, pr)
|
||||
if err != nil {
|
||||
return asked, kept, fmt.Errorf("the delivery of %s (issue %d) could not be asked: %v", pr, n, err)
|
||||
}
|
||||
kept = kept || ok
|
||||
}
|
||||
return asked, kept, nil
|
||||
}
|
||||
|
||||
// LearnFixes is the tool that asks again for one issue: every pull request its fixed-by names with no record is asked of the
|
||||
// delivery and the forge now. It answers what each is kept as.
|
||||
func (t *Tracker) LearnFixes(n uint64) (map[string]any, error) {
|
||||
is, _, err := read(t.Store, n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, _, err := t.learnUnheard(n, is, MostSweepAsks*2); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.afterFix(n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
is, _, err = read(t.Store, n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]string{}
|
||||
for _, pr := range fixPullRequests(is) {
|
||||
switch f := fixOf(is, pr); {
|
||||
case f == nil:
|
||||
out[pr] = "nothing heard: neither the delivery nor the forge said"
|
||||
case withdrawn(f):
|
||||
out[pr] = "withdrawn: closed without merging"
|
||||
case contains(is.Delivered, pr):
|
||||
out[pr] = "delivered"
|
||||
case f.Merged:
|
||||
out[pr] = "merged as " + shortCommit(f.MergedAs) + ", not delivered"
|
||||
default:
|
||||
out[pr] = f.State
|
||||
}
|
||||
}
|
||||
return map[string]any{"number": n, "fixes": out, "delivery": DeliveryLine(is)}, nil
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"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"}}`
|
||||
|
||||
func TestAPullRequestWithNoFixRecordLearnsItsMerge(t *testing.T) {
|
||||
mergedAt := time.Date(2026, 10, 19, 18, 5, 16, 0, time.UTC)
|
||||
merged := Delivery{Pulled: true, Merged: true, MergedAs: "1325c8b452c8fa65c478af6815887870a1f42e7b", MergedAt: &mergedAt, Repository: "novox/mesh-catalog"}
|
||||
closed := Delivery{Pulled: true, Closed: true}
|
||||
open := Delivery{Pulled: true}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
delivery Delivery
|
||||
deliveryEr error
|
||||
forge *Delivery
|
||||
forgeEr error
|
||||
want string // the delivery line after learning
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "mesh-delivery's pull says merged", delivery: merged, want: "fix merged, not delivered: mesh-catalog#50"},
|
||||
{name: "mesh-delivery cannot, the forge says merged", deliveryEr: errors.New("down"), forge: &merged,
|
||||
want: "fix merged, not delivered: mesh-catalog#50"},
|
||||
{name: "mesh-delivery says nothing of the pull, the forge says merged", forge: &merged, want: "fix merged, not delivered: mesh-catalog#50"},
|
||||
{name: "closed without merging is withdrawn", delivery: closed, want: "fix withdrawn: mesh-catalog#50 closed without merging"},
|
||||
{name: "still open stays unheard", delivery: open, want: "nothing heard from the delivery of mesh-catalog#50"},
|
||||
{name: "a forge that cannot say leaves it", forgeEr: errors.New("forge down"), want: "nothing heard from the delivery of mesh-catalog#50"},
|
||||
{name: "neither can say", deliveryEr: errors.New("down"), forgeEr: errors.New("forge down"), want: "nothing heard from the delivery of mesh-catalog#50", wantErr: true},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
n := fixIssue(t, h, "mesh-catalog#50")
|
||||
h.Deliveries = func(string, string) (Delivery, error) { return c.delivery, c.deliveryEr }
|
||||
h.Pull = func(string, string) (Delivery, error) {
|
||||
if c.forge != nil {
|
||||
return *c.forge, nil
|
||||
}
|
||||
return Delivery{}, errOr(c.forgeEr)
|
||||
}
|
||||
_, err := h.LearnFixes(n)
|
||||
if (err != nil) != c.wantErr {
|
||||
t.Fatalf("error %v, wanted error %v", err, c.wantErr)
|
||||
}
|
||||
if v, _ := h.View(n); v.Delivery != c.want {
|
||||
t.Fatalf("%q, wanted %q", v.Delivery, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func errOr(e error) error {
|
||||
if e == nil {
|
||||
return errors.New("the forge has no such pull request")
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// A fix merged before the tracker heard deliveries: the delivered event itself learns its merge and covers it, a resolved issue's
|
||||
// included; and the sweep learns it with no event.
|
||||
func TestAFixMergedBeforeTheTrackerHeardDeliveriesIsDeliveredByTheNextEventOfItsRepository(t *testing.T) {
|
||||
mergedAs := "1325c8b452c8fa65c478af6815887870a1f42e7b"
|
||||
forge := func(string, string) (Delivery, error) {
|
||||
return Delivery{Pulled: true, Merged: true, MergedAs: mergedAs, Repository: "novox/mesh-catalog"}, nil
|
||||
}
|
||||
contains := func(_, _, commit, in string) (bool, error) {
|
||||
return commit == mergedAs && in == "ffffffffffff0000", nil
|
||||
}
|
||||
heads := map[string]string{"novox/mesh-catalog": "ffffffffffff0000"}
|
||||
delivered := "delivered to every machine it should reach"
|
||||
|
||||
h := newHarness(t)
|
||||
n := fixIssue(t, h, "mesh-catalog#51")
|
||||
h.Pull, h.Contains = forge, contains
|
||||
viewSays(t, h, 30, "", heads)
|
||||
if v, _ := h.View(n); v.Delivery != delivered {
|
||||
t.Fatalf("the event did not cover the old fix: %q", v.Delivery)
|
||||
}
|
||||
if f := fixOf(get(t, h, n), "mesh-catalog#51"); f == nil || f.MergedAs != mergedAs || f.Repository != "novox/mesh-catalog" || f.MergedAt != nil && f.MergedAt.IsZero() {
|
||||
t.Fatalf("fix %+v", f)
|
||||
}
|
||||
|
||||
// A resolved issue is learned by the sweep, which then waits for the next event, not for ever.
|
||||
h2 := newHarness(t)
|
||||
m := fixIssue(t, h2, "mesh-catalog#52")
|
||||
if _, err := h2.Move(MoveAsk{Number: m, Status: StatusResolved, FixedBy: []string{"mesh-catalog#52"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v, _ := h2.View(m); !strings.HasPrefix(v.Delivery, "nothing heard") {
|
||||
t.Fatalf("before: %q", v.Delivery)
|
||||
}
|
||||
h2.Pull, h2.Contains = forge, contains
|
||||
if _, err := h2.Sweep(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v, _ := h2.View(m); v.Delivery != "fix merged, not delivered: mesh-catalog#52" {
|
||||
t.Fatalf("after the sweep: %q", v.Delivery)
|
||||
}
|
||||
viewSays(t, h2, 31, "", heads)
|
||||
if v, _ := h2.View(m); v.Delivery != delivered {
|
||||
t.Fatalf("after the event: %q", v.Delivery)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheForgesRealPullAnswerIsReadAsMerged(t *testing.T) {
|
||||
var out struct {
|
||||
Pull pullFacts `json:"pull"`
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// false warning in the sweep that learned it; and an issue closed before its fix was timed keeps no fix time when a late
|
||||
// delivery covers it.
|
||||
func TestALearnedOldMergeRaisesNoStuckWarningAndTimesNoClosedIssue(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
now := clocked(h)
|
||||
note := ¬ified{}
|
||||
h.Notify = note.notify
|
||||
old := now.Add(-10 * 24 * time.Hour)
|
||||
mergedAs := "1325c8b452c8fa65c478af6815887870a1f42e7b"
|
||||
h.Pull = func(string, string) (Delivery, error) {
|
||||
return Delivery{Pulled: true, Merged: true, MergedAs: mergedAs, MergedAt: &old, Repository: "novox/mesh-catalog"}, nil
|
||||
}
|
||||
h.Contains = func(_, _, commit, _ string) (bool, error) { return commit == mergedAs, nil }
|
||||
open := fixIssue(t, h, "mesh-catalog#60")
|
||||
closed := fixIssue(t, h, "mesh-catalog#61")
|
||||
if _, err := h.Move(MoveAsk{Number: closed, Status: StatusResolved, FixedBy: []string{"mesh-catalog#61"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := h.Sweep(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(note.said) != 0 || noted(get(t, h, open), "Stuck: the fix") {
|
||||
t.Fatalf("a learned old merge was said stuck in the sweep that learned it: %q", note.said)
|
||||
}
|
||||
if f := fixOf(get(t, h, open), "mesh-catalog#60"); f == nil || f.MergedAt == nil || !f.MergedAt.Equal(old) {
|
||||
t.Fatalf("the forge's merge moment is kept: %+v", f)
|
||||
}
|
||||
*now = now.Add(3 * time.Hour)
|
||||
if _, err := h.Sweep(); err != nil || len(note.said) != 1 {
|
||||
t.Fatalf("not delivered 3 hours after it was learned: %q %v", note.said, err)
|
||||
}
|
||||
viewSays(t, h, 40, "", map[string]string{"novox/mesh-catalog": "ffffffffffff0000"})
|
||||
if v, _ := h.View(closed); v.Delivery != "delivered to every machine it should reach" || get(t, h, closed).FixTime != nil {
|
||||
t.Fatalf("closed issue: %q, fix time %+v", v.Delivery, get(t, h, closed).FixTime)
|
||||
}
|
||||
}
|
||||
|
||||
// Pull requests that cannot be learned (still open) do not starve the others: the one asked longest ago goes first.
|
||||
func TestTheSweepAsksTheLeastRecentlyAskedPullRequestFirst(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
var stuck []uint64
|
||||
for i := 0; i < MostSweepAsks+2; i++ {
|
||||
stuck = append(stuck, fixIssue(t, h, "mesh-catalog#"+itoa(70+i)))
|
||||
}
|
||||
last := fixIssue(t, h, "mesh-catalog#99")
|
||||
h.Pull = func(_, number string) (Delivery, error) {
|
||||
if number == "99" {
|
||||
return Delivery{Pulled: true, Merged: true, MergedAs: "1325c8b452c8fa65c478af6815887870a1f42e7b"}, nil
|
||||
}
|
||||
return Delivery{Pulled: true}, nil // still open
|
||||
}
|
||||
for i := 0; i < 4; i++ {
|
||||
if _, err := h.Sweep(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if v, _ := h.View(last); !strings.HasPrefix(v.Delivery, "fix merged") {
|
||||
t.Fatalf("starved by pull requests that cannot be learned: %q (%d of them)", v.Delivery, len(stuck))
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ const Seat = "issue-tracker"
|
||||
|
||||
// Verbs are the seat's verbs, as the manifest declares and claims them.
|
||||
var Verbs = []string{"open", "get", "list", "move", "note", "link", "milestones", "milestone-set", "milestone-move", "import",
|
||||
"groom", "set", "tracking", "reserve", "fix-times"}
|
||||
"groom", "set", "tracking", "reserve", "fix-times", "learn-fixes"}
|
||||
|
||||
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
|
||||
|
||||
@@ -105,6 +105,9 @@ func main() {
|
||||
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.Contains = busContains
|
||||
if len(os.Args) > 1 && os.Args[1] == "dry-run" {
|
||||
@@ -921,5 +924,23 @@ func Tools(t *Tracker) []stdio.Tool {
|
||||
}
|
||||
return t.FixTimes(int(days))
|
||||
}},
|
||||
{Name: Seat + ".learn-fixes",
|
||||
Description: "Ask again for the fixes of one issue the tracker keeps no record of: each pull request its fixed-by names that " +
|
||||
"the delivery was never heard of (merged before the tracker heard deliveries, or linked after) is asked of mesh-delivery " +
|
||||
"and, when it cannot say, of the forge, and kept as merged (with the commit its merge landed as) or as withdrawn when it " +
|
||||
"was closed without merging; a merge is then matched to the next delivered revision of its repository. Answers what each " +
|
||||
"pull request is kept as and the issue's delivery line. A pull request still open, or one neither can say, stays unheard.",
|
||||
Input: schema([]string{"number"}, map[string]any{"number": number("the issue's number")}),
|
||||
Run: func(m map[string]any) (any, error) {
|
||||
a, err := readArgs("learn-fixes", m, "number")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n, err := a.number("number", true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return t.LearnFixes(n)
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ func TestItDeclaresAndHoldsTheIssueTrackerSeatOnceForTheMesh(t *testing.T) {
|
||||
// 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", "records.records_status", "records.records_list", "records.records_read", "mesh-knowledge.knowledge_highest"}) {
|
||||
"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"}) {
|
||||
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).
|
||||
|
||||
@@ -37,6 +37,11 @@ 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 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
|
||||
// revision then marks only the fixes it names (OnDelivered).
|
||||
Contains func(owner, repo, commit, in string) (bool, error)
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
"set",
|
||||
"tracking",
|
||||
"reserve",
|
||||
"fix-times"
|
||||
"fix-times",
|
||||
"learn-fixes"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -44,7 +45,8 @@
|
||||
"set",
|
||||
"tracking",
|
||||
"reserve",
|
||||
"fix-times"
|
||||
"fix-times",
|
||||
"learn-fixes"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -74,6 +76,7 @@
|
||||
"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",
|
||||
|
||||
Reference in New Issue
Block a user