Files
mesh-catalog/modules/mesh-issues/cmd/mesh-issues/ready.go
T
jochen 983d434623
mesh/merge-gate fail: builds mesh-issues → novox; no bus step; a manifest the change touches fails the module check: modules/mesh-issues/module.json: this …
mesh/repo-check fail: its merge-check.sh failed: files asking for a setting without saying whether it is trusted: 25
mesh/delivery superseded: a newer head of the same pull request
mesh-issues: keep every issue in the log changes and the bucket as its fold, so the record outlives the bucket's cap and a week of events (hq ADR 0300, issues 501, 502)
Every write appends one operation at the issue's last sequence before the
bucket is written; a refused append writes nothing. The start carries what
the bucket holds, refolds what is behind and indexes every issue in memory,
so list answers from memory with a ranked search and a before cursor.
2026-10-12 02:41:54 +02:00

380 lines
11 KiB
Go

package main
// Severity, needs, dependencies and readiness (novox/hq ADR 0279 §1–§3). An issue is ready when it is not
// resolved or wontfix and every issue it depends on is wontfix, or resolved with every pull request its fixed-by
// names delivered. The tracker learns a delivery from mesh-delivery's `transition` event, and asks
// mesh-delivery's `checks` once when it hears its own `moved` to resolved; an issue made ready gains a note and
// the event `ready`.
import (
"errors"
"fmt"
"regexp"
"sort"
"strings"
)
// The events of ADR 0279.
const (
EventSet = "set"
EventTriaged = "triaged"
EventReady = "ready"
)
// ErrCycle is a depends-on link that would close a cycle.
var ErrCycle = errors.New("it would close a cycle")
// SetAsk is what `set` carries: the values a person decides.
type SetAsk struct {
Number uint64
Severity string
Needs string
SuggestedOwner string
}
// SetDone is set's answer.
type SetDone struct {
Number uint64 `json:"number"`
Changed []string `json:"changed"`
By string `json:"by"`
Said string `json:"said"`
Announced bool `json:"announced"`
Warning string `json:"warning,omitempty"`
}
// Set changes an issue's severity, needs or suggested owner, as a person decides.
func (t *Tracker) Set(a SetAsk) (*SetDone, error) { return t.set(t.by(), a, false) }
// setOp is the kind of operation a set is in the log: a person's set, or the triage filling what is empty.
func setOp(onlyEmpty bool) string {
if onlyEmpty {
return EventTriaged
}
return EventSet
}
// set is Set as the one who acts. onlyEmpty fills only a value that is empty, as the triage does: a value a
// person gave meanwhile stands.
func (t *Tracker) set(by string, a SetAsk, onlyEmpty bool) (*SetDone, error) {
var err error
if a.Severity != "" {
if a.Severity, err = oneOf("severity", a.Severity, Severities); err != nil {
return nil, err
}
}
if a.Needs != "" {
if a.Needs, err = oneOf("needs", a.Needs, NeedsKinds); err != nil {
return nil, err
}
}
if strings.TrimSpace(a.SuggestedOwner) != "" {
owners, err := t.owners()
if err != nil {
return nil, err
}
if a.SuggestedOwner, err = owners.locatedIn(a.SuggestedOwner); err != nil {
return nil, fmt.Errorf("suggested-owner: %v", err)
}
}
if a.Severity == "" && a.Needs == "" && a.SuggestedOwner == "" {
return nil, errors.New("set changes severity, needs or suggested-owner: give at least one")
}
var changed []string
is, err := t.change(a.Number, Act{Op: setOp(onlyEmpty), By: by}, func(is *Issue) error {
changed = nil
for _, f := range []struct {
name string
to string
at *string
}{{"severity", a.Severity, &is.Severity}, {"needs", a.Needs, &is.Needs}, {"suggested-owner", a.SuggestedOwner, &is.SuggestedOwner}} {
if f.to == "" || *f.at == f.to || (onlyEmpty && *f.at != "") {
continue
}
*f.at = f.to
changed = append(changed, f.name)
}
if len(changed) == 0 {
return errNothing
}
touch(is, by, t.now())
return nil
})
if errors.Is(err, errNothing) {
return &SetDone{Number: a.Number, Changed: []string{}, By: by, Said: fmt.Sprintf("Issue %d already holds that.", a.Number)}, nil
}
if err != nil {
return nil, err
}
out := &SetDone{Number: a.Number, Changed: changed, By: by,
Said: fmt.Sprintf("Issue %d: set %s.", a.Number, strings.Join(changed, ", "))}
body := map[string]any{"number": a.Number, "by": by, "at": t.now()}
for _, c := range changed {
switch c {
case "severity":
body[c] = is.Severity
case "needs":
body[c] = is.Needs
case "suggested-owner":
body[c] = is.SuggestedOwner
}
}
out.Announced, out.Warning = t.emit(EventSet, body)
return out, nil
}
var errNothing = errors.New("nothing to change")
// MostDeliveryAsks is how many deliveries one handling of a `moved` event asks of mesh-delivery.
const MostDeliveryAsks = 3
func equalNumbers(a, b []uint64) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// dependsOn is the issues an issue depends on.
func dependsOn(is *Issue) []uint64 {
var out []uint64
for _, l := range is.Links {
if l.Kind == LinkDependsOn {
if n, ok := numberOfKey(l.Target); ok {
out = append(out, n)
}
}
}
return out
}
// reaches says whether `from` depends on `to`, directly or through others.
func reaches(all map[uint64]*Issue, from, to uint64) bool {
seen := map[uint64]bool{}
stack := []uint64{from}
for len(stack) > 0 {
n := stack[len(stack)-1]
stack = stack[:len(stack)-1]
if n == to {
return true
}
if seen[n] {
continue
}
seen[n] = true
if is := all[n]; is != nil {
stack = append(stack, dependsOn(is)...)
}
}
return false
}
// pullRequestRef is a fixed-by entry naming a pull request: `<repository>#<number>`, optionally with the owner;
// pullRequestPage the forge's page of one.
var (
pullRequestRef = regexp.MustCompile(`^(?:[a-z0-9._-]+/)?([a-z0-9][a-z0-9._-]{0,99})#([1-9][0-9]{0,6})$`)
pullRequestPage = regexp.MustCompile(`^https?://\S+/([a-z0-9][a-z0-9._-]{0,99})/pulls?/([1-9][0-9]{0,6})/?$`)
)
// pullRequests is the pull requests an issue's fixed-by names, as `<repository>#<number>`, lower-case.
func pullRequests(is *Issue) []string {
var out []string
for _, f := range is.FixedBy {
f = strings.ToLower(strings.TrimSpace(f))
m := pullRequestRef.FindStringSubmatch(f)
if m == nil {
m = pullRequestPage.FindStringSubmatch(f)
}
if m != nil && !contains(out, m[1]+"#"+m[2]) {
out = append(out, m[1]+"#"+m[2])
}
}
return out
}
// fixPullRequests is every pull request an issue names as its fix: those of fixed-by and those linked as
// pull-request, as `<repository>#<number>`, lower-case. Agents link a pull request while the issue is in fixing and
// give fixed-by only at the resolve, so a delivery measured on fixed-by alone missed every fix still being
// verified (novox/hq issue 483). Readiness (done) still reads fixed-by alone: a resolved issue's fixed-by is what
// fixed it, and a pull request linked and abandoned must not hold its dependents back.
func fixPullRequests(is *Issue) []string {
out := pullRequests(is)
for _, l := range is.Links {
if l.Kind != LinkPullRequest {
continue
}
if pr := strings.ToLower(strings.TrimSpace(l.Target)); pullRequest.MatchString(pr) && !contains(out, pr) {
out = append(out, pr)
}
}
return out
}
func contains(list []string, s string) bool {
for _, e := range list {
if e == s {
return true
}
}
return false
}
// done says whether an issue another depends on lets it go: resolved with every pull request its fix names
// delivered, or wontfix — but a wontfix closed as the duplicate of another lets go only when that one does, as
// the work moved there (ADR 0279 §3).
func done(all map[uint64]*Issue, dep *Issue) bool {
seen := map[uint64]bool{}
for dep != nil && dep.Status == StatusWontfix {
if seen[dep.Number] {
return false
}
seen[dep.Number] = true
original := uint64(0)
for _, l := range dep.Links {
if l.Kind == LinkDuplicates {
original, _ = numberOfKey(l.Target)
}
}
if original == 0 {
return true
}
dep = all[original]
}
if dep == nil {
return false
}
if dep.Status != StatusResolved {
return false
}
for _, pr := range pullRequests(dep) {
if !contains(dep.Delivered, pr) {
return false
}
}
return true
}
// waitsFor is the issues an issue depends on that do not let it go yet; an issue not kept counts as waiting.
func waitsFor(all map[uint64]*Issue, is *Issue) []uint64 {
var out []uint64
for _, n := range dependsOn(is) {
if dep := all[n]; dep == nil || !done(all, dep) {
out = append(out, n)
}
}
return out
}
// isReady says whether an issue is ready (ADR 0279 §3).
func isReady(all map[uint64]*Issue, is *Issue) bool {
return unresolved(is) && len(waitsFor(all, is)) == 0
}
// neededBy is the issues that depend on an issue.
func neededBy(all map[uint64]*Issue, n uint64) []uint64 {
var out []uint64
for _, is := range all {
for _, d := range dependsOn(is) {
if d == n {
out = append(out, is.Number)
}
}
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
// View is one issue as `get` answers it: the issue, what depends on it, what it waits for, and whether it is
// ready.
type View struct {
*Issue
NeededBy []uint64 `json:"needed-by,omitempty"`
WaitsFor []uint64 `json:"waits-for,omitempty"`
Ready bool `json:"ready"`
// Delivery is whether its fix is delivered, in words (ADR 0281).
Delivery string `json:"delivery,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).
DeliveredNotResolved bool `json:"delivered-not-resolved,omitempty"`
Abandoned bool `json:"abandoned,omitempty"`
}
// View is `get`.
func (t *Tracker) View(n uint64) (*View, error) {
all, err := t.allIssues()
if err != nil {
return nil, err
}
is := all[n]
if is == nil {
return nil, fmt.Errorf("there is no issue %d: %w", n, ErrNoIssue)
}
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),
DeliveredNotResolved: deliveredNotResolved(is), Abandoned: abandoned(is, now, staleAfter)}, nil
}
// letGo says, for an issue that may have just let others go (its fix delivered, or wontfix), every issue it
// made ready: a note on each, once, and the event `ready`.
func (t *Tracker) letGo(dep uint64) error {
all, err := t.allIssues()
if err != nil {
return err
}
d := all[dep]
if d == nil || !done(all, d) {
return nil
}
// Those that depend on it, and on anything closed as its duplicate.
waiting := neededBy(all, dep)
for n, is := range all {
for _, l := range is.Links {
if l.Kind == LinkDuplicates && l.Target == keyOf(dep) {
waiting = append(waiting, neededBy(all, n)...)
}
}
}
for _, w := range waiting {
is := all[w]
if is == nil || !isReady(all, is) {
continue
}
// Said once per spell of readiness: the dependencies it was said ready for are kept, claimed by
// compare-and-set before the note, so a second dependency letting it go at the same delivery, or the
// same event again, says nothing.
// At most once: a note that fails after the claim is not said again; the issue still reads ready.
deps := dependsOn(is)
sort.Slice(deps, func(i, j int) bool { return deps[i] < deps[j] })
claimed := false
if _, err := t.change(w, Act{Op: EventReady, By: Seat}, func(i *Issue) error {
claimed = false
if equalNumbers(i.ReadyFor, deps) {
return nil
}
i.ReadyFor, claimed = deps, true
return nil
}); err != nil {
return err
}
if !claimed {
continue
}
why := "is resolved and its fix is delivered"
if d.Status == StatusWontfix {
why = "was closed as wontfix"
}
if _, err := t.note(GroomBy, w, fmt.Sprintf("Ready: issue %d, which this one depends on, %s; nothing it depends "+
"on is left waiting, so it is ready. (novox/hq ADR 0279)", dep, why)); err != nil {
return err
}
t.emit(EventReady, map[string]any{"number": w, "after": dep, "at": t.now()})
}
return nil
}