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: `#`, 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 `#`, 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 `#`, 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 }