package main import ( "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "math/big" "sort" "strings" "sync" "time" stdio "git.novox.be/novox/mesh-sdk/go" ) // Tracker is the holder of the issue-tracker seat (novox/hq ADR 0275, to-be 53): it hands out each issue's // number from one counter, keeps each issue, refuses what the development cycle refuses, and says every // change as an event. type Tracker struct { Store Store // Emit says an event as this module (ADR 0041); the events announce, the log is the audit trail (ADR 0300 §4). Emit func(event string, body any) error Settings func() (Settings, error) Now func() time.Time Logf func(format string, a ...any) // Sleep waits between rounds of a counter that other openers keep winning. Sleep func(time.Duration) // Records is the record the import reads; nil is the records module over the bus. Records Records // Highest is the highest decision and research number the knowledge base holds, which reserve starts and // raises its counters above (hq issue 488); nil is the knowledge base over the bus. Highest func() (map[string]uint64, error) // Judge is the triage's way to a model (ADR 0278, ADR 0279); nil judges nothing. Judge Judgement // Deliveries answers a pull request's delivery (ADR 0279 §3, ADR 0281); nil asks nothing. Deliveries DeliveryState // Notify tells the operator's channel of a stuck fix (ADR 0281) and of a fix over its budget (ADR 0313); nil // says nothing there. Notify Notifier // Approval answers when a pull request was first approved at its head (ADR 0313 §4); nil asks nothing, and a // fix's review is counted with its checks. Approval Approval // CallerOf is who called, for the arguments one call was handed (stdio.CallerOf); nil proves nobody. CallerOf func(map[string]any) string // Log is the tracker's record, the log `changes` (novox/hq ADR 0300); the bucket `issues` is its fold. Log Log groom grooming held *memoryView heldOnce sync.Once } // The events this module says, one per kind of change. const ( EventOpened = "opened" EventMoved = "moved" EventNoted = "noted" EventLinked = "linked" ) // Bounds on how often a write another writer won is tried again. Each is a bounded loop: past it the // caller is told, and nothing was written. const ( // counterRounds × counterAttempts is how many times one open tries to advance the counter. counterRounds = 20 counterAttempts = 8 // issueAttempts is how many times a change to one issue is read and written again. issueAttempts = 16 // createAttempts is how many times the new issue's key is written after a failure that was not a refusal. createAttempts = 3 ) // by is who acted where no call says it — a public method called at the terminal or in a test: Unproven. func (t *Tracker) by() string { return Unproven } // callerOf is who acted on one call, as the runtime proves it: the bus user that published the call, which the // runtime takes from the subject it arrived on (hq issue 365, mesh-sdk stdio.CallerOf), or Unproven where the // runtime proves none. It is never read from the call's arguments, so no caller can name another (ADR 0275 §5). func (t *Tracker) callerOf(m map[string]any) string { if t.CallerOf == nil { return Unproven } if c := t.CallerOf(m); c != "" { return c } return Unproven } func (t *Tracker) now() time.Time { return t.Now().UTC().Truncate(time.Second) } func (t *Tracker) owners() (Owners, error) { s, err := t.Settings() if err != nil { return nil, fmt.Errorf("the registered list of owners cannot be read: %v", err) } return Owners(s.Owners), nil } // emit says an event, and answers whether it was said: a change already written is never undone because // its event could not be said, and never reported as said when it was not. func (t *Tracker) emit(event string, body map[string]any) (bool, string) { if err := t.Emit(event, body); err != nil { t.Logf("[mesh-issues] issue %v was changed and its %s event could not be said: %v", body["number"], event, err) return false, fmt.Sprintf("the change is kept, and its %s event could not be said on the bus (%v): "+ "what watches the events did not hear of it", event, err) } return true, "" } // OpenAsk is what an open carries. type OpenAsk struct { Title string Report string LocatedIn []string Record string Severity string Needs string } // Opened is the answer to an open. type Opened struct { Number uint64 `json:"number"` Title string `json:"title"` Status string `json:"status,omitempty"` LocatedIn []string `json:"located-in,omitempty"` Record string `json:"record,omitempty"` By string `json:"by"` Said string `json:"said"` Announced bool `json:"announced"` Warning string `json:"warning,omitempty"` } // Open checks an issue, then takes the next number, appends the issue's first operation to the log at expect // 0, and writes its fold under its number (to-be 53 §4, novox/hq ADR 0300 §2). An issue refused at validation // takes no number. A number taken and then not taken by the log — the bus failing between the two, or a full // log — is spent, and the open answers the failure: numbers may skip, never repeat. func (t *Tracker) Open(a OpenAsk) (*Opened, error) { return t.open(t.by(), a) } // open is Open as the one who acts. func (t *Tracker) open(by string, a OpenAsk) (*Opened, error) { title, err := singleLine("title", a.Title, MostTitle) if err != nil { return nil, err } report, err := multiLine("report", a.Report, MostReport) if err != nil { return nil, err } owners, err := t.owners() if err != nil { return nil, err } located, err := entries("located-in", a.LocatedIn, owners.locatedIn) if err != nil { return nil, err } record := "" if strings.TrimSpace(a.Record) != "" { if record, err = checkSlug(a.Record); err != nil { return nil, err } } var severity, needs string if strings.TrimSpace(a.Severity) != "" { if severity, err = oneOf("severity", a.Severity, Severities); err != nil { return nil, err } } if strings.TrimSpace(a.Needs) != "" { if needs, err = oneOf("needs", a.Needs, NeedsKinds); err != nil { return nil, err } } at := t.now() is := Issue{Title: title, Status: StatusOpen, Report: report, Opened: at, OpenedBy: by, LocatedIn: located, Opening: newID(), Severity: severity, Needs: needs} n, err := t.next() if err != nil { return nil, err } is.Number = n if record != "" { is.Record = RecordOf(n, record) } // The first operation, at expect 0 (ADR 0300 §2): one that fails after the number was taken spends the // number, and the open answers the failure. var seq uint64 if there, _, rerr := read(t.Store, n); rerr == nil && there.Opening != is.Opening { // Kept from before the switch and not carried yet: the log does not know it, the bucket does. err = errBegun } else { seq, err = t.begin(&is, Act{Op: OpOpened, By: by}) } if errors.Is(err, errBegun) { t.Logf("[mesh-issues] the counter handed out %d and an issue %d is already kept: not overwritten", n, n) return nil, fmt.Errorf("a fault, not your mistake: the counter handed out %d, and an issue %d is already kept. "+ "Nothing was overwritten and nothing was opened; %d stays unused. Open it again for the next number, "+ "and tell the operator the counter is behind the issues kept", n, n, n) } if errors.Is(err, errUnsure) { t.Logf("[mesh-issues] number %d was taken and the log's answer to the issue was lost: %v", n, err) return nil, fmt.Errorf("number %d was taken and the log did not confirm the issue (%v): it may have been written. "+ "Call get %d before opening it again; if there is no issue %d, it is spent", n, err, n, n) } if err != nil { t.Logf("[mesh-issues] number %d was taken and the log did not take the issue: %v", n, err) return nil, fmt.Errorf("number %d was taken and the log did not take the issue (%v): nothing was opened and %d "+ "is spent. Open it again for the next number", n, err, n) } is.Folded = seq t.store(n, &is, 0) out := &Opened{Number: n, Title: title, Status: is.Status, LocatedIn: located, Record: is.Record, By: is.OpenedBy} out.Said = fmt.Sprintf("Opened issue %d: %s.", n, title) if is.Record != "" { out.Said += fmt.Sprintf(" Its narrative goes in hq's %s/00-report.md; its status stays here.", is.Record) } out.Announced, out.Warning = t.emit(EventOpened, map[string]any{ "number": n, "title": title, "status": is.Status, "located-in": located, "record": is.Record, "severity": severity, "needs": needs, "by": is.OpenedBy, "at": at}) return out, nil } // next takes the next issue number: the counter advanced only at the revision read, so two openers — in // one instance or two — never take one number (ADR 0275 §3). func (t *Tracker) next() (uint64, error) { e, err := t.Store.Get(CountersState, IssueCounter) if err != nil { return 0, fmt.Errorf("the issue counter cannot be read (%v); no number was taken", err) } if e == nil { if err := t.seed(); err != nil { return 0, err } } for round := 0; round < counterRounds; round++ { n, err := t.Store.Increment(CountersState, IssueCounter, counterAttempts) if err == nil { return n, nil } if !errors.Is(err, stdio.ErrChanged) { t.Logf("[mesh-issues] the issue counter could not be advanced: %v", err) return 0, fmt.Errorf("the issue counter could not be advanced (%v); nothing was opened. If the bus took "+ "the write before it failed, one number stays unused", err) } // Others are opening at this moment: wait a little, longer each round, and try again. t.Sleep(jitter(time.Duration(round+1) * 10 * time.Millisecond)) } return 0, fmt.Errorf("so many issues are being opened at this moment that the counter was taken by another "+ "opener %d times; no number was taken and nothing was opened. Open it again", counterRounds*counterAttempts) } // seed starts the counter where the setting `first-number` says, made only when there is none, and never // below an issue already kept: the first number handed out is the setting's, or the one after the highest // kept, whichever is higher. func (t *Tracker) seed() error { s, err := t.Settings() if err != nil { return fmt.Errorf("the issue counter has not started and the settings cannot be read (%v); no number was taken", err) } if s.FirstNumber < 1 { return errors.New("the issue counter has not started and the setting first-number is not set: it is the " + "number the first issue gets, the one after the highest anywhere else. No number was taken") } start := s.FirstNumber - 1 keys, err := t.Store.Keys(IssuesState) if err != nil { return fmt.Errorf("the issue counter has not started and the issues kept cannot be listed (%v); no number was taken", err) } for _, k := range keys { if n, ok := numberOfKey(k); ok && n > start { start = n } } if _, err := t.Store.Create(CountersState, IssueCounter, start); err != nil && !errors.Is(err, stdio.ErrChanged) { return fmt.Errorf("the issue counter could not be started (%v); no number was taken", err) } // ErrChanged: another opener started it first, which is the same start. return nil } // Get is one issue whole. func (t *Tracker) Get(n uint64) (*Issue, error) { is, _, err := read(t.Store, n) return is, err } // MoveAsk is what a move carries: the status, and what it needs. type MoveAsk struct { Number uint64 Status string LocatedIn []string FixedBy []string AmendedDesign []string Replay string ReplayNone string Reason string // By is who works on it, who@node: what a move to diagnosing or fixing needs (ADR 0284 §2). By string // TakeOver is why the issue is taken over from another worker (ADR 0284 §3). TakeOver string } // Moved is the answer to a move. type Moved struct { Number uint64 `json:"number"` From string `json:"from"` To string `json:"to"` Changed []string `json:"changed,omitempty"` // By is who works on it, as the move named it; for a move that names nobody, who acted. By string `json:"by"` // Since is when By started on it, for a move to diagnosing or fixing. Since *time.Time `json:"since,omitempty"` // TookOverFrom is the worker a take-over took the issue over from. TookOverFrom string `json:"took-over-from,omitempty"` // Abandoned says the worker taken over from had abandoned it: nothing done on it past worker-stale-after and // no pull request open, so no reason was needed (novox/hq issue 483). Abandoned bool `json:"abandoned,omitempty"` // Released is the worker whose work on it the move ended. Released string `json:"released,omitempty"` Said string `json:"said"` Announced bool `json:"announced"` Warning string `json:"warning,omitempty"` } func known(status string) bool { for _, s := range Statuses { if s == status { return true } } return false } // Move changes an issue's status under the cycle's rules (to-be 53 §5). A refused move changes nothing and // says which rule refused it. func (t *Tracker) Move(a MoveAsk) (*Moved, error) { return t.move(t.by(), a) } // move is Move as the one who acts: a caller, or the duplicate check. func (t *Tracker) move(by string, a MoveAsk) (*Moved, error) { to := strings.TrimSpace(a.Status) if !known(to) { return nil, fmt.Errorf("status %q is not one of %s", to, strings.Join(Statuses, ", ")) } owners, err := t.owners() if err != nil { return nil, err } located, err := entries("located-in", a.LocatedIn, owners.locatedIn) if err != nil { return nil, err } fixed, err := entries("fixed-by", a.FixedBy, func(e string) (string, error) { return singleLine("an entry of fixed-by", e, MostLine) }) if err != nil { return nil, err } designs, err := entries("amended-design", a.AmendedDesign, checkDesign) if err != nil { return nil, err } var replay, replayNone, reason string if strings.TrimSpace(a.Replay) != "" { if replay, err = singleLine("replay", a.Replay, MostLine); err != nil { return nil, err } if !replayID.MatchString(replay) { return nil, fmt.Errorf("replay %q names no replay: an id from mesh-lab's replays register, R and a number, "+ "such as R361", replay) } } if strings.TrimSpace(a.ReplayNone) != "" { if replayNone, err = multiLine("replay-none", a.ReplayNone, MostReason); err != nil { return nil, err } } if strings.TrimSpace(a.Reason) != "" { if reason, err = multiLine("reason", a.Reason, MostReason); err != nil { return nil, err } } if (replay != "" || replayNone != "") && to != StatusResolved { return nil, fmt.Errorf("replay and replay-none are what resolving a core issue needs; this moves to %s", to) } if replay != "" && replayNone != "" { return nil, errors.New("give replay or replay-none, not both: a replay proves the fix, replay-none says why none can") } if reason != "" && to != StatusWontfix { return nil, fmt.Errorf("reason is what wontfix needs; this moves to %s", to) } // Who works on it (ADR 0284 §2): named on a move to diagnosing or fixing, and on no other. var worker, takeOver string if worked(to) { if strings.TrimSpace(a.By) == "" { return nil, fmt.Errorf("a move to %s needs by: who works on it and its node, as who@node, such as "+ "fix-399@g14 or jochen@shanks", to) } if worker, err = checkWorker(a.By); err != nil { return nil, err } } else if strings.TrimSpace(a.By) != "" { return nil, fmt.Errorf("by names who works on an issue, which only diagnosing and fixing hold; this moves to %s", to) } if strings.TrimSpace(a.TakeOver) != "" { if !worked(to) { return nil, fmt.Errorf("take-over is for taking over who works on an issue, at diagnosing or fixing; this moves to %s", to) } if takeOver, err = multiLine("take-over", a.TakeOver, MostReason); err != nil { return nil, err } } staleAfter := t.workerStaleAfter() var from string var changed []string var work workOutcome is, err := t.change(a.Number, Act{Op: EventMoved, By: by}, func(is *Issue) error { changed = nil from = is.Status at := t.now() // A field counts as changed only when its value does, so a move that changes nothing is refused. set := func(name string, cond bool, apply func()) { if !cond { return } before, _ := json.Marshal(is) apply() if after, _ := json.Marshal(is); string(before) != string(after) { changed = append(changed, name) } } set("located-in", located != nil, func() { is.LocatedIn, is.LocatedInUnverified = located, nil }) set("fixed-by", fixed != nil, func() { is.FixedBy = fixed }) set("amended-design", designs != nil, func() { is.AmendedDesign = designs }) set("replay", replay != "", func() { is.Replay, is.ReplayNone = replay, "" }) set("replay-none", replayNone != "", func() { is.ReplayNone, is.Replay = replayNone, "" }) set("reason", reason != "", func() { is.Reason = reason }) prev := is.Worker if prev != nil { c := *prev prev = &c } h, cerr := workerFor(is, to, worker, takeOver, at, staleAfter) if cerr != nil { return cerr } work = h if work.took { changed = append(changed, "by") } if work.from != "" { is.Notes = append(is.Notes, Note{By: by, At: at, Text: takeOverNote(work, worker, takeOver, prev)}) } // The same status again only to change what it holds: a fix named while the issue stays located, to be // resolved once it is delivered and proven (ADR 0281 §3). if from == to && len(changed) == 0 { return fmt.Errorf("issue %d is already %s", is.Number, to) } if err := cycleRules(is, from, to); err != nil { return err } is.Status = to if to == StatusFixing && from != StatusFixing && is.FixingFrom == nil { // The first move to fixing starts the fix time; none after it restarts it (ADR 0313 §1). start := at is.FixingFrom = &start } if (to == StatusResolved || to == StatusWontfix) && from != to { is.Closed = &at } else if to != StatusResolved && to != StatusWontfix { is.Closed = nil } return nil }) if err != nil { return nil, err } out := &Moved{Number: is.Number, From: from, To: to, Changed: changed, By: by, TookOverFrom: work.from, Released: work.released, Said: fmt.Sprintf("Issue %d moved from %s to %s.", is.Number, from, to)} // The event's by is who works on it when the move names them, and who acted otherwise; caller is always who // acted, as the runtime proves it (ADR 0284 §6). body := map[string]any{"number": is.Number, "from": from, "to": to, "by": by, "caller": by, "at": t.now()} if is.Worker != nil { since := is.Worker.Since out.By, out.Since = is.Worker.By, &since body["by"], body["since"] = is.Worker.By, since out.Said = fmt.Sprintf("Issue %d moved from %s to %s; %s works on it since %s.", is.Number, from, to, is.Worker.By, since.Format(time.RFC3339)) } if work.from != "" { body["took-over-from"] = work.from if takeOver != "" { body["take-over"] = takeOver } if work.abandoned { out.Abandoned, body["abandoned"] = true, true out.Said += fmt.Sprintf(" Taken over from %s, who had abandoned it (nothing done on it for %s and no "+ "pull request open); noted on the issue.", work.from, staleAfter) } else { out.Said += fmt.Sprintf(" Taken over from %s; noted on the issue.", work.from) } } if work.released != "" { body["released"] = work.released } for _, c := range changed { switch c { case "located-in": body[c] = is.LocatedIn case "fixed-by": body[c] = is.FixedBy case "amended-design": body[c] = is.AmendedDesign case "replay": body[c] = is.Replay case "replay-none": body[c] = is.ReplayNone case "reason": body[c] = is.Reason } } out.Announced, out.Warning = t.emit(EventMoved, body) return out, nil } // cycleRules is what the development cycle asks of an issue moving from one status to another (playbook 03, // to-be 53 §5, ADR 0284), said as the rule that refused it. func cycleRules(is *Issue, from, to string) error { switch to { case StatusLocated: if len(is.LocatedIn) == 0 { return fmt.Errorf("issue %d cannot be located without located-in: name at least one registered owner "+ "(a repository, or \"mesh-catalog modules/\")", is.Number) } case StatusResolved: // Resolved only from fixing: whoever fixed it said so when they started (ADR 0284 §5). A resolved issue // may still have what it holds corrected. if from != StatusFixing && from != StatusResolved { return fmt.Errorf("issue %d is %s, and an issue is resolved only from fixing: move it to fixing with by, "+ "who works on it (who@node), when the fix starts, then resolve it", is.Number, from) } if len(is.LocatedIn) == 0 { return fmt.Errorf("issue %d cannot be resolved without located-in: name the owner that was fixed", is.Number) } if len(is.FixedBy) == 0 { return fmt.Errorf("issue %d cannot be resolved without fixed-by: name the pull requests or commits that fixed it", is.Number) } if IsCore(is.LocatedIn) && is.Replay == "" && is.ReplayNone == "" { return fmt.Errorf("issue %d is a core issue (it is located in the controller, the node-engine, the tool "+ "runner, the SDK, the delivery parts, or the catalogue's bus, forge or build modules): it resolves with replay, the replay's id "+ "in mesh-lab's replays register, or replay-none, why no replay is possible (ADR 0237)", is.Number) } case StatusWontfix: if strings.TrimSpace(is.Reason) == "" { return fmt.Errorf("issue %d cannot be closed as wontfix without a reason: say why it will not be fixed", is.Number) } } return nil } // Noted is the answer to a note. type Noted struct { Number uint64 `json:"number"` Notes int `json:"notes"` By string `json:"by"` Said string `json:"said"` Announced bool `json:"announced"` Warning string `json:"warning,omitempty"` } // Note appends a note to an issue; a note is never edited. func (t *Tracker) Note(n uint64, text string) (*Noted, error) { return t.note(t.by(), n, text) } // note is Note as the one who acts. func (t *Tracker) note(by string, n uint64, text string) (*Noted, error) { text, err := multiLine("text", text, MostNote) if err != nil { return nil, err } note := Note{By: by, At: t.now(), Text: text} is, err := t.change(n, Act{Op: EventNoted, By: by}, func(is *Issue) error { is.Notes = append(is.Notes, note) touch(is, by, note.At) return nil }) if err != nil { return nil, err } out := &Noted{Number: n, Notes: len(is.Notes), By: note.By, Said: fmt.Sprintf("Noted on issue %d (note %d).", n, len(is.Notes))} out.Announced, out.Warning = t.emit(EventNoted, map[string]any{"number": n, "by": note.By, "at": note.At, "text": text}) return out, nil } // LinkAsk is what a link carries. type LinkAsk struct { Number uint64 Kind string Target string Remove bool } // Linked is the answer to a link. type Linked struct { Number uint64 `json:"number"` Kind string `json:"kind"` Target string `json:"target"` Removed bool `json:"removed"` Links int `json:"links"` By string `json:"by"` Said string `json:"said"` Announced bool `json:"announced"` Warning string `json:"warning,omitempty"` } // Link adds or removes a typed link. func (t *Tracker) Link(a LinkAsk) (*Linked, error) { return t.link(t.by(), a) } // link is Link as the one who acts. func (t *Tracker) link(by string, a LinkAsk) (*Linked, error) { kind := strings.TrimSpace(a.Kind) target, err := checkTarget(kind, a.Target) if err != nil { return nil, err } if (kind == LinkDuplicates || kind == LinkRelatesTo || kind == LinkDependsOn) && !a.Remove { other, ok := numberOfKey(target) if !ok { return nil, fmt.Errorf("%q is not an issue's number", target) } if other == a.Number { return nil, fmt.Errorf("issue %d cannot be linked to itself", a.Number) } if _, _, err := read(t.Store, other); err != nil { return nil, err } if kind == LinkDependsOn { // A cycle is refused, naming it (ADR 0279 §1). Read whole here; a race of two links closing one cycle // at the same moment is not ruled out, and readiness then waits, which is the safe side. all, err := t.allIssues() if err != nil { return nil, err } if reaches(all, other, a.Number) { return nil, fmt.Errorf("issue %d depending on %d: %w, as %d already depends on %d", a.Number, other, ErrCycle, other, a.Number) } } } at := t.now() is, err := t.change(a.Number, Act{Op: EventLinked, By: by}, func(is *Issue) error { for i, l := range is.Links { if l.Kind == kind && l.Target == target { if !a.Remove { return fmt.Errorf("issue %d already has the %s link to %s", is.Number, kind, target) } is.Links = append(is.Links[:i:i], is.Links[i+1:]...) touch(is, by, at) return nil } } if a.Remove { return fmt.Errorf("issue %d has no %s link to %s to remove", is.Number, kind, target) } if len(is.Links) >= MostLinks { return fmt.Errorf("issue %d has %d links, the most one issue keeps", is.Number, MostLinks) } is.Links = append(is.Links, Link{Kind: kind, Target: target, By: by, At: at}) touch(is, by, at) return nil }) if err != nil { return nil, err } out := &Linked{Number: a.Number, Kind: kind, Target: target, Removed: a.Remove, Links: len(is.Links), By: by} if a.Remove { out.Said = fmt.Sprintf("Removed the %s link from issue %d to %s.", kind, a.Number, target) } else { out.Said = fmt.Sprintf("Linked issue %d to %s (%s).", a.Number, target, kind) } out.Announced, out.Warning = t.emit(EventLinked, map[string]any{"number": a.Number, "kind": kind, "target": target, "removed": a.Remove, "by": by, "at": at}) return out, nil } // ListAsk narrows a list. type ListAsk struct { Status string Owner string Phrase string Limit int Severity string Needs string // Ready, when set, keeps only the issues ready (true) or not ready (false). Ready *bool // Flag keeps only the issues delivered and not resolved, or the abandoned ones (novox/hq issue 483). Flag string // Search ranks the issues whose title, report or notes hold its words, best first (novox/hq ADR 0300 §5). Search string // Before keeps only the issues numbered below it: the cursor that pages the newest-first list. Before uint64 // Numbers keeps only these issues, rows in the order given, combined with the other filters as AND; a // number that is not an issue is named in the answer's Missing (novox/hq issue 1070). Numbers []uint64 // Worker keeps only the issues in diagnosing or fixing whose worker is exactly this, who@node (novox/hq // issue 1070). Worker string } // MostNumbers is the most `numbers` may name in one list. const MostNumbers = 200 // Row is one issue in a list: short enough to read in a chat. type Row struct { Number uint64 `json:"number"` Status string `json:"status"` Owner string `json:"owner,omitempty"` Title string `json:"title"` Severity string `json:"severity,omitempty"` Needs string `json:"needs,omitempty"` Ready bool `json:"ready"` // SuggestedOwner is the triage's or a person's suggestion, for an issue located nowhere yet. SuggestedOwner string `json:"suggested-owner,omitempty"` // Delivery is whether its fix is delivered, in words (ADR 0281); empty when fixed-by names no pull request. Delivery string `json:"delivery,omitempty"` // Worker is who works on it and Since since when, while it is diagnosing or fixing; Stale says nobody did // anything on it for worker-stale-after, so it may be taken over without a reason (ADR 0284 §4). Worker string `json:"worker,omitempty"` Since *time.Time `json:"since,omitempty"` Stale bool `json:"stale,omitempty"` // DeliveredNotResolved says it is in fixing with every fix it names delivered; Abandoned that its worker // did nothing on it for worker-stale-after and no pull request of it is open (novox/hq issue 483). DeliveredNotResolved bool `json:"delivered-not-resolved,omitempty"` Abandoned bool `json:"abandoned,omitempty"` } // Listed is the answer to a list. type Listed struct { Rows []Row `json:"rows"` Matched int `json:"matched"` // Next is the before that continues the list where this page ends; 0 when nothing more matches. Next uint64 `json:"next,omitempty"` // Missing is each number asked for (in Numbers) that is not an issue, in the order given (novox/hq issue // 1070). Missing []uint64 `json:"missing,omitempty"` Said string `json:"said"` } // searchHits ranks the issues whose title, report or notes hold search's words, best first, from memory once it // is loaded on start, else from a fresh index (novox/hq ADR 0300 §5). func (t *Tracker) searchHits(all map[uint64]*Issue, search string) []hit { if v := t.view(); v.ready() { return v.search(search) } x := newIndex() for _, is := range all { x.add(is) } return x.search(search) } // List is the issues that match, newest first, or ranked by a search: from memory once it is loaded on start // (novox/hq ADR 0300 §5). func (t *Tracker) List(a ListAsk) (*Listed, error) { status := strings.TrimSpace(a.Status) if status != "" && !known(status) { return nil, fmt.Errorf("status %q is not one of %s", status, strings.Join(Statuses, ", ")) } owner, phrase := "", "" var err error if strings.TrimSpace(a.Owner) != "" { if owner, err = singleLine("owner", a.Owner, MostLine); err != nil { return nil, err } } if strings.TrimSpace(a.Phrase) != "" { if phrase, err = singleLine("phrase", a.Phrase, MostPhrase); err != nil { return nil, err } } severity, needs := strings.TrimSpace(a.Severity), strings.TrimSpace(a.Needs) if severity != "" { if _, err := oneOf("severity", severity, Severities); err != nil { return nil, err } } if needs != "" { if _, err := oneOf("needs", needs, NeedsKinds); err != nil { return nil, err } } flag := strings.TrimSpace(a.Flag) if flag != "" { if _, err := oneOf("flag", flag, Flags); err != nil { return nil, err } } usingNumbers := len(a.Numbers) > 0 if len(a.Numbers) > MostNumbers { return nil, fmt.Errorf("numbers names %d entries; at most %d", len(a.Numbers), MostNumbers) } search := "" if strings.TrimSpace(a.Search) != "" { if search, err = singleLine("search", a.Search, MostPhrase); err != nil { return nil, err } if len(tokens(search)) == 0 { return nil, fmt.Errorf("search %q holds no word of two letters or digits", search) } if a.Before != 0 && !usingNumbers { return nil, errors.New("before pages the newest-first list; a search is ranked best first, so give limit, " + "or more words, instead") } } worker := "" if strings.TrimSpace(a.Worker) != "" { if worker, err = checkWorkerField("worker", a.Worker); err != nil { return nil, err } } limit := a.Limit if limit <= 0 { if usingNumbers { limit = len(a.Numbers) } else { limit = 50 } } limit = min(limit, MostListResult) all, err := t.allIssues() if err != nil { return nil, err } now, staleAfter := t.now(), t.workerStaleAfter() var numbers, missing []uint64 var searchSet map[uint64]bool if usingNumbers { numbers = a.Numbers if search != "" { searchSet = map[uint64]bool{} for _, h := range t.searchHits(all, search) { searchSet[h.Number] = true } } } else if search != "" { for _, h := range t.searchHits(all, search) { if all[h.Number] != nil { numbers = append(numbers, h.Number) } } } else { for n := range all { if a.Before == 0 || n < a.Before { numbers = append(numbers, n) } } sort.Slice(numbers, func(i, j int) bool { return numbers[i] > numbers[j] }) } out := &Listed{Rows: []Row{}} lower := strings.ToLower(phrase) for _, n := range numbers { is := all[n] if is == nil { if usingNumbers { missing = append(missing, n) } continue } if usingNumbers { if a.Before != 0 && n >= a.Before { continue } if searchSet != nil && !searchSet[n] { continue } } if status != "" && is.Status != status { continue } if severity != "" && is.Severity != severity { continue } if needs != "" && is.Needs != needs { continue } if flag != "" && !hasFlag(is, flag, now, staleAfter) { continue } ready := isReady(all, is) if a.Ready != nil && ready != *a.Ready { continue } if owner != "" && !locatedUnder(is.LocatedIn, owner) { continue } if worker != "" && (is.Worker == nil || is.Worker.By != worker) { continue } if lower != "" && !strings.Contains(strings.ToLower(is.Title), lower) && !strings.Contains(strings.ToLower(is.Report), lower) { continue } out.Matched++ if len(out.Rows) < limit { row := Row{Number: n, Status: is.Status, Owner: strings.Join(is.LocatedIn, "; "), Title: is.Title, Severity: is.Severity, Needs: is.Needs, Ready: ready, SuggestedOwner: is.SuggestedOwner, Delivery: DeliveryLine(is)} if c := is.Worker; c != nil { since := c.Since row.Worker, row.Since, row.Stale = c.By, &since, c.stale(now, staleAfter) } row.DeliveredNotResolved = deliveredNotResolved(is) row.Abandoned = abandoned(is, now, staleAfter) out.Rows = append(out.Rows, row) } } out.Missing = missing below := "" if a.Before != 0 { below = fmt.Sprintf(" below %d", a.Before) } switch { case usingNumbers && out.Matched == 0 && len(missing) == 0: out.Said = "No issue asked matches." case usingNumbers: out.Said = fmt.Sprintf("%d of %d asked match, in the order given.", out.Matched, len(a.Numbers)) if out.Matched > len(out.Rows) { out.Said += fmt.Sprintf(" The first %d are shown.", len(out.Rows)) } if len(missing) > 0 { out.Said += fmt.Sprintf(" %d of the numbers asked are not an issue.", len(missing)) } case out.Matched == 0: out.Said = "No issue matches" + below + "." case search != "" && out.Matched > len(out.Rows): out.Said = fmt.Sprintf("%d issues match the search; the best %d are shown, best first.", out.Matched, len(out.Rows)) case search != "": out.Said = fmt.Sprintf("%d issues match the search, best first.", out.Matched) case out.Matched > len(out.Rows): out.Next = out.Rows[len(out.Rows)-1].Number out.Said = fmt.Sprintf("%d issues match%s; the newest %d are shown. The next page is before %d.", out.Matched, below, len(out.Rows), out.Next) default: out.Said = fmt.Sprintf("%d issues match%s, newest first.", out.Matched, below) } return out, nil } // locatedUnder says whether an issue is located in the owner asked for: an entry that is the owner, or // starts with it and a space. func locatedUnder(located []string, owner string) bool { for _, e := range located { if e == owner || strings.HasPrefix(e, owner+" ") { return true } } return false } // newID is a random id for one open. func newID() string { var b [12]byte _, _ = rand.Read(b[:]) return hex.EncodeToString(b[:]) } // jitter is a random wait up to d, so openers that collided do not collide again in step. func jitter(d time.Duration) time.Duration { if d <= 0 { return 0 } n, err := rand.Int(rand.Reader, big.NewInt(int64(d))) if err != nil { return d } return time.Duration(n.Int64()) }