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)
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/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
This commit is contained in:
@@ -0,0 +1,683 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// The import of slice 3 (novox/hq ADR 0275 §8, to-be 53 §8): every issue folder in hq, read through the
|
||||
// records module over the bus, written as an issue under its own number. It only ever creates an issue
|
||||
// that is not there, or fills the status of one the tracker holds without a status (opened with a folder
|
||||
// before this slice); it never overwrites, never advances the counter, and run twice changes nothing and
|
||||
// says so. A dry run reads everything, writes nothing, and says what it would do.
|
||||
|
||||
// Records is the record as the records module serves it (to-be 53 §8: "read through the records
|
||||
// module"): where its checkout stands, what a folder holds, and one document whole.
|
||||
type Records interface {
|
||||
// Standing is the commit the checkout is at.
|
||||
Standing() (string, error)
|
||||
// List is a folder's sub-folders and documents.
|
||||
List(folder string) (folders, documents []string, err error)
|
||||
// Read is one document and the commit it was read at.
|
||||
Read(path string) (content, commit string, err error)
|
||||
}
|
||||
|
||||
// busRecords is Records over the bus: the records module's tools, called as this module (SDK Ask).
|
||||
type busRecords struct{}
|
||||
|
||||
func (busRecords) Standing() (string, error) {
|
||||
raw, err := stdio.Ask("records.records_status", map[string]any{})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var s struct {
|
||||
Commit string `json:"commit"`
|
||||
LastError string `json:"lastError"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
return "", fmt.Errorf("records_status answered something else: %v", err)
|
||||
}
|
||||
if s.Commit == "" {
|
||||
return "", fmt.Errorf("the record's checkout stands at no commit yet (%s)", s.LastError)
|
||||
}
|
||||
return s.Commit, nil
|
||||
}
|
||||
|
||||
func (busRecords) List(folder string) ([]string, []string, error) {
|
||||
raw, err := stdio.Ask("records.records_list", map[string]any{"folder": folder})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var f struct {
|
||||
Folders []string `json:"folders"`
|
||||
Documents []string `json:"documents"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &f); err != nil {
|
||||
return nil, nil, fmt.Errorf("records_list answered something else: %v", err)
|
||||
}
|
||||
return f.Folders, f.Documents, nil
|
||||
}
|
||||
|
||||
func (busRecords) Read(path string) (string, string, error) {
|
||||
raw, err := stdio.Ask("records.records_read", map[string]any{"path": path})
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
var d struct {
|
||||
Content string `json:"content"`
|
||||
Commit string `json:"commit"`
|
||||
Truncated bool `json:"truncated"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &d); err != nil {
|
||||
return "", "", fmt.Errorf("records_read answered something else: %v", err)
|
||||
}
|
||||
if d.Truncated {
|
||||
return "", "", fmt.Errorf("%s is longer than the records module answers whole", path)
|
||||
}
|
||||
return d.Content, d.Commit, nil
|
||||
}
|
||||
|
||||
// dirRecords is Records over a checkout on disk: the terminal's dry run before the module is rolled out,
|
||||
// and the tests. Its commit is `dir:` and the directory, as no forge stands behind it.
|
||||
type dirRecords struct{ dir string }
|
||||
|
||||
func (d dirRecords) Standing() (string, error) {
|
||||
if st, err := os.Stat(d.dir); err != nil || !st.IsDir() {
|
||||
return "", fmt.Errorf("%s is not a checkout", d.dir)
|
||||
}
|
||||
if out, err := os.ReadFile(filepath.Join(d.dir, ".git", "HEAD")); err == nil {
|
||||
ref := strings.TrimSpace(string(out))
|
||||
if strings.HasPrefix(ref, "ref: ") {
|
||||
if sha, err := os.ReadFile(filepath.Join(d.dir, ".git", strings.TrimPrefix(ref, "ref: "))); err == nil {
|
||||
return strings.TrimSpace(string(sha)), nil
|
||||
}
|
||||
} else if ref != "" {
|
||||
return ref, nil
|
||||
}
|
||||
}
|
||||
return "dir:" + d.dir, nil
|
||||
}
|
||||
|
||||
func (d dirRecords) List(folder string) ([]string, []string, error) {
|
||||
entries, err := os.ReadDir(filepath.Join(d.dir, filepath.FromSlash(folder)))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var folders, docs []string
|
||||
for _, e := range entries {
|
||||
switch {
|
||||
case e.IsDir():
|
||||
folders = append(folders, e.Name())
|
||||
case strings.HasSuffix(e.Name(), ".md"):
|
||||
docs = append(docs, e.Name())
|
||||
}
|
||||
}
|
||||
return folders, docs, nil
|
||||
}
|
||||
|
||||
func (d dirRecords) Read(path string) (string, string, error) {
|
||||
raw, err := os.ReadFile(filepath.Join(d.dir, filepath.FromSlash(path)))
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
commit, _ := d.Standing()
|
||||
return string(raw), commit, nil
|
||||
}
|
||||
|
||||
// IssuesFolder is where hq keeps its issue folders.
|
||||
const IssuesFolder = "04-ISSUES"
|
||||
|
||||
// readers is how many documents are read from the record at once.
|
||||
const readers = 8
|
||||
|
||||
// commitPrefix is what the commit argument may be: a commit's id, or a prefix of at least seven digits.
|
||||
var commitPrefix = regexp.MustCompile(`^[0-9a-f]{7,40}$`)
|
||||
|
||||
// errRecordMoved is the record's checkout moving to another commit while the import read it: the folders
|
||||
// read would be of two commits, so nothing is written.
|
||||
var errRecordMoved = errors.New("the record moved while it was read")
|
||||
|
||||
// EventImported is the event said for each issue the import wrote or filled.
|
||||
const EventImported = "imported"
|
||||
|
||||
// ImportAsk is what an import carries.
|
||||
type ImportAsk struct {
|
||||
// Commit, when given, is the commit of hq the record must stand at; the import refuses another.
|
||||
Commit string
|
||||
// DryRun reads everything, writes nothing, and says what it would do.
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// The actions an import takes on one folder.
|
||||
const (
|
||||
ActionCreate = "create" // the tracker holds no issue under the number: written
|
||||
ActionFill = "fill" // the tracker holds the issue without a status: its status and fields taken
|
||||
ActionSame = "same" // the tracker holds the issue as the folder has it: nothing to do
|
||||
ActionDiffers = "differs" // the tracker holds the issue differently: the tracker is the truth, nothing done
|
||||
ActionRefused = "refused" // the folder could not be imported, and why
|
||||
ActionWould = "would-" // prefixed to create and fill in a dry run
|
||||
)
|
||||
|
||||
// ImportRow is what the import did, or would do, with one folder.
|
||||
type ImportRow struct {
|
||||
Number uint64 `json:"number"`
|
||||
Action string `json:"action"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Folder string `json:"folder"`
|
||||
Why string `json:"why,omitempty"`
|
||||
}
|
||||
|
||||
// Imported is the answer to an import.
|
||||
type Imported struct {
|
||||
DryRun bool `json:"dry-run"`
|
||||
Commit string `json:"commit"`
|
||||
Folders int `json:"folders"`
|
||||
Created int `json:"created"`
|
||||
Filled int `json:"filled"`
|
||||
Same int `json:"same"`
|
||||
Differs int `json:"differs"`
|
||||
Refused int `json:"refused"`
|
||||
ByStatus map[string]int `json:"by-status"`
|
||||
Rows []ImportRow `json:"rows"`
|
||||
Odd []string `json:"odd,omitempty"`
|
||||
// UnverifiedOwners is each entry of located-in that named no registered owner, with the issues it is in.
|
||||
UnverifiedOwners map[string][]uint64 `json:"unverified-owners,omitempty"`
|
||||
Announced int `json:"announced"`
|
||||
Said string `json:"said"`
|
||||
Warning string `json:"warning,omitempty"`
|
||||
}
|
||||
|
||||
// candidate is one folder read and checked, before the tracker is asked what it holds.
|
||||
type candidate struct {
|
||||
folder string
|
||||
number uint64
|
||||
report *Report
|
||||
issue *Issue
|
||||
// unverified is the entries of located-in that named no registered owner.
|
||||
unverified []string
|
||||
err error
|
||||
}
|
||||
|
||||
// Import reads every issue folder of hq through the record and writes each as an issue under its own
|
||||
// number: create-if-absent on the key, the counter untouched. See the file's comment for what it never
|
||||
// does.
|
||||
func (t *Tracker) Import(a ImportAsk, records Records) (*Imported, error) {
|
||||
settings, err := t.Settings()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the settings cannot be read (%v); nothing was imported", err)
|
||||
}
|
||||
if settings.FirstNumber < 1 {
|
||||
return nil, errors.New("the setting first-number is not set: it is the boundary between the folders numbered " +
|
||||
"before the tracker and the issues the tracker numbered itself. Nothing was imported")
|
||||
}
|
||||
owners := Owners(settings.Owners)
|
||||
commit, err := records.Standing()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the record cannot be read (%v); nothing was imported", err)
|
||||
}
|
||||
want := strings.TrimSpace(a.Commit)
|
||||
if want != "" && !commitPrefix.MatchString(want) {
|
||||
return nil, errors.New("commit is a commit's id or a prefix of it, 7 to 40 hex digits; nothing was imported")
|
||||
}
|
||||
if want != "" && !strings.HasPrefix(commit, want) {
|
||||
return nil, fmt.Errorf("the record stands at hq commit %s, not %s: bring it up to date (records_sync), or "+
|
||||
"name the commit it stands at. Nothing was imported", short(commit), want)
|
||||
}
|
||||
folders, _, err := records.List(IssuesFolder)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the record's %s cannot be listed (%v); nothing was imported", IssuesFolder, err)
|
||||
}
|
||||
out := &Imported{DryRun: a.DryRun, Commit: commit, ByStatus: map[string]int{}, Rows: []ImportRow{}}
|
||||
at := t.now()
|
||||
by := t.by()
|
||||
|
||||
// Every folder's number, and no two folders sharing one.
|
||||
var cands []*candidate
|
||||
byNumber := map[uint64][]string{}
|
||||
for _, f := range folders {
|
||||
n, ok := folderNumber(f)
|
||||
if !ok {
|
||||
out.Odd = append(out.Odd, fmt.Sprintf("%s/%s is not an issue folder (NNN-slug) and was skipped", IssuesFolder, f))
|
||||
continue
|
||||
}
|
||||
byNumber[n] = append(byNumber[n], f)
|
||||
cands = append(cands, &candidate{folder: f, number: n})
|
||||
}
|
||||
out.Folders = len(cands)
|
||||
for _, c := range cands {
|
||||
if len(byNumber[c.number]) > 1 {
|
||||
c.err = fmt.Errorf("two folders are numbered %d: %s — a number names one issue", c.number, strings.Join(byNumber[c.number], " and "))
|
||||
}
|
||||
}
|
||||
|
||||
// The issues a report may link: the folders read, and the issues the tracker keeps.
|
||||
exists := map[uint64]bool{}
|
||||
for _, c := range cands {
|
||||
exists[c.number] = true
|
||||
}
|
||||
if keys, err := t.Store.Keys(IssuesState); err == nil {
|
||||
for _, k := range keys {
|
||||
if n, ok := numberOfKey(k); ok {
|
||||
exists[n] = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("the issues kept cannot be listed (%v); nothing was imported", err)
|
||||
}
|
||||
|
||||
// Read and check every report, a few at a time.
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, readers)
|
||||
for _, c := range cands {
|
||||
if c.err != nil {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(c *candidate) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
path := IssuesFolder + "/" + c.folder + "/00-report.md"
|
||||
text, readAt, err := records.Read(path)
|
||||
if err != nil {
|
||||
c.err = fmt.Errorf("%s could not be read: %v", path, err)
|
||||
return
|
||||
}
|
||||
if readAt != commit {
|
||||
c.err = fmt.Errorf("%w: it stood at %s and %s was read at %s. Run the import again",
|
||||
errRecordMoved, short(commit), path, short(readAt))
|
||||
return
|
||||
}
|
||||
c.report, c.err = ParseReport(c.folder, text)
|
||||
if c.err != nil {
|
||||
return
|
||||
}
|
||||
c.issue, c.unverified, c.err = t.issueOf(c.report, owners, commit, at, by, exists)
|
||||
}(c)
|
||||
}
|
||||
wg.Wait()
|
||||
for _, c := range cands {
|
||||
if errors.Is(c.err, errRecordMoved) {
|
||||
return nil, fmt.Errorf("%v; nothing was imported", c.err)
|
||||
}
|
||||
}
|
||||
|
||||
// The counter is never passed: a number the counter has not handed out is the counter's to hand out.
|
||||
var counter uint64
|
||||
if e, err := t.Store.Get(CountersState, IssueCounter); err != nil {
|
||||
return nil, fmt.Errorf("the issue counter cannot be read (%v); nothing was imported", err)
|
||||
} else if e != nil {
|
||||
if err := json.Unmarshal(e.Value, &counter); err != nil {
|
||||
return nil, fmt.Errorf("the issue counter holds %s, not a number; nothing was imported", e.Value)
|
||||
}
|
||||
for _, c := range cands {
|
||||
if c.err == nil && c.number > counter {
|
||||
return nil, fmt.Errorf("the folder %s is numbered past the counter, which stands at %d: a number the counter "+
|
||||
"has not handed out is not a folder's to take. Nothing was imported", c.folder, counter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(cands, func(i, j int) bool { return cands[i].number < cands[j].number })
|
||||
unverified := map[string][]uint64{}
|
||||
for _, c := range cands {
|
||||
row := ImportRow{Number: c.number, Folder: IssuesFolder + "/" + c.folder}
|
||||
if c.report != nil {
|
||||
row.Status, row.Title = c.report.Status, c.report.Title
|
||||
for _, o := range c.report.Odd {
|
||||
out.Odd = append(out.Odd, fmt.Sprintf("%d: %s", c.number, o))
|
||||
}
|
||||
}
|
||||
if c.err == nil {
|
||||
c.err = t.importOne(c, settings.FirstNumber, a.DryRun, &row, out)
|
||||
}
|
||||
if c.err != nil {
|
||||
row.Action, row.Why = ActionRefused, c.err.Error()
|
||||
}
|
||||
switch row.Action {
|
||||
case ActionRefused:
|
||||
out.Refused++
|
||||
case ActionSame:
|
||||
out.Same++
|
||||
case ActionDiffers:
|
||||
out.Differs++
|
||||
default:
|
||||
if strings.HasSuffix(row.Action, ActionFill) {
|
||||
out.Filled++
|
||||
} else {
|
||||
out.Created++
|
||||
}
|
||||
out.ByStatus[row.Status]++
|
||||
for _, u := range c.unverified {
|
||||
unverified[u] = append(unverified[u], c.number)
|
||||
}
|
||||
}
|
||||
out.Rows = append(out.Rows, row)
|
||||
}
|
||||
if len(unverified) > 0 {
|
||||
out.UnverifiedOwners = unverified
|
||||
}
|
||||
|
||||
// An issue the tracker holds without a status, whose folder hq does not hold at this commit (opened with
|
||||
// a folder before slice 3, the folder not merged yet): it is open, so no issue is left without a status.
|
||||
folderOf := map[uint64]bool{}
|
||||
for _, c := range cands {
|
||||
folderOf[c.number] = true
|
||||
}
|
||||
keys, err := t.Store.Keys(IssuesState)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the issues kept cannot be listed (%v); what was imported before this is kept", err)
|
||||
}
|
||||
for _, k := range keys {
|
||||
n, ok := numberOfKey(k)
|
||||
if !ok || folderOf[n] {
|
||||
continue
|
||||
}
|
||||
is, _, err := read(t.Store, n)
|
||||
if err != nil || is.Status != "" {
|
||||
continue
|
||||
}
|
||||
row := ImportRow{Number: n, Action: ActionWould + ActionFill, Status: StatusOpen, Title: is.Title, Folder: is.Record,
|
||||
Why: "the tracker holds it without a status and hq holds no folder for it at this commit: open"}
|
||||
if !a.DryRun {
|
||||
row.Action = ActionFill
|
||||
if err := t.fillOpen(n, commit, at, by); err != nil {
|
||||
row.Action, row.Why = ActionRefused, err.Error()
|
||||
out.Refused++
|
||||
out.Rows = append(out.Rows, row)
|
||||
continue
|
||||
}
|
||||
}
|
||||
out.Filled++
|
||||
out.ByStatus[StatusOpen]++
|
||||
out.Rows = append(out.Rows, row)
|
||||
}
|
||||
verb := "created"
|
||||
if a.DryRun {
|
||||
verb = "would create"
|
||||
}
|
||||
out.Said = fmt.Sprintf("Read %d issue folders of hq at commit %s: %s %d, filled %d, the same %d, differing %d, refused %d.",
|
||||
out.Folders, short(commit), verb, out.Created, out.Filled, out.Same, out.Differs, out.Refused)
|
||||
if a.DryRun {
|
||||
out.Said += " Nothing was written: a dry run."
|
||||
}
|
||||
if out.Folders > 0 && out.Created == 0 && out.Filled == 0 && !a.DryRun {
|
||||
if out.Refused == 0 {
|
||||
out.Said += " Nothing changed: every folder is already in the tracker."
|
||||
} else {
|
||||
out.Said += " Nothing changed: every folder not refused is already in the tracker."
|
||||
}
|
||||
}
|
||||
if !a.DryRun && out.Announced < out.Created+out.Filled {
|
||||
out.Warning = fmt.Sprintf("%d of %d changes are kept, and their %s event could not be said on the bus: what watches "+
|
||||
"the events did not hear of them", out.Created+out.Filled-out.Announced, out.Created+out.Filled, EventImported)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// issueOf is the issue a report becomes: the fields as the folder has them, checked for size and shape as
|
||||
// `open` and `move` check them — but located-in is kept as written even when it names no registered
|
||||
// owner, marked unverified, and no cycle rule is applied: the status is the folder's, as it stood.
|
||||
func (t *Tracker) issueOf(r *Report, owners Owners, commit string, at time.Time, by string, exists map[uint64]bool) (*Issue, []string, error) {
|
||||
title, err := singleLine("title", r.Title, MostTitle)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
body := r.Body
|
||||
if strings.TrimSpace(body) == "" {
|
||||
body = "(the report says nothing beyond its title)"
|
||||
}
|
||||
report, err := multiLine("report", body, MostReport)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var unverified []string
|
||||
// An entry hq wrote is prose at times, longer than one typed at a verb: it is taken whole, within a reason's bound.
|
||||
located, err := entries("located-in", r.LocatedIn, func(e string) (string, error) {
|
||||
v, err := singleLine("an entry of located-in", e, MostReason)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if owners.owns(v) == "" {
|
||||
unverified = append(unverified, v)
|
||||
}
|
||||
return v, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
fixed, err := entries("fixed-by", r.FixedBy, func(e string) (string, error) { return singleLine("an entry of fixed-by", e, MostReason) })
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
designs, err := entries("amended-design", r.AmendedDesign, func(e string) (string, error) {
|
||||
v, err := singleLine("an entry of amended-design", e, MostReason)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !designPath.MatchString(v) {
|
||||
r.Odd = append(r.Odd, fmt.Sprintf("amended-design %q is not a design document's path; kept as written", v))
|
||||
}
|
||||
return v, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var replay, replayNone string
|
||||
if r.Replay != "" {
|
||||
if replay, err = singleLine("replay", r.Replay, MostLine); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !replayID.MatchString(replay) {
|
||||
r.Odd = append(r.Odd, fmt.Sprintf("replay %q names no replay id (R and a number); kept as written", replay))
|
||||
}
|
||||
}
|
||||
if r.ReplayNone != "" {
|
||||
if replayNone, err = multiLine("replay-none", r.ReplayNone, MostReason); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
is := &Issue{Number: r.Number, Title: title, Status: r.Status, Report: report, Opened: at, OpenedBy: ImportedBy,
|
||||
LocatedIn: located, LocatedInUnverified: unverified, FixedBy: fixed, AmendedDesign: designs,
|
||||
Replay: replay, ReplayNone: replayNone, Record: r.Folder, Opening: "import@" + short(commit),
|
||||
Imported: &Import{At: at, By: by, Commit: commit, From: r.Path}}
|
||||
if d, err := time.Parse("2006-01-02", r.Opened); err == nil {
|
||||
is.Opened = d
|
||||
} else {
|
||||
r.Odd = append(r.Odd, "no opened date: the import's time is taken")
|
||||
}
|
||||
if d, err := time.Parse("2006-01-02", r.Closed); err == nil {
|
||||
is.Closed = &d
|
||||
}
|
||||
for _, other := range r.Issues {
|
||||
if !exists[other] {
|
||||
r.Odd = append(r.Odd, fmt.Sprintf("names issue %d, which is neither a folder nor an issue kept; not linked", other))
|
||||
continue
|
||||
}
|
||||
is.Links = append(is.Links, Link{Kind: LinkRelatesTo, Target: keyOf(other), By: ImportedBy, At: at})
|
||||
}
|
||||
for _, d := range r.Decisions {
|
||||
is.Links = append(is.Links, Link{Kind: LinkDecision, Target: d, By: ImportedBy, At: at})
|
||||
}
|
||||
if len(is.Links) > MostLinks {
|
||||
is.Links = is.Links[:MostLinks]
|
||||
r.Odd = append(r.Odd, fmt.Sprintf("names more than %d issues and decisions; the first %d are linked", MostLinks, MostLinks))
|
||||
}
|
||||
raw, err := json.Marshal(is)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(raw) > MostIssueBytes {
|
||||
return nil, nil, fmt.Errorf("the issue would be %d bytes; one issue is kept in at most %d", len(raw), MostIssueBytes)
|
||||
}
|
||||
return is, unverified, nil
|
||||
}
|
||||
|
||||
// importOne does what the tracker's state asks for one folder: create, fill, nothing, or refuse.
|
||||
func (t *Tracker) importOne(c *candidate, first uint64, dry bool, row *ImportRow, out *Imported) error {
|
||||
kept, _, err := read(t.Store, c.number)
|
||||
if err != nil && !errors.Is(err, ErrNoIssue) {
|
||||
return err
|
||||
}
|
||||
prefix := ""
|
||||
if dry {
|
||||
prefix = ActionWould
|
||||
}
|
||||
switch {
|
||||
case kept == nil && c.number >= first:
|
||||
return fmt.Errorf("numbered %d, at or past the tracker's first number %d, and the tracker holds no issue %d: "+
|
||||
"a folder from the tracker's time carries a number the tracker gave, so this one was made without it",
|
||||
c.number, first, c.number)
|
||||
case kept == nil:
|
||||
row.Action = prefix + ActionCreate
|
||||
if dry {
|
||||
return nil
|
||||
}
|
||||
return t.importCreate(c.issue, out)
|
||||
case kept.Status == "" && kept.Record == c.issue.Record:
|
||||
row.Action = prefix + ActionFill
|
||||
if kept.Title != c.issue.Title {
|
||||
out.Odd = append(out.Odd, fmt.Sprintf("%d: the tracker's title %q and the folder's %q differ; the tracker's is kept", c.number, kept.Title, c.issue.Title))
|
||||
}
|
||||
if dry {
|
||||
return nil
|
||||
}
|
||||
return t.importFill(c.issue, out)
|
||||
case kept.Status == "":
|
||||
row.Action = ActionDiffers
|
||||
row.Why = fmt.Sprintf("the tracker holds issue %d without a status under the folder %s, and this folder is %s: "+
|
||||
"nothing was changed. If they are one issue, move it in the tracker; its status is not taken from a folder "+
|
||||
"that is not its own", c.number, kept.Record, c.issue.Record)
|
||||
return nil
|
||||
default:
|
||||
differs := differences(kept, c.issue)
|
||||
if len(differs) == 0 {
|
||||
row.Action = ActionSame
|
||||
return nil
|
||||
}
|
||||
row.Action = ActionDiffers
|
||||
row.Why = "the tracker holds it differently in " + strings.Join(differs, ", ") + "; the tracker is the truth, nothing was changed"
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// differences is the fields in which the tracker's issue and the folder's differ: the fields the import
|
||||
// sets, for an issue the import wrote; only the status and what it needs, for one the tracker opened.
|
||||
func differences(kept, folder *Issue) []string {
|
||||
var d []string
|
||||
same := func(name string, a, b any) {
|
||||
if !reflect.DeepEqual(a, b) {
|
||||
d = append(d, name)
|
||||
}
|
||||
}
|
||||
if kept.OpenedBy == ImportedBy {
|
||||
same("title", kept.Title, folder.Title)
|
||||
same("report", kept.Report, folder.Report)
|
||||
same("record", kept.Record, folder.Record)
|
||||
}
|
||||
same("status", kept.Status, folder.Status)
|
||||
same("located-in", kept.LocatedIn, folder.LocatedIn)
|
||||
same("fixed-by", kept.FixedBy, folder.FixedBy)
|
||||
same("amended-design", kept.AmendedDesign, folder.AmendedDesign)
|
||||
same("replay", kept.Replay, folder.Replay)
|
||||
same("replay-none", kept.ReplayNone, folder.ReplayNone)
|
||||
return d
|
||||
}
|
||||
|
||||
// importCreate writes a new issue under its number, never over one.
|
||||
func (t *Tracker) importCreate(is *Issue, out *Imported) error {
|
||||
_, err := t.Store.Create(IssuesState, keyOf(is.Number), is)
|
||||
if errors.Is(err, stdio.ErrChanged) {
|
||||
return fmt.Errorf("an issue %d appeared while the import ran; nothing was overwritten. Run the import again", is.Number)
|
||||
}
|
||||
if err != nil {
|
||||
// A write that timed out may still have landed: read back before saying it did not.
|
||||
if there, _, rerr := read(t.Store, is.Number); rerr == nil && there.Opening == is.Opening {
|
||||
t.announceImport(is, out)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("issue %d could not be written: %v", is.Number, err)
|
||||
}
|
||||
t.announceImport(is, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
// importFill takes the status and the fields of an issue the tracker holds without a status: opened with a
|
||||
// folder before slice 3, whose status lived in that folder. Its title, report, opening, notes and links
|
||||
// stay; links the report names are added.
|
||||
func (t *Tracker) importFill(from *Issue, out *Imported) error {
|
||||
is, err := t.change(from.Number, func(is *Issue) error {
|
||||
if is.Status != "" {
|
||||
return fmt.Errorf("issue %d took a status while the import ran; nothing was changed", is.Number)
|
||||
}
|
||||
is.Status = from.Status
|
||||
is.Closed = from.Closed
|
||||
is.LocatedIn, is.LocatedInUnverified = from.LocatedIn, from.LocatedInUnverified
|
||||
is.FixedBy, is.AmendedDesign = from.FixedBy, from.AmendedDesign
|
||||
is.Replay, is.ReplayNone = from.Replay, from.ReplayNone
|
||||
is.Imported = from.Imported
|
||||
for _, l := range from.Links {
|
||||
has := false
|
||||
for _, k := range is.Links {
|
||||
if k.Kind == l.Kind && k.Target == l.Target {
|
||||
has = true
|
||||
}
|
||||
}
|
||||
if !has && len(is.Links) < MostLinks {
|
||||
is.Links = append(is.Links, l)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.announceImport(is, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *Tracker) announceImport(is *Issue, out *Imported) {
|
||||
ok, _ := t.emit(EventImported, map[string]any{"number": is.Number, "title": is.Title, "status": is.Status,
|
||||
"record": is.Record, "commit": is.Imported.Commit, "by": is.Imported.By, "at": is.Imported.At})
|
||||
if ok {
|
||||
out.Announced++
|
||||
}
|
||||
}
|
||||
|
||||
// fillOpen gives an issue the tracker holds without a status — opened with a folder before slice 3, whose
|
||||
// folder hq does not hold at the import's commit — the status open, so no issue is left without one.
|
||||
func (t *Tracker) fillOpen(n uint64, commit string, at time.Time, by string) error {
|
||||
is, err := t.change(n, func(is *Issue) error {
|
||||
if is.Status != "" {
|
||||
return fmt.Errorf("issue %d took a status while the import ran; nothing was changed", is.Number)
|
||||
}
|
||||
is.Status = StatusOpen
|
||||
is.Imported = &Import{At: at, By: by, Commit: commit}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = t.emit(EventImported, map[string]any{"number": is.Number, "title": is.Title, "status": is.Status,
|
||||
"record": is.Record, "commit": commit, "by": by, "at": at})
|
||||
return nil
|
||||
}
|
||||
|
||||
func short(commit string) string {
|
||||
if len(commit) > 12 && !strings.HasPrefix(commit, "dir:") {
|
||||
return commit[:12]
|
||||
}
|
||||
return commit
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The import of slice 3 (to-be 53 §8) over the real reports in testdata/hq, against the tracker kept in
|
||||
// this process: it creates each issue at its own number, touches no counter, never overwrites, and run
|
||||
// again changes nothing and says so.
|
||||
|
||||
// sampleRecords is the record played from testdata/hq, at a commit the test names.
|
||||
type sampleRecords struct {
|
||||
dirRecords
|
||||
commit string
|
||||
// moveTo, when set, is the commit every read after the first answers: the record moving mid-import.
|
||||
moveTo string
|
||||
mu sync.Mutex
|
||||
reads int
|
||||
}
|
||||
|
||||
func newSample(t *testing.T) *sampleRecords {
|
||||
t.Helper()
|
||||
return &sampleRecords{dirRecords: dirRecords{dir: filepath.Join("testdata", "hq")}, commit: "1ea678c6bd48f62a45db3b5e4b21b9c46867d31a"}
|
||||
}
|
||||
|
||||
func (s *sampleRecords) Standing() (string, error) { return s.commit, nil }
|
||||
func (s *sampleRecords) Read(path string) (string, string, error) {
|
||||
content, _, err := s.dirRecords.Read(path)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.reads++
|
||||
if s.moveTo != "" && s.reads > 1 {
|
||||
return content, s.moveTo, nil
|
||||
}
|
||||
return content, s.commit, nil
|
||||
}
|
||||
|
||||
var sampleNumbers = []uint64{7, 43, 80, 150, 236, 345, 362}
|
||||
|
||||
func TestTheImportCreatesEveryFolderAtItsOwnNumberAndLeavesTheCounterAlone(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
rec := newSample(t)
|
||||
out, err := h.Import(ImportAsk{Commit: "1ea678c"}, rec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Folders != 7 || out.Created != 7 || out.Filled+out.Same+out.Differs+out.Refused != 0 || out.DryRun {
|
||||
t.Fatalf("%+v", out)
|
||||
}
|
||||
if !reflect.DeepEqual(out.ByStatus, map[string]int{StatusResolved: 5, StatusDiagnosing: 1, StatusWontfix: 1}) {
|
||||
t.Fatalf("by status %v", out.ByStatus)
|
||||
}
|
||||
if out.Announced != 7 || len(h.said()) != 7 || h.said()[0].Event != EventImported {
|
||||
t.Fatalf("announced %d, said %v", out.Announced, h.said())
|
||||
}
|
||||
for i, n := range sampleNumbers {
|
||||
if out.Rows[i].Number != n || out.Rows[i].Action != ActionCreate {
|
||||
t.Fatalf("row %d: %+v", i, out.Rows[i])
|
||||
}
|
||||
}
|
||||
if h.mem.counter(t) != 0 {
|
||||
t.Fatalf("the import moved the counter to %d", h.mem.counter(t))
|
||||
}
|
||||
// A report names only issues that exist: 362's report names issues the sample does not hold, which are
|
||||
// said and not linked; its links to decisions stay.
|
||||
if is, _ := h.Get(362); len(is.Links) == 0 {
|
||||
t.Fatalf("362 has no links: %+v", is.Links)
|
||||
}
|
||||
for _, l := range (func() *Issue { is, _ := h.Get(362); return is })().Links {
|
||||
if l.Kind == LinkRelatesTo {
|
||||
n, _ := numberOfKey(l.Target)
|
||||
if _, err := h.Get(n); err != nil {
|
||||
t.Fatalf("362 links issue %d, which does not exist", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
// The acceptance: get shows what the folder said, and who/when is the import, with the commit.
|
||||
is, err := h.Get(362)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if is.Status != StatusResolved || is.Record != "04-ISSUES/362-two-merges-seconds-apart-left-their-delivery-waiting-for-a-person" ||
|
||||
len(is.LocatedIn) != 2 || is.OpenedBy != ImportedBy || is.Opened.Format("2006-01-02") != "2026-10-10" ||
|
||||
is.Imported == nil || is.Imported.Commit != rec.commit || is.Imported.From != is.Record+"/00-report.md" ||
|
||||
is.Imported.By != Unproven || is.Imported.At != h.now() || !strings.HasPrefix(is.Report, "## Symptom") {
|
||||
t.Fatalf("362 kept as %+v", is)
|
||||
}
|
||||
if is, _ := h.Get(362); is.Replay != "R362" || !reflect.DeepEqual(is.FixedBy, []string{"mesh-catalog#171, mesh-controller#197"}) {
|
||||
t.Fatalf("362 kept as %+v", is)
|
||||
}
|
||||
if is, _ := h.Get(236); is.Replay != "R236" || is.Closed != nil {
|
||||
t.Fatalf("236 kept as %+v", is)
|
||||
}
|
||||
if is, _ := h.Get(43); is.Closed == nil || is.Closed.Format("2006-01-02") != "2026-09-12" {
|
||||
t.Fatalf("43 kept as %+v", is)
|
||||
}
|
||||
// The owners hq named that the registered list does not know are kept, and marked.
|
||||
if is, _ := h.Get(7); !reflect.DeepEqual(is.LocatedInUnverified, []string{"hal (the instance)"}) || len(is.LocatedIn) != 3 {
|
||||
t.Fatalf("7 kept as %+v", is)
|
||||
}
|
||||
if !reflect.DeepEqual(out.UnverifiedOwners["hal (the instance)"], []uint64{7}) {
|
||||
t.Fatalf("unverified %v", out.UnverifiedOwners)
|
||||
}
|
||||
// The next open takes the number after the setting, as if nothing had been imported below it.
|
||||
if got := h.open(t, "after the import"); got != 365 {
|
||||
t.Fatalf("opened %d after the import, want 365", got)
|
||||
}
|
||||
l, _ := h.List(ListAsk{Status: StatusResolved})
|
||||
if l.Matched != 5 || l.Rows[0].Number != 362 {
|
||||
t.Fatalf("listed %+v", l)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASecondImportChangesNothingAndSaysSo(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
if _, err := h.Import(ImportAsk{}, newSample(t)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := h.mem.snapshot(IssuesState)
|
||||
said := len(h.said())
|
||||
out, err := h.Import(ImportAsk{}, newSample(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Created != 0 || out.Filled != 0 || out.Same != 7 || out.Differs != 0 || out.Refused != 0 || !strings.Contains(out.Said, "Nothing changed") {
|
||||
t.Fatalf("%+v", out)
|
||||
}
|
||||
if !reflect.DeepEqual(h.mem.snapshot(IssuesState), before) || len(h.said()) != said {
|
||||
t.Fatal("a second import changed an issue or said an event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnIssueTheTrackerHoldsDifferentlyIsNeverOverwritten(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
if _, err := h.Import(ImportAsk{}, newSample(t)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The tracker is the truth once imported: 345 moves on, 80 takes a note.
|
||||
if _, err := h.Move(MoveAsk{Number: 345, Status: StatusLocated, LocatedIn: []string{"mesh-catalog modules/mesh-issues"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := h.Note(80, "a note"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := h.mem.snapshot(IssuesState)
|
||||
out, err := h.Import(ImportAsk{}, newSample(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Differs != 1 || out.Same != 6 || out.Created != 0 {
|
||||
t.Fatalf("%+v", out)
|
||||
}
|
||||
var row ImportRow
|
||||
for _, r := range out.Rows {
|
||||
if r.Number == 345 {
|
||||
row = r
|
||||
}
|
||||
}
|
||||
if row.Action != ActionDiffers || !strings.Contains(row.Why, "status, located-in") || !strings.Contains(row.Why, "the tracker is the truth") {
|
||||
t.Fatalf("row %+v", row)
|
||||
}
|
||||
if !reflect.DeepEqual(h.mem.snapshot(IssuesState), before) {
|
||||
t.Fatal("the import changed an issue the tracker holds")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFolderBackedIssueTheTrackerOpenedTakesItsStatusFromItsFolderOnce(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
// Before slice 3, an issue opened with a folder had no status in the tracker; its folder kept it.
|
||||
h.mem.put(IssuesState, "362", Issue{Number: 362, Title: "opened through the tracker", Report: "r", OpenedBy: Unproven,
|
||||
Record: "04-ISSUES/362-two-merges-seconds-apart-left-their-delivery-waiting-for-a-person", Opening: "x",
|
||||
Notes: []Note{{By: Unproven, Text: "kept"}}})
|
||||
h.settings.FirstNumber = 300 // so 362 is a number the tracker gave
|
||||
out, err := h.Import(ImportAsk{}, newSample(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 345 is past the first number too, with no issue in the tracker: refused, as a folder made without it.
|
||||
if out.Filled != 1 || out.Created != 5 || out.Refused != 1 {
|
||||
t.Fatalf("%+v", out)
|
||||
}
|
||||
is, _ := h.Get(362)
|
||||
if is.Status != StatusResolved || is.Title != "opened through the tracker" || is.Report != "r" || len(is.Notes) != 1 ||
|
||||
is.OpenedBy != Unproven || is.Imported == nil || len(is.LocatedIn) != 2 || len(is.Links) == 0 {
|
||||
t.Fatalf("362 filled as %+v", is)
|
||||
}
|
||||
if !strings.Contains(strings.Join(out.Odd, "\n"), "362: the tracker's title") {
|
||||
t.Fatalf("odd %v", out.Odd)
|
||||
}
|
||||
out, _ = h.Import(ImportAsk{}, newSample(t))
|
||||
if out.Filled != 0 || out.Same != 6 || out.Refused != 1 {
|
||||
t.Fatalf("second: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATrackerIssueWithoutAStatusAndWithoutAFolderBecomesOpen(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.mem.put(IssuesState, "375", Issue{Number: 375, Title: "t", Report: "r", OpenedBy: Unproven, Record: "04-ISSUES/375-not-merged", Opening: "x"})
|
||||
out, err := h.Import(ImportAsk{DryRun: true}, newSample(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Filled != 1 || out.Rows[len(out.Rows)-1].Action != ActionWould+ActionFill || out.Rows[len(out.Rows)-1].Number != 375 {
|
||||
t.Fatalf("dry: %+v", out)
|
||||
}
|
||||
if is, _ := h.Get(375); is.Status != "" {
|
||||
t.Fatal("a dry run wrote a status")
|
||||
}
|
||||
if _, err := h.Import(ImportAsk{}, newSample(t)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if is, _ := h.Get(375); is.Status != StatusOpen || is.Imported == nil || is.Imported.From != "" {
|
||||
t.Fatalf("375 as %+v", is)
|
||||
}
|
||||
}
|
||||
|
||||
func TestADryRunReadsEverythingAndWritesNothing(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
out, err := h.Import(ImportAsk{DryRun: true}, newSample(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !out.DryRun || out.Created != 7 || out.Rows[0].Action != ActionWould+ActionCreate || !strings.Contains(out.Said, "would create 7") ||
|
||||
!strings.Contains(out.Said, "Nothing was written") {
|
||||
t.Fatalf("%+v", out)
|
||||
}
|
||||
if keys, _ := h.mem.Keys(IssuesState); len(keys) != 0 || len(h.said()) != 0 || h.mem.counter(t) != 0 {
|
||||
t.Fatal("a dry run wrote")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheImportRefusesWhatItMustNotWrite(t *testing.T) {
|
||||
// A folder numbered past the counter: the counter's number, not a folder's to take. Nothing is imported.
|
||||
h := newHarness(t)
|
||||
h.mem.put(CountersState, IssueCounter, 100)
|
||||
if _, err := h.Import(ImportAsk{}, newSample(t)); err == nil || !strings.Contains(err.Error(), "past the counter") {
|
||||
t.Fatalf("past the counter: %v", err)
|
||||
}
|
||||
if keys, _ := h.mem.Keys(IssuesState); len(keys) != 0 {
|
||||
t.Fatal("imported past the counter")
|
||||
}
|
||||
|
||||
// A folder at or past the first number the tracker holds no issue for: made without the tracker.
|
||||
h = newHarness(t)
|
||||
h.settings.FirstNumber = 300
|
||||
out, err := h.Import(ImportAsk{}, newSample(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Refused != 2 || out.Created != 5 {
|
||||
t.Fatalf("%+v", out)
|
||||
}
|
||||
for _, r := range out.Rows {
|
||||
if (r.Number == 345 || r.Number == 362) && (r.Action != ActionRefused || !strings.Contains(r.Why, "made without it")) {
|
||||
t.Fatalf("row %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// The record standing elsewhere than asked.
|
||||
h = newHarness(t)
|
||||
if _, err := h.Import(ImportAsk{Commit: "abcdef0"}, newSample(t)); err == nil || !strings.Contains(err.Error(), "stands at hq commit 1ea678c6bd48") {
|
||||
t.Fatalf("another commit: %v", err)
|
||||
}
|
||||
if _, err := h.Import(ImportAsk{Commit: "main"}, newSample(t)); err == nil || !strings.Contains(err.Error(), "hex digits") {
|
||||
t.Fatalf("a commit that is no commit: %v", err)
|
||||
}
|
||||
|
||||
// A status-less issue the tracker holds under another folder's name is left alone and said.
|
||||
h = newHarness(t)
|
||||
h.settings.FirstNumber = 300
|
||||
h.mem.put(IssuesState, "345", Issue{Number: 345, Title: "t", Report: "r", OpenedBy: Unproven, Record: "04-ISSUES/345-another-name", Opening: "x"})
|
||||
out, err = h.Import(ImportAsk{}, newSample(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range out.Rows {
|
||||
if r.Number == 345 && (r.Action != ActionDiffers || !strings.Contains(r.Why, "not its own")) {
|
||||
t.Fatalf("row %+v", r)
|
||||
}
|
||||
}
|
||||
if is, _ := h.Get(345); is.Status != "" {
|
||||
t.Fatal("a status was taken from a folder that is not the issue's")
|
||||
}
|
||||
|
||||
// The record moving while it is read.
|
||||
h = newHarness(t)
|
||||
rec := newSample(t)
|
||||
rec.moveTo = "ffffffffffff"
|
||||
if _, err := h.Import(ImportAsk{}, rec); err == nil || !strings.Contains(err.Error(), "moved while it was read") {
|
||||
t.Fatalf("a moving record: %v", err)
|
||||
}
|
||||
if keys, _ := h.mem.Keys(IssuesState); len(keys) != 0 {
|
||||
t.Fatal("imported from a moving record")
|
||||
}
|
||||
|
||||
// An issue that appears under the number while the import runs is never overwritten.
|
||||
h = newHarness(t)
|
||||
h.mem.fail[IssuesState] = errors.New("the bus is away")
|
||||
out, err = h.Import(ImportAsk{}, newSample(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Refused != 1 || out.Created != 6 || !strings.Contains(out.Rows[0].Why, "could not be written") {
|
||||
t.Fatalf("%+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAReportThatCannotBeReadIsRefusedAloneAndSaidWhy(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write := func(folder, text string) {
|
||||
if err := os.MkdirAll(filepath.Join(dir, IssuesFolder, folder), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, IssuesFolder, folder, "00-report.md"), []byte(text), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("001-good", "---\nstatus: open\nopened: 2026-10-01\nlocated-in: [mesh-controller]\n---\n# 1. Good\n\nbody\n")
|
||||
write("002-sixth-status", "---\nstatus: fixed\n---\n# 2. t\n\nbody\n")
|
||||
write("003-no-title", "---\nstatus: open\n---\nbody\n")
|
||||
write("004-a-secret", "---\nstatus: open\n---\n# 4. t\n\nthe key is sk-ant-api03-abcdefghijklmnopqrstuvwxyz\n")
|
||||
write("005-twice", "---\nstatus: open\n---\n# 5. t\n\nbody\n")
|
||||
write("005-again", "---\nstatus: open\n---\n# 5. t\n\nbody\n")
|
||||
write("006-no-report", "")
|
||||
if err := os.Remove(filepath.Join(dir, IssuesFolder, "006-no-report", "00-report.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(dir, IssuesFolder, "README"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := newHarness(t)
|
||||
out, err := h.Import(ImportAsk{}, dirRecords{dir: dir})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Folders != 7 || out.Created != 1 || out.Refused != 6 {
|
||||
t.Fatalf("%+v", out)
|
||||
}
|
||||
why := map[uint64]string{}
|
||||
for _, r := range out.Rows {
|
||||
why[r.Number] = r.Why
|
||||
}
|
||||
for n, want := range map[uint64]string{2: "not one of", 3: "no title", 4: "an Anthropic key", 5: "two folders are numbered 5", 6: "could not be read"} {
|
||||
if !strings.Contains(why[n], want) {
|
||||
t.Errorf("%d: %q lacks %q", n, why[n], want)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(strings.Join(out.Odd, "\n"), "README is not an issue folder") {
|
||||
t.Fatalf("odd %v", out.Odd)
|
||||
}
|
||||
if is, err := h.Get(1); err != nil || is.Status != StatusOpen || is.Title != "Good" {
|
||||
t.Fatalf("1: %+v %v", is, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheImportVerbReadsItsArgumentsStrictly(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.Records = newSample(t)
|
||||
if _, err := h.call(t, "import", map[string]any{"path": "/tmp"}); err == nil || !strings.Contains(err.Error(), "does not take path") {
|
||||
t.Fatalf("an unknown argument: %v", err)
|
||||
}
|
||||
if _, err := h.call(t, "import", map[string]any{"dry-run": "yes"}); err == nil {
|
||||
t.Fatal("dry-run as text")
|
||||
}
|
||||
out, err := h.call(t, "import", map[string]any{"dry-run": true, "commit": "1ea678c"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := out.(*Imported); !got.DryRun || got.Created != 7 {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
@@ -42,18 +42,22 @@ const Unproven = "unproven"
|
||||
type Issue struct {
|
||||
Number uint64 `json:"number"`
|
||||
Title string `json:"title"`
|
||||
// Status is one of the five; empty for an issue whose folder in hq keeps its status (to-be 53 §3).
|
||||
// Status is one of the five. It was empty for an issue opened with a folder in hq before the import of
|
||||
// slice 3, whose folder kept its status; the import fills it (to-be 53 §8).
|
||||
Status string `json:"status,omitempty"`
|
||||
Report string `json:"report"`
|
||||
Opened time.Time `json:"opened"`
|
||||
OpenedBy string `json:"opened-by"`
|
||||
Closed *time.Time `json:"closed,omitempty"`
|
||||
// LocatedIn names registered owners, each optionally followed by where in it.
|
||||
LocatedIn []string `json:"located-in,omitempty"`
|
||||
FixedBy []string `json:"fixed-by,omitempty"`
|
||||
AmendedDesign []string `json:"amended-design,omitempty"`
|
||||
Replay string `json:"replay,omitempty"`
|
||||
ReplayNone string `json:"replay-none,omitempty"`
|
||||
LocatedIn []string `json:"located-in,omitempty"`
|
||||
// LocatedInUnverified is the entries of LocatedIn that named no registered owner when the import took
|
||||
// them from hq as written; a move that gives located-in anew clears it, as a move checks every entry.
|
||||
LocatedInUnverified []string `json:"located-in-unverified,omitempty"`
|
||||
FixedBy []string `json:"fixed-by,omitempty"`
|
||||
AmendedDesign []string `json:"amended-design,omitempty"`
|
||||
Replay string `json:"replay,omitempty"`
|
||||
ReplayNone string `json:"replay-none,omitempty"`
|
||||
// Reason is why it will not be fixed: what a wontfix needs.
|
||||
Reason string `json:"reason,omitempty"`
|
||||
// Record is the issue's folder in hq, when it has one.
|
||||
@@ -62,6 +66,23 @@ type Issue struct {
|
||||
Links []Link `json:"links,omitempty"`
|
||||
// Opening is the id of the call that opened it, so a write whose answer was lost is known as its own.
|
||||
Opening string `json:"opening"`
|
||||
// Imported says that the issue, or its status, was taken from its folder in hq by the import of
|
||||
// slice 3: when, by whom, at which commit of hq, from which document. An issue the import wrote has
|
||||
// OpenedBy ImportedBy, and its Opened is the date its report gave.
|
||||
Imported *Import `json:"imported,omitempty"`
|
||||
}
|
||||
|
||||
// ImportedBy is who opened an issue the import wrote: not a caller of `open`, and not a name anybody typed.
|
||||
const ImportedBy = "import"
|
||||
|
||||
// Import is how an issue, or its status, came from hq.
|
||||
type Import struct {
|
||||
At time.Time `json:"at"`
|
||||
// By is who ran the import, as the runtime proves it (Unproven until it does).
|
||||
By string `json:"by"`
|
||||
Commit string `json:"commit"`
|
||||
// From is the document read, empty for a status filled without one.
|
||||
From string `json:"from,omitempty"`
|
||||
}
|
||||
|
||||
// Note is one note on an issue: appended, never edited.
|
||||
|
||||
@@ -22,14 +22,15 @@ import (
|
||||
const Seat = "issue-tracker"
|
||||
|
||||
// Verbs are the seat's verbs, as the manifest declares and claims them.
|
||||
var Verbs = []string{"open", "get", "list", "move", "note", "link"}
|
||||
var Verbs = []string{"open", "get", "list", "move", "note", "link", "import"}
|
||||
|
||||
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
|
||||
|
||||
// Settings are the operator's values for this module, merged by the mesh into one JSON file.
|
||||
type Settings struct {
|
||||
// FirstNumber is the number the first issue gets: read once, when the counter is started, and never
|
||||
// again. The counter never starts below an issue already kept.
|
||||
// FirstNumber is the number the first issue gets: read once for the counter, when it is started, and
|
||||
// never again for it. The counter never starts below an issue already kept. The import reads it as the
|
||||
// boundary between the folders hq numbered before the tracker and the issues the tracker numbered.
|
||||
FirstNumber uint64 `json:"first-number"`
|
||||
// Owners is the registered list `located-in` may name (to-be 53 §3).
|
||||
Owners []string `json:"owners"`
|
||||
@@ -67,6 +68,9 @@ func main() {
|
||||
Logf: logf,
|
||||
Sleep: time.Sleep,
|
||||
}
|
||||
if len(os.Args) > 1 && os.Args[1] == "dry-run" {
|
||||
os.Exit(dryRunAtTheTerminal(t, os.Args[2:]))
|
||||
}
|
||||
if _, err := t.Settings(); err != nil {
|
||||
logf("[mesh-issues] the settings cannot be read (%v): every open and move will say so", err)
|
||||
}
|
||||
@@ -76,6 +80,43 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// dryRunAtTheTerminal is `mesh-issues dry-run <hq checkout> <settings.json>`: the import's dry run over a
|
||||
// checkout on disk, against no tracker, before the module is rolled out. It reads, writes nothing, and
|
||||
// prints what the import would create from that checkout, as the verb's dry run would answer against an
|
||||
// empty tracker. Not a way to import: only the verb writes, and it reads the record over the bus.
|
||||
func dryRunAtTheTerminal(t *Tracker, args []string) int {
|
||||
if len(args) != 2 {
|
||||
logf("usage: mesh-issues dry-run <hq checkout> <settings.json>")
|
||||
return 2
|
||||
}
|
||||
t.Store = emptyStore{}
|
||||
t.Settings = func() (Settings, error) { return readSettings(args[1]) }
|
||||
out, err := t.Import(ImportAsk{DryRun: true}, dirRecords{dir: args[0]})
|
||||
if err != nil {
|
||||
logf("%v", err)
|
||||
return 1
|
||||
}
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(out)
|
||||
return 0
|
||||
}
|
||||
|
||||
// emptyStore is a Store that holds nothing and takes nothing: the terminal's dry run.
|
||||
type emptyStore struct{}
|
||||
|
||||
func (emptyStore) Get(string, string) (*Entry, error) { return nil, nil }
|
||||
func (emptyStore) Create(string, string, any) (uint64, error) {
|
||||
return 0, errors.New("a dry run at the terminal writes nothing")
|
||||
}
|
||||
func (emptyStore) Update(string, string, any, uint64) (uint64, error) {
|
||||
return 0, errors.New("a dry run at the terminal writes nothing")
|
||||
}
|
||||
func (emptyStore) Keys(string) ([]string, error) { return nil, nil }
|
||||
func (emptyStore) Increment(string, string, int) (uint64, error) {
|
||||
return 0, errors.New("a dry run at the terminal writes nothing")
|
||||
}
|
||||
|
||||
// args reads one call's arguments strictly: an argument the verb does not take is refused, never ignored,
|
||||
// so nothing a caller sends is silently dropped — and no argument can say who the caller is.
|
||||
type args struct {
|
||||
@@ -285,8 +326,7 @@ func Tools(t *Tracker) []stdio.Tool {
|
||||
"development cycle's rules: located needs located-in, owners on the registered list; resolved needs " +
|
||||
"located-in and fixed-by (the pull requests or commits), and an issue in the mesh's core also replay " +
|
||||
"(the replay's id, such as R361) or replay-none (why no replay is possible); wontfix needs a reason. " +
|
||||
"A refused move changes nothing and says which rule refused it. An issue with a folder in hq keeps its " +
|
||||
"status there and is refused here.",
|
||||
"A refused move changes nothing and says which rule refused it.",
|
||||
Input: schema([]string{"number", "status"}, map[string]any{
|
||||
"number": number("the issue's number"),
|
||||
"status": map[string]any{"type": "string", "enum": Statuses, "description": "the status it moves to"},
|
||||
@@ -379,5 +419,35 @@ func Tools(t *Tracker) []stdio.Tool {
|
||||
}
|
||||
return t.Link(ask)
|
||||
}},
|
||||
{Name: Seat + ".import",
|
||||
Description: "Import hq's issue folders (04-ISSUES/NNN-slug/00-report.md), read through the records module at " +
|
||||
"the commit its checkout stands at, each as an issue under its own number with the status, owners, fix " +
|
||||
"and replay its report gives. It only ever creates an issue that is not there, or fills the status of one " +
|
||||
"the tracker holds without a status; it never overwrites an issue, never advances the counter, refuses a " +
|
||||
"folder numbered past the counter or past the tracker's first number without an issue, and run again " +
|
||||
"changes nothing and says so. With dry-run it writes nothing and says what it would do; with commit it " +
|
||||
"refuses a checkout standing elsewhere. The answer is a row per folder and the counts.",
|
||||
Input: schema(nil, map[string]any{
|
||||
"dry-run": map[string]any{"type": "boolean", "description": "read everything, write nothing, say what would be done"},
|
||||
"commit": text("the commit of hq the record must stand at (a prefix is enough); refused when it stands elsewhere"),
|
||||
}),
|
||||
Run: func(m map[string]any) (any, error) {
|
||||
a, err := readArgs("import", m, "dry-run", "commit")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ask ImportAsk
|
||||
if ask.DryRun, err = a.flag("dry-run"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ask.Commit, err = a.str("commit"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records := t.Records
|
||||
if records == nil {
|
||||
records = busRecords{}
|
||||
}
|
||||
return t.Import(ask, records)
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ func TestItDeclaresAndHoldsTheIssueTrackerSeatOnceForTheMesh(t *testing.T) {
|
||||
if m.Module != "mesh-issues" || !reflect.DeepEqual(m.Seats, want) || !reflect.DeepEqual(m.Claims, want) {
|
||||
t.Fatalf("module %q, seats %+v, claims %+v", m.Module, m.Seats, m.Claims)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Emits, []string{EventOpened, EventMoved, EventNoted, EventLinked}) {
|
||||
if !reflect.DeepEqual(m.Emits, []string{EventOpened, EventMoved, EventNoted, EventLinked, EventImported}) {
|
||||
t.Fatalf("emits %v", m.Emits)
|
||||
}
|
||||
// Two states, neither expiring: issues keep a short history per key, the counter one value.
|
||||
|
||||
@@ -217,3 +217,14 @@ func (h *harness) call(t *testing.T, verb string, args map[string]any) (any, err
|
||||
t.Fatalf("no verb %s", verb)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// snapshot is every value of a state, by key, as kept now.
|
||||
func (m *memory) snapshot(state string) map[string]string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := map[string]string{}
|
||||
for k, e := range m.data[state] {
|
||||
out[k] = string(e.Value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// An issue report in hq, `04-ISSUES/NNN-slug/00-report.md`, as the import of slice 3 reads it (novox/hq
|
||||
// to-be 53 §8): a YAML frontmatter of a few keys, a heading that is the title, and the report. The
|
||||
// frontmatter is read as hq's own checks read it (00-META/checks/cycle.py), a little more carefully: a
|
||||
// list in brackets may run over lines and hold commas inside parentheses or quotes, a block list's items
|
||||
// may be quoted, and a long value may wrap onto an indented line.
|
||||
|
||||
// Report is one issue report as read from its folder.
|
||||
type Report struct {
|
||||
Number uint64
|
||||
// Folder is the issue's folder, `04-ISSUES/NNN-slug`.
|
||||
Folder string
|
||||
// Path is the report, `<Folder>/00-report.md`.
|
||||
Path string
|
||||
Title string
|
||||
Status string
|
||||
// Opened and Closed are dates as the frontmatter gives them, YYYY-MM-DD, or empty.
|
||||
Opened string
|
||||
Closed string
|
||||
LocatedIn []string
|
||||
FixedBy []string
|
||||
AmendedDesign []string
|
||||
Replay string
|
||||
ReplayNone string
|
||||
// Body is the report without its frontmatter and its title.
|
||||
Body string
|
||||
// Issues and Decisions are the other issues and the decision records the report names.
|
||||
Issues []uint64
|
||||
Decisions []string
|
||||
// Odd is what was odd about the report and did not stop it from being read.
|
||||
Odd []string
|
||||
}
|
||||
|
||||
var (
|
||||
issueFolder = regexp.MustCompile(`^([0-9]{1,4})-([a-z0-9]+(?:-[a-z0-9]+)*)$`)
|
||||
frontKey = regexp.MustCompile(`^([A-Za-z][A-Za-z0-9-]*):(.*)$`)
|
||||
blockItem = regexp.MustCompile(`^\s+-\s*(.*)$`)
|
||||
continued = regexp.MustCompile(`^\s+(\S.*)$`)
|
||||
heading = regexp.MustCompile(`(?m)^#\s+(.+?)\s*$`)
|
||||
numbered = regexp.MustCompile(`^([0-9]{1,4})\s*([.:]|—|–|-)?\s*(.*)$`)
|
||||
dateShape = regexp.MustCompile(`^[0-9]{4}-[0-9]{2}-[0-9]{2}$`)
|
||||
namesIssue = regexp.MustCompile(`(?i)\bissue ([0-9]{1,4})\b|04-ISSUES/([0-9]{1,4})-`)
|
||||
namesRecord = regexp.MustCompile(`\bADR ([0-9]{4})\b|02-DECISIONS/([0-9]{4})-`)
|
||||
)
|
||||
|
||||
// folderNumber is the number an issue folder's name carries, or false for a name that is not one.
|
||||
func folderNumber(name string) (uint64, bool) {
|
||||
m := issueFolder.FindStringSubmatch(name)
|
||||
if m == nil || len(m[2]) > 120 {
|
||||
return 0, false
|
||||
}
|
||||
n, err := strconv.ParseUint(m[1], 10, 64)
|
||||
if err != nil || n == 0 {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
// frontmatter is the YAML block between the first two `---` lines as {key: values}, and the text after
|
||||
// it. A scalar is one value; a list, in brackets or as block items, its items; an empty value no values.
|
||||
// A key given twice keeps the last. False when the text has no frontmatter.
|
||||
func frontmatter(text string) (map[string][]string, string, bool) {
|
||||
text = strings.ReplaceAll(text, "\r\n", "\n")
|
||||
if !strings.HasPrefix(text, "---\n") {
|
||||
return nil, "", false
|
||||
}
|
||||
rest := text[4:]
|
||||
end := -1
|
||||
if strings.HasPrefix(rest, "---\n") || rest == "---" {
|
||||
end = 0
|
||||
} else if i := strings.Index(rest, "\n---\n"); i >= 0 {
|
||||
end = i + 1
|
||||
} else if strings.HasSuffix(rest, "\n---") {
|
||||
end = len(rest) - 3
|
||||
}
|
||||
if end < 0 {
|
||||
return nil, "", false
|
||||
}
|
||||
block := rest[:end]
|
||||
body := strings.TrimPrefix(rest[end:], "---")
|
||||
body = strings.TrimPrefix(body, "\n")
|
||||
|
||||
out := map[string][]string{}
|
||||
key := ""
|
||||
const (
|
||||
none = iota
|
||||
scalar
|
||||
list
|
||||
flow
|
||||
)
|
||||
mode := none
|
||||
flowText := ""
|
||||
closeFlow := func() {
|
||||
out[key] = splitFlow(flowText)
|
||||
mode = list
|
||||
}
|
||||
for _, line := range strings.Split(block, "\n") {
|
||||
if strings.TrimSpace(line) == "" || strings.HasPrefix(strings.TrimSpace(line), "#") {
|
||||
continue
|
||||
}
|
||||
if mode == flow {
|
||||
flowText += " " + strings.TrimSpace(line)
|
||||
if flowClosed(flowText) {
|
||||
closeFlow()
|
||||
}
|
||||
continue
|
||||
}
|
||||
if m := frontKey.FindStringSubmatch(line); m != nil && !strings.HasPrefix(line, " ") {
|
||||
key = m[1]
|
||||
value := strings.TrimSpace(m[2])
|
||||
switch {
|
||||
case value == "":
|
||||
out[key] = nil
|
||||
mode = list
|
||||
case strings.HasPrefix(value, "["):
|
||||
flowText = value
|
||||
mode = flow
|
||||
if flowClosed(flowText) {
|
||||
closeFlow()
|
||||
}
|
||||
default:
|
||||
out[key] = []string{unquote(value)}
|
||||
mode = scalar
|
||||
}
|
||||
continue
|
||||
}
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if m := blockItem.FindStringSubmatch(line); m != nil && mode == list {
|
||||
out[key] = append(out[key], unquote(strings.TrimSpace(m[1])))
|
||||
continue
|
||||
}
|
||||
if m := continued.FindStringSubmatch(line); m != nil && len(out[key]) > 0 {
|
||||
last := len(out[key]) - 1
|
||||
out[key][last] = strings.TrimSpace(out[key][last] + " " + strings.TrimSpace(m[1]))
|
||||
}
|
||||
}
|
||||
if mode == flow {
|
||||
// Never closed: what was read of it, without the bracket.
|
||||
out[key] = splitFlow(flowText + "]")
|
||||
}
|
||||
return out, body, true
|
||||
}
|
||||
|
||||
// flowClosed says whether a bracketed list has its closing bracket, outside quotes. A quote opens an item
|
||||
// only at the item's start: an apostrophe inside prose ("the provisioner's ACL") is no quote.
|
||||
func flowClosed(s string) bool {
|
||||
depth, quote, atStart := 0, rune(0), true
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case quote != 0:
|
||||
if r == quote {
|
||||
quote = 0
|
||||
}
|
||||
continue
|
||||
case (r == '"' || r == '\'') && atStart:
|
||||
quote = r
|
||||
case r == '[':
|
||||
depth++
|
||||
case r == ']':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
atStart = r == '[' || r == ',' || atStart && unicode.IsSpace(r)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// splitFlow is a bracketed list's items: split on the commas outside parentheses, brackets and quotes,
|
||||
// a quote opening an item only at its start.
|
||||
func splitFlow(s string) []string {
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.TrimPrefix(s, "[")
|
||||
s = strings.TrimSuffix(s, "]")
|
||||
var items []string
|
||||
var cur strings.Builder
|
||||
depth, quote, atStart := 0, rune(0), true
|
||||
flush := func() {
|
||||
if v := unquote(strings.TrimSpace(cur.String())); v != "" {
|
||||
items = append(items, v)
|
||||
}
|
||||
cur.Reset()
|
||||
}
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case quote != 0:
|
||||
if r == quote {
|
||||
quote = 0
|
||||
}
|
||||
case (r == '"' || r == '\'') && atStart:
|
||||
quote = r
|
||||
case r == '(' || r == '[':
|
||||
depth++
|
||||
case r == ')' || r == ']':
|
||||
if depth > 0 {
|
||||
depth--
|
||||
}
|
||||
case r == ',' && depth == 0:
|
||||
flush()
|
||||
atStart = true
|
||||
continue
|
||||
}
|
||||
cur.WriteRune(r)
|
||||
atStart = atStart && unicode.IsSpace(r)
|
||||
}
|
||||
flush()
|
||||
return items
|
||||
}
|
||||
|
||||
// unquote strips one pair of matching quotes around a value, and the escapes a double-quoted YAML
|
||||
// scalar uses for its own quote.
|
||||
func unquote(v string) string {
|
||||
if len(v) >= 2 && (v[0] == '"' && v[len(v)-1] == '"' || v[0] == '\'' && v[len(v)-1] == '\'') {
|
||||
inner := v[1 : len(v)-1]
|
||||
if v[0] == '"' {
|
||||
inner = strings.ReplaceAll(inner, `\"`, `"`)
|
||||
} else {
|
||||
inner = strings.ReplaceAll(inner, `''`, `'`)
|
||||
}
|
||||
return strings.TrimSpace(inner)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// ParseReport reads one issue report. The folder is its name, `NNN-slug`; the text is the report whole.
|
||||
// What makes a report unreadable is an error: no frontmatter, no title, no status, or a status that
|
||||
// is not one of the five. What is odd but readable is said in Odd.
|
||||
func ParseReport(folder, text string) (*Report, error) {
|
||||
n, ok := folderNumber(folder)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%q is not an issue folder's name: NNN-slug, lower-case words joined by hyphens", folder)
|
||||
}
|
||||
front, body, ok := frontmatter(text)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s/00-report.md has no frontmatter", folder)
|
||||
}
|
||||
r := &Report{Number: n, Folder: "04-ISSUES/" + folder, Path: "04-ISSUES/" + folder + "/00-report.md"}
|
||||
one := func(key string) string {
|
||||
if v := front[key]; len(v) > 0 {
|
||||
if len(v) > 1 {
|
||||
r.Odd = append(r.Odd, fmt.Sprintf("%s: is a list of %d; one value was expected, and they were joined", key, len(v)))
|
||||
return strings.Join(v, "; ")
|
||||
}
|
||||
return v[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
r.Status = one("status")
|
||||
if r.Status == "" {
|
||||
return nil, fmt.Errorf("%s/00-report.md has no status", folder)
|
||||
}
|
||||
if !known(r.Status) {
|
||||
return nil, fmt.Errorf("%s/00-report.md has the status %q, not one of %s", folder, r.Status, strings.Join(Statuses, ", "))
|
||||
}
|
||||
r.Opened = one("opened")
|
||||
if r.Opened != "" && !dateShape.MatchString(r.Opened) {
|
||||
r.Odd = append(r.Odd, fmt.Sprintf("opened: %q is not a date", r.Opened))
|
||||
r.Opened = ""
|
||||
}
|
||||
for _, key := range []string{"resolved", "closed"} {
|
||||
if v := one(key); v != "" {
|
||||
if dateShape.MatchString(v) {
|
||||
r.Closed = v
|
||||
} else {
|
||||
r.Odd = append(r.Odd, fmt.Sprintf("%s: %q is not a date", key, v))
|
||||
}
|
||||
}
|
||||
}
|
||||
r.LocatedIn = front["located-in"]
|
||||
r.FixedBy = front["fixed-by"]
|
||||
r.AmendedDesign = front["amended-design"]
|
||||
r.Replay = one("replay")
|
||||
r.ReplayNone = one("replay-none")
|
||||
imported := map[string]bool{"status": true, "opened": true, "resolved": true, "closed": true, "located-in": true,
|
||||
"fixed-by": true, "amended-design": true, "replay": true, "replay-none": true}
|
||||
var unknown []string
|
||||
for k := range front {
|
||||
if !imported[k] {
|
||||
unknown = append(unknown, k)
|
||||
}
|
||||
}
|
||||
if len(unknown) > 0 {
|
||||
sort.Strings(unknown)
|
||||
r.Odd = append(r.Odd, "frontmatter keys not imported: "+strings.Join(unknown, ", "))
|
||||
}
|
||||
|
||||
m := heading.FindStringSubmatchIndex(body)
|
||||
if m == nil {
|
||||
return nil, fmt.Errorf("%s/00-report.md has no title: no `# ` heading", folder)
|
||||
}
|
||||
title := body[m[2]:m[3]]
|
||||
// "362. The title", "043 — The title": the number and its separator are the folder's, not the title's. A
|
||||
// title that starts with a bare number ("2 nodes …") keeps it, unless the number is the folder's.
|
||||
if nm := numbered.FindStringSubmatch(title); nm != nil {
|
||||
heads, _ := strconv.ParseUint(nm[1], 10, 64)
|
||||
if nm[2] != "" || heads == n {
|
||||
title = nm[3]
|
||||
if heads != n && title != "" {
|
||||
r.Odd = append(r.Odd, fmt.Sprintf("the title is numbered %s; the folder is %d", nm[1], n))
|
||||
}
|
||||
}
|
||||
}
|
||||
r.Title = strings.TrimSpace(title)
|
||||
if r.Title == "" {
|
||||
return nil, fmt.Errorf("%s/00-report.md has an empty title", folder)
|
||||
}
|
||||
r.Body = strings.TrimSpace(body[:m[0]] + body[m[1]:])
|
||||
|
||||
seen := map[uint64]bool{n: true}
|
||||
for _, mm := range namesIssue.FindAllStringSubmatch(text, -1) {
|
||||
if other, err := strconv.ParseUint(mm[1]+mm[2], 10, 64); err == nil && !seen[other] {
|
||||
seen[other] = true
|
||||
r.Issues = append(r.Issues, other)
|
||||
}
|
||||
}
|
||||
sort.Slice(r.Issues, func(i, j int) bool { return r.Issues[i] < r.Issues[j] })
|
||||
records := map[string]bool{}
|
||||
for _, mm := range namesRecord.FindAllStringSubmatch(text, -1) {
|
||||
if d := mm[1] + mm[2]; !records[d] {
|
||||
records[d] = true
|
||||
r.Decisions = append(r.Decisions, d)
|
||||
}
|
||||
}
|
||||
sort.Strings(r.Decisions)
|
||||
return r, nil
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The reports in testdata/hq are copies of real issue reports of novox/hq, as they stood on 2026-10-10,
|
||||
// chosen for their shapes: a bracketed list with commas inside parentheses, quoted block items, an
|
||||
// apostrophe in prose inside a bracketed list, a `resolved:` date, a replay, an empty list, a wontfix.
|
||||
|
||||
func readSample(t *testing.T, folder string) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("testdata", "hq", IssuesFolder, folder, "00-report.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func TestTheFrontmatterOfRealReportsIsReadAsWritten(t *testing.T) {
|
||||
cases := []struct {
|
||||
folder string
|
||||
want Report
|
||||
}{
|
||||
{"362-two-merges-seconds-apart-left-their-delivery-waiting-for-a-person", Report{
|
||||
Number: 362, Status: StatusResolved, Opened: "2026-10-10",
|
||||
Title: "Two catalogue merges seconds apart left their delivery waiting for a person",
|
||||
// A bracketed list whose entries hold commas inside parentheses: two entries, not four.
|
||||
LocatedIn: []string{"mesh-catalog modules/mesh-delivery (holder.go deliveryWalking)",
|
||||
"mesh-controller cmd/mesh-controller (upgrades.go SourceMoved, release_plan.go laterOnTheBranch)"},
|
||||
// A scalar is one entry, commas and all, as hq's own check reads it.
|
||||
FixedBy: []string{"mesh-catalog#171, mesh-controller#197"},
|
||||
AmendedDesign: []string{"03-DESIGN/01-to-be/47-delivery-from-commit-to-delivered.md"},
|
||||
Replay: "R362",
|
||||
}},
|
||||
{"007-an-installed-package-is-not-a-capability", Report{
|
||||
Number: 7, Status: StatusResolved, Opened: "2026-08-23",
|
||||
Title: "An installed package is not an available capability",
|
||||
LocatedIn: []string{"hal (the instance)", "mesh-host internal/bootstrap (preflight asks the daemon)", "mesh-controller internal/catalogue (capabilities)"},
|
||||
// Quoted block items, each holding colons and commas, taken whole and unquoted.
|
||||
FixedBy: []string{
|
||||
"the instance only: PR #962 — incus hook. Merged, ran on one node in 6s of a 60s budget; pipeline #6832 green in 48s. The class remains open.",
|
||||
"partly, on the mesh: mesh-host 73c010e, 9d8239a — preflight and the profile detectors ask the daemon, not the package (installed-but-broken reports absent)",
|
||||
"the class, on the mesh: a manifest declares capabilities, an action declares verify (ADR 0005), and a module's own assertions are the lab's verdict (design 01). The old arrangement is replaced module by module, so its count is never taken",
|
||||
},
|
||||
AmendedDesign: []string{"03-DESIGN/01-to-be/01-end-to-end-testing.md"},
|
||||
}},
|
||||
{"080-a-cache-grant-lets-the-consumer-flush-the-server", Report{
|
||||
Number: 80, Status: StatusResolved, Opened: "2026-09-22",
|
||||
Title: "A cache grant lets the consumer flush the server",
|
||||
// An apostrophe inside prose in a bracketed list is no quote: the list closes on its own line.
|
||||
LocatedIn: []string{"mesh-catalog modules/redis (the provisioner's ACL)"},
|
||||
FixedBy: []string{"mesh-catalog multiple-fixes (the consumer's ACL user loses the dangerous command category); proven by " +
|
||||
"the grant end-to-end bed, which now asserts a write outside the consumer's keys and FLUSHALL are refused"},
|
||||
}},
|
||||
{"043-a-module-cannot-be-given-the-meshs-own-queues", Report{
|
||||
Number: 43, Status: StatusResolved, Opened: "2026-09-12", Closed: "2026-09-12",
|
||||
Title: "A module cannot be given an account for the mesh's own queues",
|
||||
LocatedIn: []string{"mesh-controller (the verb existed)"},
|
||||
FixedBy: []string{"nothing — the capability already existed and the wrong verb was used"},
|
||||
}},
|
||||
{"236-the-catalogue-check-passes-a-manifest-the-host-refuses", Report{
|
||||
Number: 236, Status: StatusResolved, Opened: "2026-10-04",
|
||||
Title: "The catalogue check passes a manifest the host refuses",
|
||||
LocatedIn: []string{"mesh-controller cmd/mesh-controller (the merge gate)"},
|
||||
FixedBy: []string{"novox/mesh-controller PR #96 (bbd442c, the merge gate, ADR 0237)"},
|
||||
Replay: "R236",
|
||||
}},
|
||||
{"345-a-reminder-window-shows-transparent-and-empty", Report{
|
||||
Number: 345, Status: StatusDiagnosing, Opened: "2026-10-09",
|
||||
Title: "A calendar reminder shows as a transparent, empty window, and the mesh had no tool to look at it",
|
||||
}},
|
||||
{"150-a-route-is-contributed-before-its-module-is-taken", Report{
|
||||
Number: 150, Status: StatusWontfix, Opened: "2026-09-29",
|
||||
Title: "A route is contributed before its module is taken",
|
||||
LocatedIn: []string{"mesh-controller internal/catalogue/declaration.go (contributions are emitted for an assigned module, taken or not)"},
|
||||
}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := ParseReport(c.folder, readSample(t, c.folder))
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", c.folder, err)
|
||||
}
|
||||
if got.Folder != IssuesFolder+"/"+c.folder || got.Path != got.Folder+"/00-report.md" {
|
||||
t.Fatalf("%s: folder %q path %q", c.folder, got.Folder, got.Path)
|
||||
}
|
||||
if got.Body == "" || strings.Contains(got.Body, "\n# ") || strings.HasPrefix(got.Body, "# ") || strings.HasPrefix(got.Body, "---") {
|
||||
t.Fatalf("%s: the body keeps its frontmatter or title: %.80q", c.folder, got.Body)
|
||||
}
|
||||
if len(got.Odd) != 0 {
|
||||
t.Fatalf("%s: odd %v", c.folder, got.Odd)
|
||||
}
|
||||
// The title's number and separator are stripped, whatever the separator: "362. ", "043 — ".
|
||||
got.Folder, got.Path, got.Body, got.Issues, got.Decisions = "", "", "", nil, nil
|
||||
if !reflect.DeepEqual(*got, c.want) {
|
||||
t.Fatalf("%s:\n got %+v\n want %+v", c.folder, *got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAReportNamesTheIssuesAndDecisionsItCites(t *testing.T) {
|
||||
r, err := ParseReport("362-two-merges-seconds-apart-left-their-delivery-waiting-for-a-person",
|
||||
readSample(t, "362-two-merges-seconds-apart-left-their-delivery-waiting-for-a-person"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, n := range r.Issues {
|
||||
if n == 362 {
|
||||
t.Fatal("an issue names itself")
|
||||
}
|
||||
}
|
||||
for _, d := range r.Decisions {
|
||||
if len(d) != 4 {
|
||||
t.Fatalf("decision %q", d)
|
||||
}
|
||||
}
|
||||
text := "---\nstatus: open\nopened: 2026-10-10\n---\n# 9. t\n\nSee issue 155, 04-ISSUES/203-x/00-report.md and ADR 0275 " +
|
||||
"(02-DECISIONS/0019-how.md), and issue 155 again, and issue 9 itself."
|
||||
r, err = ParseReport("009-t", text)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(r.Issues, []uint64{155, 203}) || !reflect.DeepEqual(r.Decisions, []string{"0019", "0275"}) {
|
||||
t.Fatalf("issues %v decisions %v", r.Issues, r.Decisions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhatMakesAReportUnreadableAndWhatIsOnlyOdd(t *testing.T) {
|
||||
refused := map[string]string{
|
||||
"no frontmatter": "# 9. t\n\nbody",
|
||||
"no status": "---\nopened: 2026-10-10\n---\n# 9. t\n",
|
||||
"a sixth status": "---\nstatus: fixed\n---\n# 9. t\n",
|
||||
"no title": "---\nstatus: open\n---\nbody only\n",
|
||||
"an empty title": "---\nstatus: open\n---\n# 9.\n",
|
||||
"no folder shape": "",
|
||||
}
|
||||
for what, text := range refused {
|
||||
folder := "009-t"
|
||||
if what == "no folder shape" {
|
||||
folder, text = "nine-t", "---\nstatus: open\n---\n# t\n"
|
||||
}
|
||||
if _, err := ParseReport(folder, text); err == nil {
|
||||
t.Errorf("%s was read", what)
|
||||
}
|
||||
}
|
||||
r, err := ParseReport("009-t", "---\nstatus: open\nopened: yesterday\nseverity: high\nreplay: R1\nreplay: R2\n---\n# 12 — t\n\nbody")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.Opened != "" || r.Replay != "R2" || r.Title != "t" {
|
||||
t.Fatalf("%+v", r)
|
||||
}
|
||||
if r, _ := ParseReport("009-t", "---\nstatus: open\n---\n# 2 nodes share a name\n"); r.Title != "2 nodes share a name" {
|
||||
t.Fatalf("a title starting with a number: %q", r.Title)
|
||||
}
|
||||
if r, _ := ParseReport("009-t", "---\nstatus: open\n---\n# 9 nodes share a name\n"); r.Title != "nodes share a name" {
|
||||
t.Fatalf("a title starting with the folder's number: %q", r.Title)
|
||||
}
|
||||
odd := strings.Join(r.Odd, "\n")
|
||||
for _, want := range []string{"opened: \"yesterday\" is not a date", "frontmatter keys not imported: severity", "numbered 12; the folder is 9"} {
|
||||
if !strings.Contains(odd, want) {
|
||||
t.Fatalf("odd %q lacks %q", odd, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheFrontmatterShapesHqUses(t *testing.T) {
|
||||
front, body, ok := frontmatter("---\n" +
|
||||
"a: [x, \"y, z\", (p, q) r]\n" +
|
||||
"b:\n - one\n - \"two: 2\"\n" +
|
||||
"c: plain scalar\n wrapped onto a line\n" +
|
||||
"d:\n" +
|
||||
"e: []\n" +
|
||||
"f: [over,\n two lines]\n" +
|
||||
"g: 'it''s'\n" +
|
||||
"---\nbody\n")
|
||||
if !ok || body != "body\n" {
|
||||
t.Fatalf("ok %v body %q", ok, body)
|
||||
}
|
||||
want := map[string][]string{
|
||||
"a": {"x", "y, z", "(p, q) r"},
|
||||
"b": {"one", "two: 2"},
|
||||
"c": {"plain scalar wrapped onto a line"},
|
||||
"d": nil,
|
||||
"e": nil,
|
||||
"f": {"over", "two lines"},
|
||||
"g": {"it's"},
|
||||
}
|
||||
for k, v := range want {
|
||||
if !reflect.DeepEqual(front[k], v) {
|
||||
t.Errorf("%s: %q, want %q", k, front[k], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
---
|
||||
status: resolved
|
||||
opened: 2026-08-23
|
||||
located-in: [hal (the instance), mesh-host internal/bootstrap (preflight asks the daemon), mesh-controller internal/catalogue (capabilities)]
|
||||
fixed-by:
|
||||
- "the instance only: PR #962 — incus hook. Merged, ran on one node in 6s of a 60s budget; pipeline #6832 green in 48s. The class remains open."
|
||||
- "partly, on the mesh: mesh-host 73c010e, 9d8239a — preflight and the profile detectors ask the daemon, not the package (installed-but-broken reports absent)"
|
||||
- "the class, on the mesh: a manifest declares capabilities, an action declares verify (ADR 0005), and a module's own assertions are the lab's verdict (design 01). The old arrangement is replaced module by module, so its count is never taken"
|
||||
amended-design: 03-DESIGN/01-to-be/01-end-to-end-testing.md
|
||||
---
|
||||
|
||||
# 007 — An installed package is not an available capability
|
||||
|
||||
## Symptom
|
||||
|
||||
A module declares the virtualisation package the lab needs. The package is installed —
|
||||
version 7.3.0-1, recorded as explicitly installed. The client binary runs.
|
||||
|
||||
The capability does not exist:
|
||||
|
||||
| Checked | State |
|
||||
|---|---|
|
||||
| `incus.service` | disabled, inactive |
|
||||
| `incus.socket` | disabled, inactive |
|
||||
| `incus-user.socket` | disabled, inactive |
|
||||
| the operator's group membership | not a member of any incus group |
|
||||
| the client | reports **`Server version: unreachable`** |
|
||||
|
||||
Nothing failed. Nothing reported anything. The declaration was satisfied exactly as written,
|
||||
and the thing it was declared for cannot be used.
|
||||
|
||||
## Why this is not issue 001 again
|
||||
|
||||
[Issue 001](../001-failed-package-install-reports-success/00-report.md) is *the install failed
|
||||
and the job reported success*. This is the opposite and arguably worse: **the install
|
||||
succeeded, and success was not the point.**
|
||||
|
||||
A package is a set of files. A capability is a running service, an enabled socket, and an
|
||||
identity permitted to reach it. The module model declares the first and has no vocabulary for
|
||||
the second, so the gap between them is invisible — there is no state in which the mesh believes
|
||||
this node has virtualisation and is wrong, because the mesh was never asked to believe it.
|
||||
|
||||
The distance between the two is the same one the delivery layer already has a name for:
|
||||
**transport versus effect.** A package install reports that files arrived, which is transport.
|
||||
|
||||
## Why it matters now
|
||||
|
||||
This is the first requirement of the lab
|
||||
([ADR 0016](../../02-DECISIONS/0016-the-lab.md)), which is
|
||||
phase 0 of the entire migration. The first capability the new work depends on is present,
|
||||
declared, and unusable — and would have stayed unusable silently.
|
||||
|
||||
It also generalises. Every module that declares a package needing a unit enabled, a group
|
||||
joined, a kernel module loaded, or a socket activated has this gap. Post-install work lives in
|
||||
hooks, and hooks have their own recorded failure mode: thirteen were found that had never run.
|
||||
|
||||
## Evidence
|
||||
|
||||
- Package recorded as installed 2026-08-23 00:02, explicitly.
|
||||
- Both units disabled and inactive; the operator in no incus group; client reports the server
|
||||
unreachable.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Should a module be able to declare a **capability** — a unit that must be enabled, a group
|
||||
the operator must be in — rather than only the package that provides it?
|
||||
- If that is what hooks are for, why is the gap invisible when a hook does not run? A hook that
|
||||
never fires and a hook that fires and does nothing are indistinguishable today.
|
||||
- Is this what the verify stage should be asserting? It exists, and a module's own assertions
|
||||
are meant to test outcomes rather than steps — "the socket accepts a connection" is exactly
|
||||
that shape.
|
||||
- How many other declared packages are in this state? Nothing currently reports it, which means
|
||||
the answer is unknown rather than zero.
|
||||
|
||||
## The instance is fixed; the class is what this issue is now about
|
||||
|
||||
*Updated 2026-08-23.* A hook now does the post-install work — group membership, subordinate id
|
||||
ranges, enabling both units, creating the storage pool, the bridge, and the default profile.
|
||||
Verified independently afterwards: the group exists with the operator in it, both id files
|
||||
carry the range, the service is active, and the storage pool reports `CREATED`.
|
||||
|
||||
**So the mechanism was never missing.** Hooks are exactly the right place for this, and used
|
||||
properly they work. The gap is narrower and worse than "there is no way to do it":
|
||||
|
||||
> The hook did six things. **Six checks were then performed by a human, by hand.** Nothing in
|
||||
> the pipeline asserted any of them, and a pipeline that dispatched a hook which silently never
|
||||
> fired would have been green in the same 48 seconds.
|
||||
|
||||
That is not hypothetical — a hook named for a feature its module does not carry is skipped
|
||||
without complaint, and thirteen such hooks were found at once in the past. The distance between
|
||||
*the hook ran and did six things* and *the hook was dispatched* is invisible from the outside,
|
||||
and it is the whole of this issue.
|
||||
|
||||
### The verification that was done by hand is the assertion set
|
||||
|
||||
The six checks performed after the merge are, almost word for word, what the module's own
|
||||
verification should assert — outcomes, not steps, exactly as the lab design requires:
|
||||
|
||||
| Asserted by hand | As a module assertion |
|
||||
|---|---|
|
||||
| operator in the admin group | the group exists and contains the operator |
|
||||
| subordinate id range in both files | both files carry the range |
|
||||
| both units enabled, service active | the service is active, and still active shortly after |
|
||||
| storage pool present | the pool exists and reports created |
|
||||
| bridge present with an address | the bridge exists and holds its address |
|
||||
| default profile wired to both | the profile references the bridge and the pool |
|
||||
|
||||
They currently live in a chat message. Moved into the module, they would run on every delivery
|
||||
to every node, and the difference between a hook that worked and a hook that was merely
|
||||
dispatched would stop being something a person has to notice.
|
||||
|
||||
### What this issue now asks
|
||||
|
||||
- Does a module gain a way to declare a **capability** — the outcome — separately from the
|
||||
package that provides it, or is "write assertions" the whole answer?
|
||||
- The verify stage exists and asserts almost nothing. Is this simply the first module that
|
||||
should use it properly, making the issue a coverage problem rather than a design gap?
|
||||
- **How many other declared packages are in the state this one was in?** Still unknown, still
|
||||
unreported by anything, and now demonstrably worth asking.
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
---
|
||||
status: resolved
|
||||
opened: 2026-09-12
|
||||
resolved: 2026-09-12
|
||||
located-in: [mesh-controller (the verb existed)]
|
||||
fixed-by: nothing — the capability already existed and the wrong verb was used
|
||||
amended-design:
|
||||
---
|
||||
|
||||
# 043 — A module cannot be given an account for the mesh's own queues
|
||||
|
||||
**Withdrawn the day it was opened. The premise was wrong.** Kept rather than deleted, because the
|
||||
mistake is repeatable and the reason is worth reading.
|
||||
|
||||
## What was claimed
|
||||
|
||||
That a build machine could not be given access to the mesh's build queue, because a broker account
|
||||
is scoped to what a module `emits` and `consumes`, and the build queue is not a module event. The
|
||||
evidence was a builder that authenticated and was then refused:
|
||||
|
||||
```
|
||||
ACCESS_REFUSED - User 'anchor-builder' doesn't have permissions to queue 'builds'
|
||||
```
|
||||
|
||||
## Why it was wrong
|
||||
|
||||
**The capability exists and is reachable from the command line.** There are two verbs, and they
|
||||
create different things:
|
||||
|
||||
| Verb | Creates | Scoped to |
|
||||
|---|---|---|
|
||||
| `module issue <module> --node <n>` | a module's account | what that module emits and consumes |
|
||||
| `builder issue <name> [--node <n>]` | a build machine's account | the build queue, and the mesh exchange |
|
||||
|
||||
The refusal was produced by using the first for a job the second exists to do. Running
|
||||
`builder issue` and pushing produced a builder that starts and takes work — no change to any
|
||||
manifest, any code, or the account mechanism.
|
||||
|
||||
## The part worth keeping
|
||||
|
||||
**The wrong verb succeeds, and says so.** `module issue` reported *"broker account created, scoped
|
||||
to what it emits and consumes"* for a module that emits and consumes nothing, producing an account
|
||||
that authenticates and can do nothing. The failure then appears one layer away, in the module's own
|
||||
log, as a permissions error against a queue — which reads like a missing capability rather than a
|
||||
misused command.
|
||||
|
||||
That is a small, real sharp edge, and it is the whole of what this issue found. Whether it is worth
|
||||
anything — a refusal when a module with no events asks for an account, or a note in the builder
|
||||
module pointing at the verb that fits it — is a judgement, not a defect.
|
||||
|
||||
**And a lesson that is not about the mesh:** the capability was three lines away in the same file as
|
||||
the code being read, under a name that says exactly what it does. The issue was written before
|
||||
looking for it.
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
---
|
||||
status: resolved
|
||||
opened: 2026-09-22
|
||||
located-in: [mesh-catalog modules/redis (the provisioner's ACL)]
|
||||
fixed-by: mesh-catalog multiple-fixes (the consumer's ACL user loses the dangerous command category); proven by the grant end-to-end bed, which now asserts a write outside the consumer's keys and FLUSHALL are refused
|
||||
---
|
||||
|
||||
# 080 — A cache grant lets the consumer flush the server
|
||||
|
||||
## Symptom
|
||||
|
||||
The cache provider's provisioner creates each consumer an ACL user confined to keys under its own
|
||||
login and allowed every command. A key pattern confines only commands that name keys. `FLUSHALL`,
|
||||
`FLUSHDB`, `CONFIG`, `SHUTDOWN` and the rest of the dangerous category name none, so a consumer
|
||||
granted "its own keys" could wipe every other consumer's, or stop the server.
|
||||
|
||||
Found by carrying the large mesh bed's retired tenancy assertions into the grant end-to-end bed:
|
||||
`FLUSHALL` as the consumer answered `OK`.
|
||||
|
||||
## Why it matters beyond the instance
|
||||
|
||||
A grant is the mesh's promise that a consumer gets what it asked for and nothing else. The
|
||||
promise was checked on the key pattern and never on the command set, and the one bed that had
|
||||
asked was retired before it was run against the catalogue's module.
|
||||
|
||||
## What would close it
|
||||
|
||||
The ACL user is allowed the ordinary command set minus the dangerous category, and the grant bed
|
||||
asserts a write outside the consumer's keys and a `FLUSHALL` are both refused.
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
---
|
||||
status: wontfix
|
||||
opened: 2026-09-29
|
||||
located-in:
|
||||
- mesh-controller internal/catalogue/declaration.go (contributions are emitted for an assigned module, taken or not)
|
||||
fixed-by:
|
||||
---
|
||||
|
||||
# 150 — A route is contributed before its module is taken
|
||||
|
||||
## What was observed
|
||||
|
||||
ace is adopted and runs `route-adapter` beside the predecessor's traefik (ADR 0104). The first web
|
||||
module migrated there was searxng. `assign ace searxng` + `push ace` — the step that is supposed to
|
||||
change nothing on an adopted machine (ADR 0100, hq 125: assign holds, take replaces) — took
|
||||
`searxng.zurag.be` down:
|
||||
|
||||
```
|
||||
https://searxng.zurag.be/ → 502
|
||||
```
|
||||
|
||||
for about five minutes, until the module was unassigned again.
|
||||
|
||||
## Why
|
||||
|
||||
Assign held everything it found on the machine — the predecessor's `searxng` container, its
|
||||
directories — exactly as designed. But the module's **route contribution** is not a resource on the
|
||||
machine, so nothing held it: it reached `route-adapter` at once, which wrote
|
||||
`mesh-searxng.zurag.be.yml` into traefik's file provider pointing at the mesh's assigned machine port
|
||||
(`http://ace.internal:20000`) — where nothing listened, because the container that would was held.
|
||||
traefik's file router for the name then won over the predecessor's docker-label router for the same
|
||||
name, and the name served a dead backend.
|
||||
|
||||
On this occasion the window was lengthened by an unrelated failure (the machine's docker address
|
||||
pools were exhausted, so the module's network could not be created), but the fault does not depend on
|
||||
it: **between assign and take, every routed module's public name points at a backend the mesh has
|
||||
deliberately not started.** The runbook's §6 step 2 ("check the preparation — HAL's service still
|
||||
serves") is false for every routed module on a node running route-adapter.
|
||||
|
||||
## What the operator did
|
||||
|
||||
Rolled back per runbook (unassign before take), then re-ran as assign → push → wait for the node to
|
||||
report its holds → take → push, keeping the window to the ~10 s between the two pushes. A workaround,
|
||||
not a fix: a module whose data must move between assign and take (runbook §6 step 3) cannot shrink
|
||||
its window this way.
|
||||
|
||||
## What would be right
|
||||
|
||||
A contribution from a module that is assigned but **not taken** on an adopted node should be held like
|
||||
the module's resources are — withheld from the provider until take — or the provider should be told
|
||||
the contributor is held and leave the predecessor's route alone. Either keeps "assign changes nothing"
|
||||
true for routed modules.
|
||||
|
||||
## Won't fix — 2026-10-08
|
||||
|
||||
The arrangement it is about no longer exists: every machine is converged, no machine runs the
|
||||
predecessor's proxy or the `route-adapter` beside it, and the predecessor it was adopted from is
|
||||
retired, so there is no adopted machine on which an assigned but untaken module can contribute a route.
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
---
|
||||
status: resolved
|
||||
opened: 2026-10-04
|
||||
located-in: [mesh-controller cmd/mesh-controller (the merge gate)]
|
||||
fixed-by: novox/mesh-controller PR #96 (bbd442c, the merge gate, ADR 0237)
|
||||
replay: R236
|
||||
amended-design:
|
||||
---
|
||||
|
||||
# 236 — The catalogue check passes a manifest the host refuses
|
||||
|
||||
## What was observed
|
||||
|
||||
2026-10-04. A login-manager module passed `mesh-controller module check`, was registered, built and
|
||||
assigned. The first push of its declaration was refused whole by the host:
|
||||
|
||||
```
|
||||
refused a declaration: this declaration is refused, and none of it was applied:
|
||||
- resource "lemurs.service": a service that omits state leaves the unit's lifecycle to the
|
||||
machine, and boot and takes-over are both its lifecycle
|
||||
```
|
||||
|
||||
The rule is the host's declaration validation. The controller's check never applies it, so a manifest
|
||||
can pass every check the catalogue has and still fail on the first machine that receives it.
|
||||
|
||||
## Why it matters beyond this instance
|
||||
|
||||
A refusal is whole, so one module with this fault blocks every other change for that node until the
|
||||
module is fixed, rebuilt and pushed again. The fault is static: it is in the manifest, and could be
|
||||
found before a module is merged. Today it is found by assigning the module to a live machine.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. Should the host's declaration validation be importable, so that `module check` (and registration)
|
||||
runs it over each resource the manifest declares?
|
||||
2. Or should the controller validate the composed declaration before it sends it, and refuse to send
|
||||
one the host would refuse?
|
||||
3. Which other host-side rules are not visible to the catalogue check today?
|
||||
|
||||
## Resolved — 2026-10-08
|
||||
|
||||
The merge gate (mesh-controller PR #96) composes each machine with the change and runs the node-engine's
|
||||
own declaration validation over it, failing the pull request with the machine and the module. R236 in
|
||||
mesh-lab's replays register holds it.
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
---
|
||||
status: diagnosing
|
||||
opened: 2026-10-09
|
||||
located-in: []
|
||||
fixed-by:
|
||||
amended-design:
|
||||
---
|
||||
|
||||
# 345. A calendar reminder shows as a transparent, empty window, and the mesh had no tool to look at it
|
||||
|
||||
## Symptom
|
||||
|
||||
On 2026-10-09 the operator reported that on the workstation, when an Outlook (Exchange) meeting is
|
||||
due, the mail client's reminder window appears, but it is transparent and shows no content: no title,
|
||||
no time, no Dismiss or Snooze. The mail client is Betterbird (a Thunderbird fork, installed from the
|
||||
AUR), with the Exchange calendar through the Owl add-on. The workstation runs i3 and the `picom`
|
||||
compositor (glx backend, vsync, fading). No module owned Betterbird.
|
||||
|
||||
The operator's view of done: *on the workstation, when an Outlook meeting is due, the reminder window
|
||||
shows the meeting (title, time, Dismiss/Snooze) on a normal background, not a transparent empty window,
|
||||
and the mesh has tools to see and fix Betterbird.*
|
||||
|
||||
## Reported history
|
||||
|
||||
The operator reports (2026-10-09) earlier trouble with picom on the workstations, never recorded: a chat
|
||||
window (Telegram) froze, not redrawn, after a change of i3 workspace. `records_search` for picom finds
|
||||
nothing about it. It is the same kind of failure as this one, a window the compositor shows without its
|
||||
content, and it makes picom the first suspect here.
|
||||
|
||||
## Why it is an issue
|
||||
|
||||
Two gaps, and the symptom itself.
|
||||
|
||||
1. **A desktop application the operator uses every day ran outside the mesh.** Nothing could say which
|
||||
window the reminder is, what the compositor makes of it, or which preferences the mail client runs
|
||||
with. A change to it could only be made by hand in its profile, which the mesh's rules forbid and
|
||||
which nothing would keep.
|
||||
2. **The tools that look at a screen could not look at this window.** The window manager's list
|
||||
(`node-display-session.windows`) leaves out every window it does not tile, the reminder among them,
|
||||
and says nothing a compositor decides by (a window's depth, type, opacity). The screenshot tool of the
|
||||
display server failed for one window with `X Error BadColor (invalid Colormap parameter)
|
||||
X_QueryColors`, because it dumped the window by its id.
|
||||
3. **The symptom's cause is not known yet.** A reminder that shows nothing is a missed meeting.
|
||||
|
||||
The diagnosis is in [01-diagnosis.md](01-diagnosis.md).
|
||||
|
||||
## Impact
|
||||
|
||||
- The operator cannot read or dismiss a meeting reminder: the window is there but empty.
|
||||
- Until now, the next session that looked at it had to work around the mesh to see a window.
|
||||
|
||||
## What is done
|
||||
|
||||
- The display server's module gains a window list that includes unmanaged windows with their depth,
|
||||
type, state, role and own opacity, and its screenshot of one window is cut from the screen as the
|
||||
compositor shows it (catalogue pull request, linked from this record's pull request).
|
||||
- A `betterbird` module: status, named preferences without credentials or accounts, a user.js declared
|
||||
through the module's setting, its log, restart, check, and a reminder on demand from a throw-away
|
||||
profile, so a fix can be proved without touching the operator's calendar.
|
||||
|
||||
- The `picom` module's backend and damage tracking become settings of each machine (defaults unchanged:
|
||||
`glx`, `use-damage` true), so the candidate fixes can be set and proved without a hand edit.
|
||||
|
||||
What is not done: the cause, and so the fix. See the diagnosis.
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
---
|
||||
status: resolved
|
||||
opened: 2026-10-10
|
||||
located-in: [mesh-catalog modules/mesh-delivery (holder.go deliveryWalking), mesh-controller cmd/mesh-controller (upgrades.go SourceMoved, release_plan.go laterOnTheBranch)]
|
||||
fixed-by: mesh-catalog#171, mesh-controller#197
|
||||
replay: R362
|
||||
amended-design: 03-DESIGN/01-to-be/47-delivery-from-commit-to-delivered.md
|
||||
---
|
||||
|
||||
# 362. Two catalogue merges seconds apart left their delivery waiting for a person
|
||||
|
||||
## Symptom
|
||||
|
||||
Shortly after midnight on 2026-10-10 (local time) two pull requests of the catalogue were merged
|
||||
18 seconds apart: the claude-code change at 00:20:52, then the dunst change at 00:21:09. Both had passed
|
||||
their checks.
|
||||
|
||||
`mesh-delivery.deliveries` then showed every delivery of the dunst pull request `superseded`, and the
|
||||
claude-code delivery `held`, waiting for "a person: merged, and the controller opened no walk for it within
|
||||
10m0s — nothing it holds follows that branch, or the merge was not heard: a person decides". The
|
||||
controller's `plans` said of the walk for the catalogue's head, which carried both merges: "published; its
|
||||
walk waits for mesh-delivery's word — `plans go … --why …` starts it by hand".
|
||||
|
||||
Both reasons were wrong. A walk had been opened, and it contained the held merge. Nothing gave it the word.
|
||||
The operator's change sat undelivered for 58 minutes, until the operator's hand-act `mesh-controller.plans
|
||||
go` started the walk. **No condition asked anyone to act in that time**: the hold is bounded at 24 hours
|
||||
before `mesh-delivery.stalled` lists it, and the walk's own wait raised only a warning, at 00:51, which said
|
||||
there was nothing to do and pointed at the ended delivery (the diagnosis's second entry; this sentence said
|
||||
before that the walk's wait raised nothing).
|
||||
|
||||
## Why it is an issue
|
||||
|
||||
A merge that passed its checks must reach its nodes without a person, and a delivery that cannot must say
|
||||
so loudly and truly. Here both failed: two ordinary merges close together, which happens whenever the
|
||||
operator merges a batch, stopped the mesh's delivery silently, and the reason it gave pointed away from the
|
||||
cause.
|
||||
|
||||
## Where it lives
|
||||
|
||||
The diagnosis ([01-diagnosis.md](01-diagnosis.md)) traces it to two parts that each behave as written and
|
||||
disagree on what a walk's commit means:
|
||||
|
||||
- The controller plans a merge it hears after a later merge of the same branch at the later merge's commit,
|
||||
and supersedes the earlier walk (the fix for
|
||||
[issue 349](../349-a-merge-acted-on-late-superseded-the-merge-after-it-and-built-the-older-commit/00-report.md)).
|
||||
So the claude-code merge got a walk at the dunst merge's commit.
|
||||
- mesh-delivery finds the delivery a walk walks by its walk id or by the commit it was merged as. The new
|
||||
walk matched the dunst delivery, which its first walk's supersession had already ended, so no live
|
||||
delivery held the walk and nothing gave it its turn. The claude-code delivery, merged as a different
|
||||
commit, never got a walk and was held after ten minutes.
|
||||
|
||||
## Open
|
||||
|
||||
- How mesh-delivery learns which merges a walk carries: the controller's walk record naming every merge it
|
||||
answers (its commit and the commits planned into it), or mesh-delivery reading containment itself, as it
|
||||
already does to decide *covered*.
|
||||
- A superseded walk whose successor matches only final deliveries: whether the successor should be adopted
|
||||
as a delivery of its own, so that something always holds a waiting walk.
|
||||
- A walk waiting for mesh-delivery's word past a bound, and a delivery held because no walk was opened,
|
||||
raise a condition to the operator instead of waiting silently (failure must be loud).
|
||||
|
||||
## Decided
|
||||
|
||||
The operator decided the model that removes this shape, not only its bookkeeping:
|
||||
[ADR 0276](../../02-DECISIONS/0276-merges-are-assembled-in-a-rolling-window-and-each-batch-is-delivered-by-one-walk-with-one-commit-per-repository.md). Merges are collected in a rolling merge window and each batch is walked once, with at most one
|
||||
commit per repository, one walk at a time. The bookkeeping that tied a walk to an ended delivery is repaired as
|
||||
well, and every wait of this shape has a bound and a condition. It is designed in to-be 47, *The merge window
|
||||
and the batch*.
|
||||
|
||||
## Resolved
|
||||
|
||||
2026-10-10. mesh-delivery's side is mesh-catalog#171 (merged): a walk is tied to every live delivery whose
|
||||
merge it answers, never to one that has ended, and a waiting walk no live delivery holds is listed by `stalled`
|
||||
at once. The controller's side is mesh-controller#197: the merge window and the batch, one walk per batch with
|
||||
one commit per repository, the walk naming every merge it answers, and the bounds of decision 9. The replay
|
||||
R362 is registered in mesh-lab's replays register (mesh-lab#75), proved to fail on the commit before the
|
||||
mesh-delivery fix and pass on it. The build raised one rule the record had not settled, decided during the
|
||||
build and written into ADR 0276: a merge on the controller's own path never shares a batch with a catalogue
|
||||
merge.
|
||||
@@ -26,6 +26,8 @@ type Tracker struct {
|
||||
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.
|
||||
@@ -119,16 +121,9 @@ func (t *Tracker) Open(a OpenAsk) (*Opened, error) {
|
||||
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
|
||||
}
|
||||
is := Issue{Title: title, Status: StatusOpen, Report: report, Opened: at, OpenedBy: t.by(), LocatedIn: located, Opening: newID()}
|
||||
|
||||
n, err := t.next()
|
||||
if err != nil {
|
||||
@@ -144,7 +139,7 @@ func (t *Tracker) Open(a OpenAsk) (*Opened, error) {
|
||||
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.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,
|
||||
@@ -377,10 +372,6 @@ func (t *Tracker) Move(a MoveAsk) (*Moved, error) {
|
||||
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)
|
||||
@@ -391,7 +382,7 @@ func (t *Tracker) Move(a MoveAsk) (*Moved, error) {
|
||||
changed = append(changed, name)
|
||||
}
|
||||
}
|
||||
set("located-in", located != nil, func() { is.LocatedIn = located })
|
||||
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, "" })
|
||||
@@ -591,9 +582,6 @@ type Listed struct {
|
||||
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)
|
||||
@@ -649,11 +637,7 @@ func (t *Tracker) List(a ListAsk) (*Listed, error) {
|
||||
}
|
||||
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})
|
||||
out.Rows = append(out.Rows, Row{Number: n, Status: is.Status, Owner: strings.Join(is.LocatedIn, "; "), Title: is.Title})
|
||||
}
|
||||
}
|
||||
switch {
|
||||
|
||||
@@ -72,7 +72,6 @@ func TestARefusedOpenSpendsNoNumber(t *testing.T) {
|
||||
{Title: "t\x1b[31m", Report: "r"},
|
||||
{Title: "t", Report: "r"},
|
||||
{Title: "t", Report: "r\x00"},
|
||||
{Title: "t", Report: "r", Record: "a-folder", LocatedIn: []string{"mesh-controller"}},
|
||||
}
|
||||
for i, a := range refused {
|
||||
if _, err := h.Open(a); err == nil {
|
||||
@@ -244,18 +243,18 @@ func TestAnIssueWithAFolderIsNumberedTitledAndPointsAtItsFolder(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if o.Record != "04-ISSUES/365-the-counter-skips-a-number" || o.Status != "" {
|
||||
if o.Record != "04-ISSUES/365-the-counter-skips-a-number" || o.Status != StatusOpen {
|
||||
t.Fatalf("answer %+v", o)
|
||||
}
|
||||
_, err = h.Move(MoveAsk{Number: 365, Status: StatusDiagnosing})
|
||||
if err == nil || !strings.Contains(err.Error(), "keeps its status in its folder") {
|
||||
// Since the import of slice 3, an issue with a folder is whole in the tracker: its status moves here.
|
||||
if _, err := h.Move(MoveAsk{Number: 365, Status: StatusDiagnosing}); err != nil {
|
||||
t.Fatalf("a move of an issue with a folder: %v", err)
|
||||
}
|
||||
if _, err := h.Note(365, "notes are taken"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l, _ := h.List(ListAsk{})
|
||||
if l.Rows[0].Status != InFolder {
|
||||
if l.Rows[0].Status != StatusDiagnosing {
|
||||
t.Fatalf("listed %+v", l.Rows)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user