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
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.
846 lines
29 KiB
Go
846 lines
29 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
|
||
// 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())
|
||
}
|