Files
mesh-catalog/modules/mesh-issues/cmd/mesh-issues/ready.go
T
jochen 11afde55d6
mesh/delivery-group group feat/399-fixing-status checking: 0 of 2 member(s) ready
mesh/merge-gate pass: builds mesh-issues → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
mesh-issues: an issue says who works on it, and resolves only from fixing (hq issue 399)
Two agents could start on one issue without seeing each other, and an issue
could be resolved without anyone having said they started its fix. A move to
diagnosing or fixing now names by, the worker as who@node; get and list show
the worker with since and stale; another worker is refused while it is
current unless take-over says why (noted); a worker untouched for
worker-stale-after (24h) may be taken over from without a reason. Resolved is
reached only from fixing. The moved event carries by, since and caller.
Fixing counts as not closed for the triage and the duplicate check.
hq ADR 0283.
2026-10-10 21:04:32 +02:00

349 lines
9.8 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) }
// 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, 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
}
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 0283 §2).
WorkerStale bool `json:"worker-stale,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)
}
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(t.now(), t.workerStaleAfter())}, 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, 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
}