Files
mesh-catalog/modules/mesh-issues/cmd/mesh-issues/tracker.go
T
jochen d413da203c
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 delivered
mesh-issues: resume an interrupted carry by revision and fault one issue instead of the whole start, so no kept value is lost and the tracker always starts (review of PR 228)
An open's lost answer is judged by the issue's first entry; an entry that does not
fold faults its issue on start; a value written beside the log is never folded over;
one issue is kept in at most 252 KiB so its whole operation fits a log entry.
2026-10-12 10:35:57 +02:00

901 lines
32 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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); nil says nothing there.
Notify Notifier
// 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 == 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/<module>\")", 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, or the catalogue's bus, forge or build agent): 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
}
// 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"`
Said string `json:"said"`
}
// 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
}
}
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 {
return nil, errors.New("before pages the newest-first list; a search is ranked best first, so give limit, " +
"or more words, instead")
}
}
limit := a.Limit
if limit <= 0 {
limit = 50
}
limit = min(limit, MostListResult)
all, err := t.allIssues()
if err != nil {
return nil, err
}
now, staleAfter := t.now(), t.workerStaleAfter()
var numbers []uint64
if search != "" {
var hits []hit
if v := t.view(); v.ready() {
hits = v.search(search)
} else {
x := newIndex()
for _, is := range all {
x.add(is)
}
hits = x.search(search)
}
for _, h := range hits {
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 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 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)
}
}
below := ""
if a.Before != 0 {
below = fmt.Sprintf(" below %d", a.Before)
}
switch {
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())
}