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
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.
380 lines
11 KiB
Go
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
|
|
}
|