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
683 lines
22 KiB
Go
683 lines
22 KiB
Go
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
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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
|
||
}
|
||
}
|
||
at := t.now()
|
||
is := Issue{Title: title, Status: StatusOpen, Report: report, Opened: at, OpenedBy: t.by(), LocatedIn: located, Opening: newID()}
|
||
|
||
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,
|
||
"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
|
||
}
|
||
|
||
// 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 string `json:"by"`
|
||
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) {
|
||
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)
|
||
}
|
||
|
||
var from string
|
||
var changed []string
|
||
is, err := t.change(a.Number, func(is *Issue) error {
|
||
changed = nil
|
||
from = is.Status
|
||
if from == to {
|
||
return fmt.Errorf("issue %d is already %s", is.Number, to)
|
||
}
|
||
set := func(name string, cond bool, apply func()) {
|
||
if cond {
|
||
apply()
|
||
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 })
|
||
if err := cycleRules(is, to); err != nil {
|
||
return err
|
||
}
|
||
is.Status = to
|
||
if to == StatusResolved || to == StatusWontfix {
|
||
at := t.now()
|
||
is.Closed = &at
|
||
} else {
|
||
is.Closed = nil
|
||
}
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out := &Moved{Number: is.Number, From: from, To: to, Changed: changed, By: t.by(),
|
||
Said: fmt.Sprintf("Issue %d moved from %s to %s.", is.Number, from, to)}
|
||
body := map[string]any{"number": is.Number, "from": from, "to": to, "by": out.By, "at": t.now()}
|
||
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 at a status (hq cycle.py, playbook 03, to-be 53
|
||
// §5), said as the rule that refused it.
|
||
func cycleRules(is *Issue, 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:
|
||
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) {
|
||
text, err := multiLine("text", text, MostNote)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
note := Note{By: t.by(), At: t.now(), Text: text}
|
||
is, err := t.change(n, func(is *Issue) error {
|
||
is.Notes = append(is.Notes, note)
|
||
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) {
|
||
kind := strings.TrimSpace(a.Kind)
|
||
target, err := checkTarget(kind, a.Target)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if (kind == LinkDuplicates || kind == LinkRelatesTo) && !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
|
||
}
|
||
}
|
||
by, at := t.by(), 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:]...)
|
||
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})
|
||
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
|
||
}
|
||
|
||
// 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"`
|
||
}
|
||
|
||
// 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
|
||
}
|
||
}
|
||
limit := a.Limit
|
||
if limit <= 0 {
|
||
limit = 50
|
||
}
|
||
limit = min(limit, MostListResult)
|
||
keys, err := t.Store.Keys(IssuesState)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("the issues cannot be listed: %v", err)
|
||
}
|
||
var numbers []uint64
|
||
for _, k := range keys {
|
||
if n, ok := numberOfKey(k); ok {
|
||
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, _, err := read(t.Store, n)
|
||
if errors.Is(err, ErrNoIssue) {
|
||
continue
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if status != "" && is.Status != status {
|
||
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 {
|
||
out.Rows = append(out.Rows, Row{Number: n, Status: is.Status, Owner: strings.Join(is.LocatedIn, "; "), Title: is.Title})
|
||
}
|
||
}
|
||
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())
|
||
}
|