The tool runner now takes a call's caller from the subject it arrived on and hands it to the module through the SDK (stdio.CallerOf); the tracker records that bus user on what each verb writes instead of unproven. Never from the arguments, which still refuse any caller, and unproven where the runtime proves none. Needs mesh-sdk's go/caller.go (pinned at its branch head until merged).
688 lines
24 KiB
Go
688 lines
24 KiB
Go
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) {
|
|
return t.importAs(t.by(), a, records)
|
|
}
|
|
|
|
// importAs is Import as the one who acts.
|
|
func (t *Tracker) importAs(by string, 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()
|
|
|
|
// 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
|
|
}
|