Files
mesh-catalog/modules/mesh-issues/cmd/mesh-issues/tracker.go
T
jochen a73b04843d
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/delivery-group group feat/399-fixing-status delivered: every member is delivered
mesh-issues: a take-over note records who acted, not the worker named; tests for the remaining rules (hq issue 399)
The note's by is who acted as the runtime proves it (ADR 0275 §5); the
note's text names both workers. Tests for a resolved issue corrected, link
and set keeping the worker current, and a take-over reason with a secret's
shape. The ready-to-verify note says an issue resolves from fixing.
2026-10-10 21:10:36 +02:00

846 lines
29 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"
"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 are the audit trail.
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
// 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
groom grooming
}
// 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 on this call. The runtime does not hand a module its caller, so it is Unproven; it is
// never read from the call's arguments, so no caller can name another (ADR 0275 §5).
func (t *Tracker) by() string { 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 and writes the issue under it (to-be 53 §4). A refused
// issue takes no number. A number taken and then not written — the bus failing between the two — stays
// unused, and the answer says which: numbers may skip, never repeat.
func (t *Tracker) Open(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: t.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)
}
if err := t.create(&is); err != nil {
return nil, err
}
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
}
// create writes a new issue under its number, never over one: a key that is there already is a fault said
// loudly, and the number stays unused.
func (t *Tracker) create(is *Issue) error {
key := keyOf(is.Number)
var err error
for i := 0; i < createAttempts; i++ {
if i > 0 {
t.Sleep(jitter(time.Duration(i) * 50 * time.Millisecond))
}
_, err = t.Store.Create(IssuesState, key, is)
if err == nil {
return nil
}
if errors.Is(err, stdio.ErrChanged) {
// Ours, written by an attempt whose answer was lost?
there, _, rerr := read(t.Store, is.Number)
if rerr != nil {
t.Logf("[mesh-issues] issue %d is kept and could not be read back: %v", is.Number, rerr)
return fmt.Errorf("issue %d may have been written by this open: the key is taken and could not be read "+
"back (%v). Call get %d before opening it again, so it is not opened twice", is.Number, rerr, is.Number)
}
if there.Opening == is.Opening {
return nil
}
t.Logf("[mesh-issues] the counter handed out %d and an issue %d is already kept: not overwritten", is.Number, is.Number)
return 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", is.Number, is.Number, is.Number)
}
}
// A write that timed out may still have landed: say so, never "nothing was opened".
t.Logf("[mesh-issues] number %d was taken and the issue's write was not confirmed: %v", is.Number, err)
if there, _, rerr := read(t.Store, is.Number); rerr == nil && there.Opening == is.Opening {
return nil
}
return fmt.Errorf("number %d was taken and the issue's write was not confirmed (%v): it may have been written. "+
"Call get %d before opening it again; if there is no issue %d, it stays unused", is.Number, err, is.Number, is.Number)
}
// 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
}
// change reads an issue, applies f, and writes it only at the revision read; a write another writer won
// is read and applied again, so no note, link or move is lost and none is applied to a stale issue.
func (t *Tracker) change(n uint64, f func(*Issue) error) (*Issue, error) {
for i := 0; i < issueAttempts; i++ {
is, rev, err := read(t.Store, n)
if err != nil {
return nil, err
}
if err := f(is); err != nil {
return nil, err
}
raw, err := json.Marshal(is)
if err != nil {
return nil, err
}
if len(raw) > MostIssueBytes {
return nil, fmt.Errorf("issue %d would be %d bytes; one issue is kept in at most %d (256 KiB). Nothing "+
"was changed: say it shorter, or in a document the issue links", n, len(raw), MostIssueBytes)
}
_, err = t.Store.Update(IssuesState, keyOf(n), json.RawMessage(raw), rev)
if err == nil {
return is, nil
}
if !errors.Is(err, stdio.ErrChanged) {
return nil, fmt.Errorf("issue %d could not be written (%v); nothing was changed", n, err)
}
t.Sleep(jitter(time.Duration(i+1) * 5 * time.Millisecond))
}
return nil, fmt.Errorf("issue %d was changed by another caller %d times while this change was made; nothing was "+
"changed. Try again", n, issueAttempts)
}
// 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 0283 §1).
By string
// TakeOver is why the issue is taken over from another worker (ADR 0283 §1).
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"`
// 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 0283 §1): 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, 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 0283 §4).
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
}
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 0283), 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 0283 §3). 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, 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, 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
}
// 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 0283).
Worker string `json:"worker,omitempty"`
Since *time.Time `json:"since,omitempty"`
Stale bool `json:"stale,omitempty"`
}
// Listed is the answer to a list.
type Listed struct {
Rows []Row `json:"rows"`
Matched int `json:"matched"`
Said string `json:"said"`
}
// List is the issues that match, newest first.
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
}
}
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
for n := range all {
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
}
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)
}
out.Rows = append(out.Rows, row)
}
}
switch {
case out.Matched == 0:
out.Said = "No issue matches."
case out.Matched > len(out.Rows):
out.Said = fmt.Sprintf("%d issues match; the newest %d are shown.", out.Matched, len(out.Rows))
default:
out.Said = fmt.Sprintf("%d issues match, newest first.", out.Matched)
}
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())
}