Add mesh-issues, the issue tracker, so an issue's number comes from one counter no two openers share (hq ADR 0275, to-be 53 slice 1)
mesh/merge-gate pass: builds new: modules/mesh-issues, sent nowhere; 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 superseded: a newer head of the same pull request
mesh/merge-gate pass: builds new: modules/mesh-issues, sent nowhere; 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 superseded: a newer head of the same pull request
The seat issue-tracker, held once for the mesh: open, get, list, move, note and link. The counter is advanced by the SDK's compare-and-set Increment (go/v0.1.12) and started once at the setting first-number (365, after hq's highest), never below an issue kept. The cycle's issue rules are refused at the write; every change is an event. The runtime does not yet say who called, so every change is recorded as by "unproven" and no argument can name a caller.
This commit is contained in:
@@ -0,0 +1,688 @@
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 = 12
|
||||
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
|
||||
}
|
||||
if len(located) > 0 {
|
||||
return nil, errors.New("an issue with a folder in hq keeps located-in in that folder's report until " +
|
||||
"the import: open it without located-in, or without record")
|
||||
}
|
||||
}
|
||||
at := t.now()
|
||||
is := Issue{Title: title, Report: report, Opened: at, OpenedBy: t.by(), LocatedIn: located, Opening: newID()}
|
||||
if record == "" {
|
||||
is.Status = StatusOpen
|
||||
}
|
||||
if size, _ := json.Marshal(is); len(size) > MostIssueBytes-1024 {
|
||||
return nil, fmt.Errorf("the issue is %d bytes; one issue is kept in at most %d", len(size), MostIssueBytes)
|
||||
}
|
||||
|
||||
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, which keeps its status.", 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++ {
|
||||
_, 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?
|
||||
if there, _, rerr := read(t.Store, is.Number); rerr == nil && 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)
|
||||
}
|
||||
}
|
||||
t.Logf("[mesh-issues] number %d was taken and the issue could not be written: %v", is.Number, err)
|
||||
return fmt.Errorf("number %d was taken and the issue could not be written (%v); nothing was opened and %d "+
|
||||
"stays unused. Open it again", is.Number, err, 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
|
||||
if is.Record != "" {
|
||||
return fmt.Errorf("issue %d keeps its status in its folder %s until the import of slice 3 has run: "+
|
||||
"change it there. The tracker holds its number, title, notes and links", is.Number, is.Record)
|
||||
}
|
||||
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 = located })
|
||||
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"`
|
||||
}
|
||||
|
||||
// InFolder is the status a list shows for an issue whose folder in hq keeps its status.
|
||||
const InFolder = "kept in its folder"
|
||||
|
||||
// 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 {
|
||||
st := is.Status
|
||||
if is.Record != "" {
|
||||
st = InFolder
|
||||
}
|
||||
out.Rows = append(out.Rows, Row{Number: n, Status: st, 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())
|
||||
}
|
||||
Reference in New Issue
Block a user