Files
mesh-catalog/modules/mesh-issues/cmd/mesh-issues/tracker.go
T
jochen d05308a419
mesh/merge-gate pass: builds mesh-issues → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
mesh-issues: import hq's issue folders at their own numbers, and an issue with a folder is whole in the tracker (hq ADR 0275, to-be 53 slice 3)
2026-10-10 16:00:19 +02:00

683 lines
22 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
}
// 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())
}