package main // list (novox/hq issue 1064): one short row per record, filtered by kind, status, topic or a flag, and paged // newest first, number by number, like issue-tracker.list. import ( "context" "fmt" "sort" "strings" "time" ) // The flags list may be asked for. const ( FlagResearchDone = "research-done" FlagProposedOld = "proposed-old" ) // ListFlags are the flags list takes. var ListFlags = []string{FlagResearchDone, FlagProposedOld} // ProposedOldAfter is how long a proposal may sit at proposed before flag proposed-old names it. const ProposedOldAfter = 24 * time.Hour // MostListResult is the most rows list may answer in one call. const MostListResult = 500 // AllStatuses are every status a record of either kind may hold. var AllStatuses = []string{StatusReserved, StatusProposed, StatusAccepted, StatusSuperseded, StatusWithdrawn, StatusActive, StatusGraduated, StatusAbandoned} // ListAsk narrows a list. type ListAsk struct { Kind string Status string Topic string // Flag keeps only the research efforts done (research-done) or the proposals grown old (proposed-old). Flag string // Before keeps only the records numbered below it: the cursor that pages the newest-first list. Before int Limit int } // ListRow is one record in a list: short enough to read in a chat. type ListRow struct { // Number is the record's number as the records print it: "0276" for a decision, "research 045" for // research. Number string `json:"number"` Kind string `json:"kind"` Title string `json:"title"` Status string `json:"status"` Topic string `json:"topic,omitempty"` Date string `json:"date,omitempty"` Versions int `json:"versions"` // Pending says a version waits for the knowledge base's own review, or the operator still waits to Approve // (an ask that ended, approved or declined, is not pending; novox/hq issue 1064, review round 1). Pending bool `json:"pending,omitempty"` } // Listed is the answer to a list. type Listed struct { Rows []ListRow `json:"rows"` Matched int `json:"matched"` // Next is the before that continues the list where this page ends; 0 when nothing more matches. Next int `json:"next,omitempty"` Said string `json:"said"` } // rowNumber is a record's number as list's row prints it. func rowNumber(kind string, number int) string { if kind == KindResearch { return fmt.Sprintf("research %03d", number) } return fmt.Sprintf("%04d", number) } // List is the records that match, newest first, number by number (novox/hq issue 1064). func (k *KB) List(a ListAsk) (*Listed, error) { if a.Kind != "" && !contains(Kinds, a.Kind) { return nil, fmt.Errorf("kind is decision or research, not %q", a.Kind) } if a.Status != "" && !contains(AllStatuses, a.Status) { return nil, fmt.Errorf("status %q is not one of %s", a.Status, strings.Join(AllStatuses, ", ")) } if a.Flag != "" && !contains(ListFlags, a.Flag) { return nil, fmt.Errorf("flag is %s, not %q", strings.Join(ListFlags, " or "), a.Flag) } limit := a.Limit if limit <= 0 { limit = 50 } limit = min(limit, MostListResult) ctx := context.Background() all, err := k.Store.All(ctx) if err != nil { return nil, err } byKey := map[Key]*Entry{} for _, e := range all { byKey[Key{e.Current.Kind, e.Current.Number}] = e } keys := make([]Key, 0, len(byKey)) for key := range byKey { if a.Before == 0 || key.Number < a.Before { keys = append(keys, key) } } sort.Slice(keys, func(i, j int) bool { if keys[i].Number != keys[j].Number { return keys[i].Number > keys[j].Number } return keys[i].Kind < keys[j].Kind }) out := &Listed{Rows: []ListRow{}} var lastNumber int for _, key := range keys { e := byKey[key] r := e.Current if a.Kind != "" && r.Kind != a.Kind { continue } if a.Status != "" && r.Status != a.Status { continue } if a.Topic != "" && r.Topic != a.Topic { continue } if a.Flag != "" { ok, err := k.matchesListFlag(ctx, a.Flag, key, e, byKey) if err != nil { return nil, err } if !ok { continue } } out.Matched++ // Never cut a page between two records of the same number: a decision and a research effort may share // one, and a bare-number cursor (before) must stay correct for both (novox/hq issue 1064, review round // 1). if len(out.Rows) < limit || (len(out.Rows) > 0 && key.Number == lastNumber) { out.Rows = append(out.Rows, ListRow{ Number: rowNumber(r.Kind, r.Number), Kind: r.Kind, Title: r.Title, Status: r.Status, Topic: r.Topic, Date: r.Date, Versions: e.Versions, Pending: e.Pending != nil || (r.Ask != nil && r.Ask.Said == AskWaiting), }) lastNumber = key.Number } } below := "" if a.Before != 0 { below = fmt.Sprintf(" below %d", a.Before) } switch { case out.Matched == 0: out.Said = "No record matches" + below + "." case out.Matched > len(out.Rows): out.Next = lastNumber out.Said = fmt.Sprintf("%d records match%s; the newest %d are shown. The next page is before %d.", out.Matched, below, len(out.Rows), out.Next) default: out.Said = fmt.Sprintf("%d records match%s, newest first.", out.Matched, below) } return out, nil } // matchesListFlag says whether an entry matches a list flag. func (k *KB) matchesListFlag(ctx context.Context, flag string, key Key, e *Entry, byKey map[Key]*Entry) (bool, error) { switch flag { case FlagResearchDone: return researchDone(e, byKey), nil case FlagProposedOld: return k.proposedOld(ctx, key, e) } return false, nil } // researchDone says whether a research effort is active, names what it became in at least one became link to a // decision (a became to a design's path is ignored), and every decision it names that way is accepted: a // candidate for graduated (novox/hq issue 1064). func researchDone(e *Entry, byKey map[Key]*Entry) bool { r := e.Current if r.Kind != KindResearch || r.Status != StatusActive { return false } any := false for _, l := range r.Links { if l.Kind != LinkBecame { continue } target, isRecord := l.To.Record() if !isRecord || target.Kind != KindDecision { continue } any = true d := byKey[target] if d == nil || d.Current.Status != StatusAccepted { return false } } return any } // proposedOld says whether a record is proposed and its current version is older than ProposedOldAfter, by the // KB's clock (novox/hq issue 1064). func (k *KB) proposedOld(ctx context.Context, key Key, e *Entry) (bool, error) { if e.Current.Status != StatusProposed { return false, nil } vs, err := k.Store.Versions(ctx, key) if err != nil { return false, err } for _, v := range vs { if v.N == e.CurrentVersion { return k.now().Sub(v.At) > ProposedOldAfter, nil } } return false, nil }