From 043b0fd7ade4c7efca7ece081fcc4cabb195534a Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Thu, 13 Aug 2026 09:12:34 +0300 Subject: [PATCH] web: answer what is ready across every tracker --- beads/build.go | 68 +---- beads/milestones.go | 23 +- beads/ready.go | 492 +++++++++++++++++++++++++++++++++++++ beads/ready_test.go | 477 +++++++++++++++++++++++++++++++++++ beads/rows.go | 99 ++++++++ web/adapters.go | 4 + web/deps.go | 7 + web/handlers_ready.go | 90 +++++++ web/handlers_ready_test.go | 347 ++++++++++++++++++++++++++ web/router.go | 12 + web/templates/index.html | 7 +- web/templates/ready.html | 168 +++++++++++++ web/web_test.go | 50 +++- 13 files changed, 1762 insertions(+), 82 deletions(-) create mode 100644 beads/ready.go create mode 100644 beads/ready_test.go create mode 100644 web/handlers_ready.go create mode 100644 web/handlers_ready_test.go create mode 100644 web/templates/ready.html diff --git a/beads/build.go b/beads/build.go index d4778b76eb4c8a4ea2e653a1ffbfdfdc78f0ce61..e0fd6eed21bfc7fae4caf70812e90e239b39f425 100644 --- a/beads/build.go +++ b/beads/build.go @@ -33,64 +33,18 @@ func Build(ctx context.Context, sess BrowseSession, ref string, query url.Values shownOf := issuesTotal // status name → category, from custom_statuses (may be empty → heuristics). - catByStatus := map[string]string{} - if statuses != nil { - nameIdx := statuses.Columns - cols := indexCols(nameIdx) - for _, r := range statuses.Rows { - name := cell(cols, r, "name") - cat := cell(cols, r, "category") - if name != "" { - catByStatus[strings.ToLower(name)] = strings.ToLower(cat) - } - } - } + catByStatus := indexStatusCategories(statuses) // issue id → category, needed to decide whether a blocking target is "open". issueCols := indexCols(issues.Columns) - catByIssue := make(map[string]string, len(issues.Rows)) - for _, r := range issues.Rows { - id := cell(issueCols, r, "id") - catByIssue[id] = statusCategory(cell(issueCols, r, "status"), catByStatus) - } + catByIssue := indexIssueCategories(issues, issueCols, catByStatus) // Aggregate dependency edges by issue. depCols := indexCols(deps.Columns) - blockedByCount := map[string]int{} // issue_id → #deps it has - blocksCount := map[string]int{} // depends_on_issue_id → #deps aimed at it - blockedOpen := map[string]bool{} // issue_id → has an open blocking dep - for _, r := range deps.Rows { - from := cell(depCols, r, "issue_id") - to := cell(depCols, r, "depends_on_issue_id") - typ := strings.ToLower(cell(depCols, r, "type")) - if from != "" { - blockedByCount[from]++ - } - if to != "" { - blocksCount[to]++ - } - if from != "" && typ == "blocks" { - // A "blocks" edge to a still-open target blocks the source. parent-child - // is hierarchy, not a blocker — a subtask is not blocked by its (open) - // epic, matching bd's own is_blocked/ready accounting. - if catByIssue[to] != "closed" { - blockedOpen[from] = true - } - } - } + depIdx := indexDeps(deps, catByIssue) // labels: issue_id → [label] - labelsByIssue := map[string][]string{} - if labels != nil { - lcols := indexCols(labels.Columns) - for _, r := range labels.Rows { - id := cell(lcols, r, "issue_id") - lb := cell(lcols, r, "label") - if id != "" && lb != "" { - labelsByIssue[id] = append(labelsByIssue[id], lb) - } - } - } + labelsByIssue := indexLabels(labels) // Detail mode: a named issue short-circuits the board build. if want := query.Get("issue"); want != "" { @@ -118,12 +72,10 @@ func Build(ctx context.Context, sess BrowseSession, ref string, query url.Values continue } cat := catByIssue[id] - blocked := truthy(cell(issueCols, r, "is_blocked")) || blockedOpen[id] - // "Ready" mirrors bd's ready set: open (not in-progress/closed), unblocked, - // and not a template/ephemeral scaffold. Derived in-process (the issues - // data is already loaded) rather than reading the full ready_issues table. - ready := cat == "open" && !blocked && - !truthy(cell(issueCols, r, "is_template")) && !truthy(cell(issueCols, r, "ephemeral")) + blocked := truthy(cell(issueCols, r, "is_blocked")) || depIdx.blockedOpen[id] + // "Ready" mirrors bd's ready set; readyRow is the one copy of the rule, + // shared with the cross-database ready page. + ready := readyRow(cat, blocked, r, issueCols) if filter.Ready && !ready { continue } @@ -134,8 +86,8 @@ func Build(ctx context.Context, sess BrowseSession, ref string, query url.Values Priority: cell(issueCols, r, "priority"), Assignee: cell(issueCols, r, "assignee"), Labels: labelsByIssue[id], - BlockedBy: blockedByCount[id], - Blocks: blocksCount[id], + BlockedBy: depIdx.blockedByCount[id], + Blocks: depIdx.blocksCount[id], Ready: ready, StartedAt: cell(issueCols, r, "started_at"), ClosedAt: cell(issueCols, r, "closed_at"), diff --git a/beads/milestones.go b/beads/milestones.go index fe3710d443593b8c9e5dfc083600cabc253cad16..335106aa0ed87f83f3b027f9a0a64774d458cf6c 100644 --- a/beads/milestones.go +++ b/beads/milestones.go @@ -76,27 +76,8 @@ func BuildMilestones(ctx context.Context, sess BrowseSession, ref string) (*Mile } } - catByStatus := map[string]string{} - if statuses != nil { - cols := indexCols(statuses.Columns) - for _, r := range statuses.Rows { - if name := cell(cols, r, "name"); name != "" { - catByStatus[strings.ToLower(name)] = strings.ToLower(cell(cols, r, "category")) - } - } - } - - labelsByIssue := map[string][]string{} - if labels != nil { - cols := indexCols(labels.Columns) - for _, r := range labels.Rows { - id := cell(cols, r, "issue_id") - lb := cell(cols, r, "label") - if id != "" && lb != "" { - labelsByIssue[id] = append(labelsByIssue[id], lb) - } - } - } + catByStatus := indexStatusCategories(statuses) + labelsByIssue := indexLabels(labels) issueCols := indexCols(issues.Columns) byLabel := map[string]*MilestoneDetail{} diff --git a/beads/ready.go b/beads/ready.go new file mode 100644 index 0000000000000000000000000000000000000000..3d68aaa5055dcca736fb51cf4c960d1c354b146e --- /dev/null +++ b/beads/ready.go @@ -0,0 +1,492 @@ +package beads + +import ( + "context" + "net/url" + "sort" + "strings" + "sync" + "time" + + "sourcecraft.dev/bigbes/sr-ht-dolt/browse" +) + +// --- the cross-database ready set --------------------------------------------- +// +// "What is ready to work" is answerable in each beads database on the instance +// and, until this, nowhere across them — which is the question the split into a +// global tracker plus per-project trackers was supposed to make askable. +// +// This is one function with two consumers: the /ready page renders it, and the +// MCP surface's ready_work with no database named calls it. Two implementations +// would answer differently the first time the ready rule moved. +// +// Authorization is not here. By the time a caller reaches this function it has +// already decided which databases this caller may browse (see the package +// comment: this package renders nothing and authorizes nothing); handing it a +// database is the statement that the caller may read it. + +const ( + // ReadyMaxDatabases bounds how many databases one call opens. Opening N + // stores per request is exactly what the per-request browse discipline does + // not scale to, and a page that hit this ceiling says so: a silent cap reads + // as "that is everything". + ReadyMaxDatabases = 64 + + // ReadyCacheTTL is how long a cached projection stands regardless of the head + // hash. The head-hash gate is what makes the cache correct; the TTL is what + // makes it impossible for the cache to be the reason a reader sees yesterday's + // answer. + ReadyCacheTTL = 60 * time.Second + + // readyCacheMaxEntries bounds the cache by entry count. A cache is not a + // store: over the ceiling it drops what has expired and, failing that, starts + // again, rather than growing with the instance. + readyCacheMaxEntries = 256 +) + +// ReadyDatabase names one database to consider. ID is the identity the cache is +// keyed on — the repository row id, which no two databases share and which +// survives a rename. +// +// The field names are OwnerName and Name deliberately: the freshness partial the +// beads views render is handed a database here rather than a repository, and a +// partial that reads .OwnerName must find it on both. +type ReadyDatabase struct { + ID int + OwnerName string + Name string +} + +// Slug is the database's "owner/name" address — what ?db= names and what the +// group headers show. +func (d ReadyDatabase) Slug() string { return d.OwnerName + "/" + d.Name } + +// ReadySession is the read-only surface one database is read through. It is +// BrowseSession (the rows) plus the three things this aggregation needs that a +// projection of a single, already-opened database does not: the branch list (for +// the head hash the cache gates on), the table list (for the fingerprint), the +// log (for the group's own freshness line) — and Close, because here the +// aggregation owns the session's lifetime. +// +// web's BrowseSession and *browse.DB satisfy it structurally. +type ReadySession interface { + BrowseSession + Branches(ctx context.Context) ([]browse.Branch, error) + Tables(ctx context.Context, refStr string) ([]browse.TableInfo, error) + Log(ctx context.Context, refStr, fromHash string, limit int) ([]browse.CommitInfo, string, error) + Close() error +} + +// ReadyOpener opens one database. The returned session is closed by this +// package — a caller that kept it would be hoarding a file handle and a memory +// mapping, which is what per-request opening exists to avoid. +type ReadyOpener func(ctx context.Context, db ReadyDatabase) (ReadySession, error) + +// ReadyGroup is one database's ready work. +type ReadyGroup struct { + Database ReadyDatabase + Ref string // the branch the ready set was read from + Head *browse.CommitInfo // that branch's head, or nil when it cannot be read + Cards []Card // ready issues, priority then id +} + +// ReadyFailure is one database that could not be read. It carries the error for +// the caller's log and nothing for the reader: an error string on a page is how +// a store path and a dolt internal end up in a browser (sr-ht-dolt-7ta). +type ReadyFailure struct { + Database ReadyDatabase + Err error +} + +// ReadyView is the whole answer: the groups, what was considered, and the two +// facts a reader needs in order not to over-read it (the ceiling, and that some +// databases could not be read). +type ReadyView struct { + Groups []ReadyGroup + Total int // ready cards across every group, after filtering + Considered int // databases actually opened (or served from cache) + Capped bool // there were more candidates than Max + Max int // ReadyMaxDatabases, so the page can name the number it hit + Failed []ReadyFailure + Filter ReadyFilter + Options ReadyOptions + // Query is the request's query as parsed, carried so a link can rebuild this + // exact URL with one key replaced (web's withQuery) instead of re-listing the + // parameters it happens to know about. + Query url.Values +} + +// ReadyOptions lists the distinct values present across the ready set, so the +// filter dropdowns offer only real choices. Collected before the card filters +// narrow anything, so the options do not shrink as a filter is applied. +type ReadyOptions struct { + Assignees []string + Priorities []string +} + +// ReadyFilter is the page's filter state: ?q=, ?assignee=, ?priority= over the +// cards, and a repeatable ?db=/ over the databases. +type ReadyFilter struct { + Query string + Assignee string + Priority string + Databases []string // empty means every database the caller was given +} + +// ParseReadyFilter reads the filter out of a request query. +func ParseReadyFilter(q url.Values) ReadyFilter { + f := ReadyFilter{ + Query: strings.TrimSpace(q.Get("q")), + Assignee: strings.TrimSpace(q.Get("assignee")), + Priority: strings.TrimSpace(q.Get("priority")), + } + for _, d := range q["db"] { + if d = strings.TrimSpace(d); d != "" { + f.Databases = append(f.Databases, d) + } + } + return f +} + +// Active reports whether any filter is set (drives the "Clear" link and the +// empty-page wording). +func (f ReadyFilter) Active() bool { + return f.Query != "" || f.Assignee != "" || f.Priority != "" || len(f.Databases) > 0 +} + +// selects reports whether a database is one of the ones asked for. No ?db= at +// all means every database the caller was handed. +func (f ReadyFilter) selects(slug string) bool { + if len(f.Databases) == 0 { + return true + } + for _, d := range f.Databases { + if d == slug { + return true + } + } + return false +} + +// matches reports whether one ready card passes every set card filter. +func (f ReadyFilter) matches(c Card) bool { + if f.Assignee != "" && c.Assignee != f.Assignee { + return false + } + if f.Priority != "" && c.Priority != f.Priority { + return false + } + if f.Query != "" { + hay := strings.ToLower(c.ID + " " + c.Title) + if !strings.Contains(hay, strings.ToLower(f.Query)) { + return false + } + } + return true +} + +// ReadyCache holds one projection per database, keyed by the repository id and +// gated on the head hash. It is deliberately small: a mutex, a map, and the two +// bounds above. +// +// What it holds is the projection — the ready cards — and never an open +// session. An open store is a file handle and a memory mapping; caching those is +// the thing per-request opening exists to prevent. +// +// The zero value is not usable; call NewReadyCache. A nil *ReadyCache is a +// programming error and panics on first use rather than quietly disabling the +// bound it exists to enforce. +type ReadyCache struct { + mu sync.Mutex + entries map[int]readyEntry +} + +// readyEntry is one database's cached projection. +type readyEntry struct { + head string // the branch head the projection was read at; the gate + ref string // the branch it was read from + at time.Time // when it was stored; the TTL + beads bool // the fingerprint held — a false entry is a database to skip + // commit is the head commit for the group's freshness line. It cannot go + // stale under an unmoved head, so it is cached with the cards and a cache hit + // reads no log either. + commit *browse.CommitInfo + cards []Card +} + +// NewReadyCache returns an empty cache. +func NewReadyCache() *ReadyCache { + return &ReadyCache{entries: map[int]readyEntry{}} +} + +// lookup returns the cached projection for a database when it was read at the +// same head and has not expired. Both conditions, not either: the head hash is +// what makes it correct, the TTL is what makes it bounded. +func (c *ReadyCache) lookup(id int, head string, now time.Time) (readyEntry, bool) { + if head == "" { + // A database whose head cannot be named cannot be gated on one. + return readyEntry{}, false + } + c.mu.Lock() + defer c.mu.Unlock() + e, ok := c.entries[id] + if !ok || e.head != head || now.Sub(e.at) >= ReadyCacheTTL { + return readyEntry{}, false + } + return e, true +} + +// store records a projection, dropping expired entries — and, if that was not +// enough, everything — when the map is at its ceiling. +func (c *ReadyCache) store(id int, e readyEntry) { + if e.head == "" { + return + } + c.mu.Lock() + defer c.mu.Unlock() + if len(c.entries) >= readyCacheMaxEntries { + for k, old := range c.entries { + if e.at.Sub(old.at) >= ReadyCacheTTL { + delete(c.entries, k) + } + } + if len(c.entries) >= readyCacheMaxEntries { + c.entries = make(map[int]readyEntry, readyCacheMaxEntries) + } + } + c.entries[id] = e +} + +// ReadyAcross collects the ready set of every database it is handed, grouped by +// database: groups ordered by ready count desc then name, cards inside a group +// by priority then id, and a database with no ready work absent rather than +// shown empty. +// +// now is the clock the TTL is measured against, passed in rather than read here +// for the reason BuildMemories takes one: this package reads no hidden clock, +// and a caller that pins its own gets a deterministic answer. +// +// It returns no error. One database that cannot be opened or read costs itself +// only — it lands in Failed for the caller's log — because a page that 500s +// because the seventeenth store is corrupt answers nothing about the other +// sixteen. +func ReadyAcross( + ctx context.Context, + dbs []ReadyDatabase, + open ReadyOpener, + cache *ReadyCache, + filter ReadyFilter, + now time.Time, +) *ReadyView { + view := &ReadyView{Filter: filter, Max: ReadyMaxDatabases} + + // ?db= narrows the candidates before the ceiling applies: a database the + // caller named is the one thing the cap must not be able to drop. + candidates := make([]ReadyDatabase, 0, len(dbs)) + for _, d := range dbs { + if filter.selects(d.Slug()) { + candidates = append(candidates, d) + } + } + if len(candidates) > ReadyMaxDatabases { + // The first Max in the order the caller listed them, which is the caller's + // own ordering (newest first, as the listing produces it) and stable + // across requests. + candidates = candidates[:ReadyMaxDatabases] + view.Capped = true + } + view.Considered = len(candidates) + + assignees, priorities := map[string]bool{}, map[string]bool{} + for _, d := range candidates { + entry, err := readyProjection(ctx, d, open, cache, now) + if err != nil { + view.Failed = append(view.Failed, ReadyFailure{Database: d, Err: err}) + continue + } + if !entry.beads { + // Not a beads database (or a store with no branches at all): skipped + // silently, exactly as the view tabs skip it. + continue + } + cards := make([]Card, 0, len(entry.cards)) + for _, c := range entry.cards { + if c.Assignee != "" { + assignees[c.Assignee] = true + } + if c.Priority != "" { + priorities[c.Priority] = true + } + if filter.matches(c) { + cards = append(cards, c) + } + } + if len(cards) == 0 { + // Nothing ready here: absent from the page rather than an empty group. + continue + } + view.Groups = append(view.Groups, ReadyGroup{ + Database: d, + Ref: entry.ref, + Head: entry.commit, + Cards: cards, + }) + view.Total += len(cards) + } + + sort.SliceStable(view.Groups, func(i, j int) bool { + ni, nj := len(view.Groups[i].Cards), len(view.Groups[j].Cards) + if ni != nj { + return ni > nj + } + return view.Groups[i].Database.Slug() < view.Groups[j].Database.Slug() + }) + view.Options = ReadyOptions{ + Assignees: sortedKeys(assignees), + Priorities: sortedKeys(priorities), // single digits sort numerically as strings + } + return view +} + +// readyProjection returns one database's ready cards, from the cache when the +// head has not moved and by reading the store otherwise. +// +// The order is the whole point of the gate: open, list branches, and only then +// consult the cache. Opening a session and reading the branch list is cheap; +// reading and projecting issues + dependencies is not, and on a hit neither the +// tables, nor the rows, nor the log are touched. +func readyProjection( + ctx context.Context, + d ReadyDatabase, + open ReadyOpener, + cache *ReadyCache, + now time.Time, +) (readyEntry, error) { + sess, err := open(ctx, d) + if err != nil { + return readyEntry{}, err + } + defer sess.Close() + + branches, err := sess.Branches(ctx) + if err != nil { + return readyEntry{}, err + } + ref := browse.DefaultBranch(branches) + if ref == "" { + // A store with no branches carries no tables either: nothing to skip past + // and nothing to cache. + return readyEntry{}, nil + } + head := headHashOf(branches, ref) + if e, ok := cache.lookup(d.ID, head, now); ok { + return e, nil + } + + tables, err := sess.Tables(ctx, ref) + if err != nil { + return readyEntry{}, err + } + entry := readyEntry{head: head, ref: ref, at: now, beads: Applies(tables)} + if !entry.beads { + // Cached too: a database that is not a tracker is not one on the next + // request either, and the fingerprint is worth exactly one table listing. + cache.store(d.ID, entry) + return entry, nil + } + + cards, err := readyCards(ctx, sess, ref) + if err != nil { + return readyEntry{}, err + } + entry.cards = cards + entry.commit = readyHead(ctx, sess, ref) + cache.store(d.ID, entry) + return entry, nil +} + +// headHashOf returns the head hash of the named branch, or "" when the branch +// list does not carry it. An empty hash disables the cache for that database +// rather than letting an entry stand un-gated. +func headHashOf(branches []browse.Branch, ref string) string { + for _, b := range branches { + if b.Name == ref { + return b.Head + } + } + return "" +} + +// readyHead reads the head commit of ref for the group's freshness line, and +// returns nil rather than an error on both failure arms. "Ready" read from a +// store that stopped receiving pushes is exactly the claim this page must not +// make silently — but a log that cannot be read may not be the reason the ready +// set is withheld either, so the line is simply absent (the partial renders +// nothing for nil). +func readyHead(ctx context.Context, sess ReadySession, ref string) *browse.CommitInfo { + commits, _, err := sess.Log(ctx, ref, "", 1) + if err != nil || len(commits) == 0 { + return nil + } + return &commits[0] +} + +// readyCards projects one database's ready set: the same tables the board reads, +// through the same ready rule (readyRow), sorted priority then id. +func readyCards(ctx context.Context, sess BrowseSession, ref string) ([]Card, error) { + issues, _, err := readRows(ctx, sess, ref, "issues") + if err != nil { + return nil, err + } + deps, _, err := readRows(ctx, sess, ref, "dependencies") + if err != nil { + return nil, err + } + labels, _, _ := readRowsOptional(ctx, sess, ref, "labels") + statuses, _, _ := readRowsOptional(ctx, sess, ref, "custom_statuses") + + catByStatus := indexStatusCategories(statuses) + issueCols := indexCols(issues.Columns) + catByIssue := indexIssueCategories(issues, issueCols, catByStatus) + depIdx := indexDeps(deps, catByIssue) + labelsByIssue := indexLabels(labels) + + var cards []Card + for _, r := range issues.Rows { + id := cell(issueCols, r, "id") + cat := catByIssue[id] + blocked := truthy(cell(issueCols, r, "is_blocked")) || depIdx.blockedOpen[id] + if !readyRow(cat, blocked, r, issueCols) { + continue + } + cards = append(cards, Card{ + ID: id, + Title: cell(issueCols, r, "title"), + Type: cell(issueCols, r, "issue_type"), + Priority: cell(issueCols, r, "priority"), + Assignee: cell(issueCols, r, "assignee"), + Labels: labelsByIssue[id], + BlockedBy: depIdx.blockedByCount[id], + Blocks: depIdx.blocksCount[id], + Ready: true, + Category: cat, + }) + } + sortReadyCards(cards) + return cards, nil +} + +// sortReadyCards orders a group by priority (0 = highest first), then id. The +// board sorts by created_at between the two; here the id is the tie-break, +// because across databases the created_at of one tracker says nothing about the +// order of another's. +func sortReadyCards(cards []Card) { + sort.SliceStable(cards, func(i, j int) bool { + pi, pj := priorityRank(cards[i].Priority), priorityRank(cards[j].Priority) + if pi != pj { + return pi < pj + } + return cards[i].ID < cards[j].ID + }) +} diff --git a/beads/ready_test.go b/beads/ready_test.go new file mode 100644 index 0000000000000000000000000000000000000000..725ee6427f02176b04fc98ef474d32ed029fe6b6 --- /dev/null +++ b/beads/ready_test.go @@ -0,0 +1,477 @@ +package beads + +import ( + "context" + "errors" + "fmt" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "sourcecraft.dev/bigbes/sr-ht-dolt/browse" +) + +// --- fixtures ---------------------------------------------------------------- + +// readyNow is the instant the aggregation's clock is pinned to. Nothing about it +// is special beyond being fixed: the TTL is a function of the fixture rather +// than of when the suite ran. +var readyNow = time.Date(2026, 8, 12, 12, 0, 0, 0, time.UTC) + +// fakeReadyDB is one database behind the ReadyOpener seam, counting what each +// call actually read. The counters are the point: the head-hash gate is a claim +// about reads not happening, and only a counter can check it — a timing +// measurement would pass on a fast machine whatever the code did. +type fakeReadyDB struct { + branches []browse.Branch + tables []browse.TableInfo + rows map[string]*browse.RowPage + commits []browse.CommitInfo + + openErr error + branchesErr error + logErr error + + opens int + closes int + rowReads int + tableReads int + logReads int +} + +func (f *fakeReadyDB) Branches(context.Context) ([]browse.Branch, error) { + if f.branchesErr != nil { + return nil, f.branchesErr + } + return f.branches, nil +} + +func (f *fakeReadyDB) Tables(context.Context, string) ([]browse.TableInfo, error) { + f.tableReads++ + return f.tables, nil +} + +func (f *fakeReadyDB) Log(_ context.Context, _, _ string, _ int) ([]browse.CommitInfo, string, error) { + f.logReads++ + if f.logErr != nil { + return nil, "", f.logErr + } + return f.commits, "", nil +} + +func (f *fakeReadyDB) Rows(_ context.Context, _, table string, _, _ int) (*browse.RowPage, error) { + f.rowReads++ + if p, ok := f.rows[table]; ok { + return p, nil + } + return nil, fmt.Errorf("%w: %s", browse.ErrTableNotFound, table) +} + +func (f *fakeReadyDB) Close() error { f.closes++; return nil } + +// readyInstance is a set of databases addressed by repository id, plus the +// opener over them. +type readyInstance struct { + dbs map[int]*fakeReadyDB +} + +func (in *readyInstance) open(_ context.Context, d ReadyDatabase) (ReadySession, error) { + f, ok := in.dbs[d.ID] + if !ok { + return nil, fmt.Errorf("no such database: %s", d.Slug()) + } + if f.openErr != nil { + return nil, f.openErr + } + f.opens++ + return f, nil +} + +// readyTracker builds a beads database whose issues page carries the given rows. +// The column order puts is_blocked, is_template and ephemeral last so the +// projection's name-based (not positional) cell mapping is exercised. +func readyTracker(head string, issues [][]string, deps [][]string) *fakeReadyDB { + return &fakeReadyDB{ + branches: []browse.Branch{{Name: "main", Head: head}}, + tables: beadsTables(), + commits: []browse.CommitInfo{{ + Hash: head, Author: "Eugene Blikh", Date: readyNow.Add(-4 * time.Minute), + Message: "bd: create (auto-commit)", + }}, + rows: map[string]*browse.RowPage{ + "issues": { + Columns: []string{"id", "title", "status", "priority", "issue_type", "assignee", "is_blocked", "is_template", "ephemeral"}, + Rows: issues, + Total: len(issues), + }, + "dependencies": { + Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"}, + Rows: deps, + Total: len(deps), + }, + }, + } +} + +// readyFixture is two trackers: one with three ready issues among six, one with +// a single ready issue. +func readyFixture() (*readyInstance, []ReadyDatabase) { + alpha := readyTracker("h-alpha", [][]string{ + {"a-1", "Ready, top priority", "open", "0", "task", "alice", "0", "0", "0"}, + {"a-2", "Ready, lower", "open", "2", "bug", "bob", "0", "0", "0"}, + {"a-3", "Ready, same priority as a-2", "open", "2", "task", "alice", "0", "0", "0"}, + {"a-4", "In progress, not ready", "in_progress", "0", "task", "alice", "0", "0", "0"}, + {"a-5", "Blocked, not ready", "open", "0", "task", "bob", "1", "0", "0"}, + {"a-6", "Closed, not ready", "closed", "0", "task", "bob", "0", "0", "0"}, + }, nil) + beta := readyTracker("h-beta", [][]string{ + {"b-1", "The one ready thing", "open", "1", "task", "carol", "0", "0", "0"}, + {"b-2", "A template scaffold", "open", "0", "task", "carol", "0", "1", "0"}, + {"b-3", "An ephemeral scratch", "open", "0", "task", "carol", "0", "0", "1"}, + }, nil) + in := &readyInstance{dbs: map[int]*fakeReadyDB{1: alpha, 2: beta}} + return in, []ReadyDatabase{ + {ID: 1, OwnerName: "alice", Name: "alpha"}, + {ID: 2, OwnerName: "bob", Name: "beta"}, + } +} + +func across(in *readyInstance, dbs []ReadyDatabase, cache *ReadyCache, f ReadyFilter, now time.Time) *ReadyView { + return ReadyAcross(context.Background(), dbs, in.open, cache, f, now) +} + +// --- tests ------------------------------------------------------------------- + +// The projection: which issues are ready, how the groups are ordered, how the +// cards inside one are, and that each group carries its own database's head. +func TestReadyAcrossGroupsAndOrders(t *testing.T) { + in, dbs := readyFixture() + + view := across(in, dbs, NewReadyCache(), ReadyFilter{}, readyNow) + + require.Len(t, view.Groups, 2) + assert.Equal(t, 4, view.Total) + assert.Equal(t, 2, view.Considered) + assert.False(t, view.Capped) + assert.Empty(t, view.Failed) + + // Groups: ready count desc, then name. + assert.Equal(t, "alice/alpha", view.Groups[0].Database.Slug()) + assert.Equal(t, "bob/beta", view.Groups[1].Database.Slug()) + + // Cards: priority, then id. In-progress, blocked, closed, template and + // ephemeral issues are not ready. + var ids []string + for _, c := range view.Groups[0].Cards { + ids = append(ids, c.ID) + assert.True(t, c.Ready, "%s must be marked ready", c.ID) + } + assert.Equal(t, []string{"a-1", "a-2", "a-3"}, ids) + require.Len(t, view.Groups[1].Cards, 1) + assert.Equal(t, "b-1", view.Groups[1].Cards[0].ID) + + // Each group carries its own database's head, so "ready" from a store that + // stopped receiving pushes is not claimed silently. + require.NotNil(t, view.Groups[0].Head) + assert.Equal(t, "h-alpha", view.Groups[0].Head.Hash) + assert.Equal(t, "main", view.Groups[0].Ref) + require.NotNil(t, view.Groups[1].Head) + assert.Equal(t, "h-beta", view.Groups[1].Head.Hash) + + // The filter options come from the whole ready set. + assert.Equal(t, []string{"alice", "bob", "carol"}, view.Options.Assignees) + assert.Equal(t, []string{"0", "1", "2"}, view.Options.Priorities) + + // Every session opened is closed again: this page opens N stores, and one + // leaked handle per request per database is the failure mode it is bounded + // against. + for id, f := range in.dbs { + assert.Equal(t, f.opens, f.closes, "database %d: %d opens, %d closes", id, f.opens, f.closes) + } +} + +// A "blocks" edge to a still-open target blocks its source; the same edge to a +// closed one does not. This is the ready rule the board shares, exercised +// through the aggregation rather than restated in it. +func TestReadyAcrossHonoursOpenBlockers(t *testing.T) { + tracker := readyTracker("h1", [][]string{ + {"i-1", "Blocked by an open issue", "open", "1", "task", "alice", "0", "0", "0"}, + {"i-2", "The open blocker", "open", "1", "task", "alice", "0", "0", "0"}, + {"i-3", "Blocked by a closed issue", "open", "1", "task", "alice", "0", "0", "0"}, + {"i-4", "The closed blocker", "closed", "1", "task", "alice", "0", "0", "0"}, + {"i-5", "A subtask of an open epic", "open", "1", "task", "alice", "0", "0", "0"}, + {"i-6", "The epic", "open", "1", "epic", "alice", "0", "0", "0"}, + }, [][]string{ + {"d1", "i-1", "i-2", "blocks"}, + {"d2", "i-3", "i-4", "blocks"}, + {"d3", "i-5", "i-6", "parent-child"}, + }) + in := &readyInstance{dbs: map[int]*fakeReadyDB{1: tracker}} + dbs := []ReadyDatabase{{ID: 1, OwnerName: "alice", Name: "alpha"}} + + view := across(in, dbs, NewReadyCache(), ReadyFilter{}, readyNow) + + require.Len(t, view.Groups, 1) + var ids []string + for _, c := range view.Groups[0].Cards { + ids = append(ids, c.ID) + } + // i-1 waits on an open issue; everything else is actionable — including the + // subtask, because parent-child is hierarchy and not a blocker. + assert.Equal(t, []string{"i-2", "i-3", "i-5", "i-6"}, ids) +} + +// The head-hash gate: a second call with unmoved heads reads no rows, no tables +// and no log. Asserted by counting reads on the fake, never by timing. +func TestReadyAcrossHeadHashGateSkipsEveryRead(t *testing.T) { + in, dbs := readyFixture() + cache := NewReadyCache() + + first := across(in, dbs, cache, ReadyFilter{}, readyNow) + require.Len(t, first.Groups, 2) + alpha := in.dbs[1] + firstReads := alpha.rowReads + require.Greater(t, firstReads, 0, "the first call must read rows") + + second := across(in, dbs, cache, ReadyFilter{}, readyNow.Add(30*time.Second)) + + assert.Equal(t, firstReads, alpha.rowReads, "a second call with an unmoved head must read no rows") + assert.Equal(t, 1, alpha.tableReads, "nor list tables again") + assert.Equal(t, 1, alpha.logReads, "nor read the log again") + // The session is still opened and its branches listed — that is what the gate + // is gated on, and it is the cheap half. + assert.Equal(t, 2, alpha.opens) + assert.Equal(t, 2, alpha.closes) + // And the answer is the same one. + assert.Equal(t, first.Total, second.Total) + require.Len(t, second.Groups, 2) + assert.Equal(t, first.Groups[0].Cards, second.Groups[0].Cards) + require.NotNil(t, second.Groups[0].Head) + assert.Equal(t, "h-alpha", second.Groups[0].Head.Hash) +} + +// A head that moved is a projection that no longer stands: the rows are read +// again even well inside the TTL. +func TestReadyAcrossMovedHeadForcesAReread(t *testing.T) { + in, dbs := readyFixture() + cache := NewReadyCache() + alpha := in.dbs[1] + + across(in, dbs, cache, ReadyFilter{}, readyNow) + firstReads := alpha.rowReads + + alpha.branches = []browse.Branch{{Name: "main", Head: "h-alpha-2"}} + across(in, dbs, cache, ReadyFilter{}, readyNow.Add(time.Second)) + + assert.Greater(t, alpha.rowReads, firstReads, "a moved head must be re-read") + // The other database's head did not move, so it was not re-read. + assert.Equal(t, 1, in.dbs[2].tableReads) +} + +// The TTL: past it the projection is re-read even though the head has not +// moved, so a cache can never be the reason a reader sees yesterday's answer. +func TestReadyAcrossTTLExpiryForcesAReread(t *testing.T) { + in, dbs := readyFixture() + cache := NewReadyCache() + alpha := in.dbs[1] + + across(in, dbs, cache, ReadyFilter{}, readyNow) + firstReads := alpha.rowReads + + // One tick short of the TTL: still cached. + across(in, dbs, cache, ReadyFilter{}, readyNow.Add(ReadyCacheTTL-time.Nanosecond)) + assert.Equal(t, firstReads, alpha.rowReads, "inside the TTL the projection stands") + + // At the TTL: read again. + across(in, dbs, cache, ReadyFilter{}, readyNow.Add(ReadyCacheTTL)) + assert.Greater(t, alpha.rowReads, firstReads, "at the TTL the projection is re-read") +} + +// The ceiling: more candidates than ReadyMaxDatabases and the view says it was +// capped, having opened exactly the ceiling's worth of stores. +func TestReadyAcrossCeiling(t *testing.T) { + in := &readyInstance{dbs: map[int]*fakeReadyDB{}} + var dbs []ReadyDatabase + for i := 1; i <= ReadyMaxDatabases+3; i++ { + in.dbs[i] = readyTracker(fmt.Sprintf("h%d", i), [][]string{ + {fmt.Sprintf("t%d-1", i), "Ready", "open", "1", "task", "alice", "0", "0", "0"}, + }, nil) + dbs = append(dbs, ReadyDatabase{ID: i, OwnerName: "alice", Name: fmt.Sprintf("db%02d", i)}) + } + + view := across(in, dbs, NewReadyCache(), ReadyFilter{}, readyNow) + + assert.True(t, view.Capped, "a page that hit the ceiling must say so") + assert.Equal(t, ReadyMaxDatabases, view.Considered) + assert.Equal(t, ReadyMaxDatabases, view.Max) + assert.Len(t, view.Groups, ReadyMaxDatabases) + // The three past the ceiling were never opened. + for i := ReadyMaxDatabases + 1; i <= ReadyMaxDatabases+3; i++ { + assert.Zero(t, in.dbs[i].opens, "database %d is past the ceiling", i) + } +} + +// A database named by ?db= is never dropped by the ceiling: the filter narrows +// the candidates first. +func TestReadyAcrossCeilingAppliesAfterTheDatabaseFilter(t *testing.T) { + in := &readyInstance{dbs: map[int]*fakeReadyDB{}} + var dbs []ReadyDatabase + for i := 1; i <= ReadyMaxDatabases+3; i++ { + in.dbs[i] = readyTracker(fmt.Sprintf("h%d", i), [][]string{ + {fmt.Sprintf("t%d-1", i), "Ready", "open", "1", "task", "alice", "0", "0", "0"}, + }, nil) + dbs = append(dbs, ReadyDatabase{ID: i, OwnerName: "alice", Name: fmt.Sprintf("db%02d", i)}) + } + last := dbs[len(dbs)-1] + + view := across(in, dbs, NewReadyCache(), ReadyFilter{Databases: []string{last.Slug()}}, readyNow) + + assert.False(t, view.Capped) + assert.Equal(t, 1, view.Considered) + require.Len(t, view.Groups, 1) + assert.Equal(t, last.Slug(), view.Groups[0].Database.Slug()) +} + +// A database that cannot be opened, or whose rows cannot be read, costs itself +// only. The error is carried out for the caller's log and the rest of the answer +// stands. +func TestReadyAcrossFailingDatabaseCostsItselfOnly(t *testing.T) { + in, dbs := readyFixture() + in.dbs[1].openErr = errors.New("browse: open store: no such file or directory") + + view := across(in, dbs, NewReadyCache(), ReadyFilter{}, readyNow) + + require.Len(t, view.Failed, 1) + assert.Equal(t, "alice/alpha", view.Failed[0].Database.Slug()) + assert.ErrorContains(t, view.Failed[0].Err, "no such file") + require.Len(t, view.Groups, 1) + assert.Equal(t, "bob/beta", view.Groups[0].Database.Slug()) + + // A store that opens but cannot list its branches fails the same way. + in2, dbs2 := readyFixture() + in2.dbs[1].branchesErr = errors.New("browse: list branches: corrupt chunk") + view2 := across(in2, dbs2, NewReadyCache(), ReadyFilter{}, readyNow) + require.Len(t, view2.Failed, 1) + require.Len(t, view2.Groups, 1) + assert.Equal(t, 1, in2.dbs[1].closes, "a failing database still closes its session") +} + +// A database whose tables are not a beads schema is skipped silently: no group, +// no failure, and no second table listing on the next request. +func TestReadyAcrossSkipsNonBeadsDatabases(t *testing.T) { + in, dbs := readyFixture() + plain := &fakeReadyDB{ + branches: []browse.Branch{{Name: "main", Head: "h-plain"}}, + tables: []browse.TableInfo{{Name: "measurements", Columns: []browse.ColumnInfo{{Name: "id"}}}}, + } + in.dbs[3] = plain + dbs = append(dbs, ReadyDatabase{ID: 3, OwnerName: "carol", Name: "sensors"}) + cache := NewReadyCache() + + view := across(in, dbs, cache, ReadyFilter{}, readyNow) + assert.Len(t, view.Groups, 2) + assert.Empty(t, view.Failed) + assert.Zero(t, plain.rowReads, "a non-beads database is never read for rows") + + across(in, dbs, cache, ReadyFilter{}, readyNow.Add(time.Second)) + assert.Equal(t, 1, plain.tableReads, "the fingerprint is worth one table listing") +} + +// A store with no branches at all — freshly initialised, never pushed to — is +// skipped rather than failing the page. +func TestReadyAcrossSkipsAnEmptyStore(t *testing.T) { + in := &readyInstance{dbs: map[int]*fakeReadyDB{1: {}}} + dbs := []ReadyDatabase{{ID: 1, OwnerName: "alice", Name: "fresh"}} + + view := across(in, dbs, NewReadyCache(), ReadyFilter{}, readyNow) + + assert.Empty(t, view.Groups) + assert.Empty(t, view.Failed) + assert.Equal(t, 1, view.Considered) +} + +// A log that cannot be read costs the group its freshness line and nothing else. +func TestReadyAcrossSurvivesAnUnreadableLog(t *testing.T) { + in, dbs := readyFixture() + in.dbs[1].logErr = errors.New("browse: walk commits: corrupt chunk") + + view := across(in, dbs, NewReadyCache(), ReadyFilter{}, readyNow) + + require.Len(t, view.Groups, 2) + assert.Nil(t, view.Groups[0].Head, "no head is renderable") + assert.Len(t, view.Groups[0].Cards, 3, "the ready set is still answered") +} + +func TestReadyFilters(t *testing.T) { + in, dbs := readyFixture() + + t.Run("q narrows over id and title", func(t *testing.T) { + view := across(in, dbs, NewReadyCache(), ReadyFilter{Query: "TOP priority"}, readyNow) + require.Len(t, view.Groups, 1) + require.Len(t, view.Groups[0].Cards, 1) + assert.Equal(t, "a-1", view.Groups[0].Cards[0].ID) + assert.Equal(t, 1, view.Total) + }) + + t.Run("assignee is exact", func(t *testing.T) { + view := across(in, dbs, NewReadyCache(), ReadyFilter{Assignee: "alice"}, readyNow) + require.Len(t, view.Groups, 1, "only alpha has alice's ready work") + assert.Equal(t, "alice/alpha", view.Groups[0].Database.Slug()) + assert.Equal(t, 2, view.Total) + }) + + t.Run("priority is exact", func(t *testing.T) { + view := across(in, dbs, NewReadyCache(), ReadyFilter{Priority: "2"}, readyNow) + require.Len(t, view.Groups, 1) + assert.Equal(t, 2, view.Total) + }) + + t.Run("db selects databases", func(t *testing.T) { + // Its own fixture: this one asserts that a database was never opened, and + // the shared one has been opened by every subtest above. + in, dbs := readyFixture() + view := across(in, dbs, NewReadyCache(), ReadyFilter{Databases: []string{"bob/beta"}}, readyNow) + require.Len(t, view.Groups, 1) + assert.Equal(t, "bob/beta", view.Groups[0].Database.Slug()) + assert.Equal(t, 1, view.Considered, "the other database is never opened") + assert.Zero(t, in.dbs[1].opens) + }) + + t.Run("a filter that matches nothing empties the page", func(t *testing.T) { + view := across(in, dbs, NewReadyCache(), ReadyFilter{Assignee: "nobody"}, readyNow) + assert.Empty(t, view.Groups) + assert.Zero(t, view.Total) + // The options still list the real values, so the reader can pick another. + assert.Equal(t, []string{"alice", "bob", "carol"}, view.Options.Assignees) + }) +} + +func TestParseReadyFilter(t *testing.T) { + q, err := url.ParseQuery("q=+cache+&assignee=alice&priority=1&db=alice%2Falpha&db=+&db=bob%2Fbeta") + require.NoError(t, err) + + f := ParseReadyFilter(q) + + assert.Equal(t, "cache", f.Query) + assert.Equal(t, "alice", f.Assignee) + assert.Equal(t, "1", f.Priority) + assert.Equal(t, []string{"alice/alpha", "bob/beta"}, f.Databases, "a blank ?db= is not a database") + assert.True(t, f.Active()) + assert.False(t, ParseReadyFilter(url.Values{}).Active()) +} + +// The cache is bounded by entry count: it is a cache and not a store, and an +// instance with more databases than the ceiling must not grow it without end. +func TestReadyCacheIsBoundedByEntryCount(t *testing.T) { + cache := NewReadyCache() + for i := 1; i <= readyCacheMaxEntries+10; i++ { + cache.store(i, readyEntry{head: fmt.Sprintf("h%d", i), at: readyNow, beads: true}) + } + cache.mu.Lock() + n := len(cache.entries) + cache.mu.Unlock() + assert.LessOrEqual(t, n, readyCacheMaxEntries) +} diff --git a/beads/rows.go b/beads/rows.go index 4d15489fdbf705ae9f68a19b90e366931b1d6dec..54eef8de7ece7e248fae2aa32152a1da03606d64 100644 --- a/beads/rows.go +++ b/beads/rows.go @@ -57,6 +57,105 @@ func cell(cols map[string]int, row []string, name string) string { return v } +// indexStatusCategories maps a status name (lowercased) to its category, from +// the optional custom_statuses table. A nil page — the table is absent — yields +// an empty map, and statusCategory then falls back to its name heuristics. +func indexStatusCategories(statuses *browse.RowPage) map[string]string { + out := map[string]string{} + if statuses == nil { + return out + } + cols := indexCols(statuses.Columns) + for _, r := range statuses.Rows { + if name := cell(cols, r, "name"); name != "" { + out[strings.ToLower(name)] = strings.ToLower(cell(cols, r, "category")) + } + } + return out +} + +// indexLabels maps issue id → its label names, from the optional labels table. +// A nil page yields an empty map. +func indexLabels(labels *browse.RowPage) map[string][]string { + out := map[string][]string{} + if labels == nil { + return out + } + cols := indexCols(labels.Columns) + for _, r := range labels.Rows { + id := cell(cols, r, "issue_id") + lb := cell(cols, r, "label") + if id != "" && lb != "" { + out[id] = append(out[id], lb) + } + } + return out +} + +// indexIssueCategories maps issue id → status category (open / in_progress / +// closed), which is what decides whether a blocking target still blocks. +func indexIssueCategories(issues *browse.RowPage, cols map[string]int, catByStatus map[string]string) map[string]string { + out := make(map[string]string, len(issues.Rows)) + for _, r := range issues.Rows { + out[cell(cols, r, "id")] = statusCategory(cell(cols, r, "status"), catByStatus) + } + return out +} + +// depIndex is the dependency edge set aggregated per issue: how many things an +// issue waits on, how many wait on it, and whether any of the things it waits on +// is still open (which is what "blocked" means). +type depIndex struct { + blockedByCount map[string]int // issue_id → #deps it has + blocksCount map[string]int // depends_on_issue_id → #deps aimed at it + blockedOpen map[string]bool // issue_id → has an open blocking dep +} + +// indexDeps aggregates the dependencies table. A "blocks" edge to a still-open +// target blocks its source; parent-child is hierarchy, not a blocker — a subtask +// is not blocked by its (open) epic, matching bd's own is_blocked/ready +// accounting. A nil page (the table is absent) yields empty maps. +func indexDeps(deps *browse.RowPage, catByIssue map[string]string) depIndex { + idx := depIndex{ + blockedByCount: map[string]int{}, + blocksCount: map[string]int{}, + blockedOpen: map[string]bool{}, + } + if deps == nil { + return idx + } + cols := indexCols(deps.Columns) + for _, r := range deps.Rows { + from := cell(cols, r, "issue_id") + to := cell(cols, r, "depends_on_issue_id") + typ := strings.ToLower(cell(cols, r, "type")) + if from != "" { + idx.blockedByCount[from]++ + } + if to != "" { + idx.blocksCount[to]++ + } + if from != "" && typ == "blocks" && catByIssue[to] != "closed" { + idx.blockedOpen[from] = true + } + } + return idx +} + +// readyRow is bd's ready rule, and the only copy of it: an issue is ready when +// it is open (not in-progress, not closed), unblocked, and not a +// template/ephemeral scaffold. Derived in-process from the issues rows already +// loaded rather than by reading the ready_issues table. +// +// Every surface that says "ready" — the board's ⚡ marker and its ?ready=1 +// filter, the cross-database /ready page, the MCP ready_work tool — comes +// through here, because two surfaces that each spelled the rule out would +// disagree the first time it changed. +func readyRow(cat string, blocked bool, row []string, cols map[string]int) bool { + return cat == "open" && !blocked && + !truthy(cell(cols, row, "is_template")) && !truthy(cell(cols, row, "ephemeral")) +} + // truthy reports whether a cell reads as a set boolean/flag. func truthy(s string) bool { switch strings.ToLower(strings.TrimSpace(s)) { diff --git a/web/adapters.go b/web/adapters.go index 0fd8da904da344c9c00938c260591317a5a459c0..e6f3a697af7794012fbf4ebf845d7e38c83322ef 100644 --- a/web/adapters.go +++ b/web/adapters.go @@ -39,6 +39,10 @@ func (DBAdapter) ListReposByOwner(ctx context.Context, owner string, viewer *cor return db.FromContext(ctx).ListReposByOwner(ctx, owner, viewer) } +func (DBAdapter) ListReposForViewer(ctx context.Context, viewer *core.Caller) ([]*core.Repo, error) { + return db.FromContext(ctx).ListReposForViewer(ctx, viewer) +} + func (DBAdapter) ListReposForDashboard(ctx context.Context, userID int) ([]*core.Repo, error) { return db.FromContext(ctx).ListReposForDashboard(ctx, userID) } diff --git a/web/deps.go b/web/deps.go index b3ff0d7e0f187063286681f53e94aa1d935a2a28..5731336fb41513fe559aba4901c02222c769f314 100644 --- a/web/deps.go +++ b/web/deps.go @@ -101,6 +101,13 @@ type RepoStore interface { CreateRepo(ctx context.Context, r *core.Repo) (*core.Repo, error) GetRepoByOwnerAndName(ctx context.Context, ownerUsername, name string) (*core.Repo, error) ListReposByOwner(ctx context.Context, ownerUsername string, viewer *core.Caller) ([]*core.Repo, error) + // ListReposForViewer lists every database viewer may be shown, across all + // owners. It is the enumeration the cross-database ready page is built on: + // ListReposByOwner asks the same listing question about one owner, and + // ListReposForDashboard omits every PUBLIC database belonging to somebody + // else. Listing is not authorization — /ready still asks core.Allowed per + // database before it opens anything. + ListReposForViewer(ctx context.Context, viewer *core.Caller) ([]*core.Repo, error) ListReposForDashboard(ctx context.Context, userID int) ([]*core.Repo, error) UpdateRepo(ctx context.Context, id int, description string, visibility core.Visibility) error DeleteRepo(ctx context.Context, id int) error diff --git a/web/handlers_ready.go b/web/handlers_ready.go new file mode 100644 index 0000000000000000000000000000000000000000..a8eda0e08a60827f70a12320a138f7128f572693 --- /dev/null +++ b/web/handlers_ready.go @@ -0,0 +1,90 @@ +package web + +import ( + "context" + "fmt" + "log/slog" + "net/http" + + "go.bigb.es/auxilia/scribe" + + "sourcecraft.dev/bigbes/sr-ht-ecore/chrome" + + "sourcecraft.dev/bigbes/sr-ht-dolt/beads" + "sourcecraft.dev/bigbes/sr-ht-dolt/core" +) + +// handleReady renders the cross-database ready page: for every database this +// caller may browse, the issues that are open, unblocked and not scaffolding, +// grouped by database. +// +// It is the one page here that reads more than one database, and the three +// things that keeps affordable live in beads: the head-hash gate over the app's +// projection cache, the 60-second TTL, and the ceiling on how many databases one +// request opens. This handler's own job is the other half — deciding which +// databases the caller may see at all, and doing it before a single store is +// opened. +// +// Visibility is enumerated with ListReposForViewer (the listing rule) and then +// re-asked per database with core.Allowed/OpBrowse (the access rule). A database +// the caller may not browse is simply absent: not a 403, not a count, not a +// group with a hidden name — nothing on the page may hint that it exists. +func (a *app) handleReady(w http.ResponseWriter, r *http.Request) { + _, caller := callerOf(r.Context()) + + repos, err := a.cfg.Repos.ListReposForViewer(r.Context(), caller) + if err != nil { + slog.Error("listing databases for the ready page failed", + "component", "web", scribe.Err(err)) + a.fail(w, r, http.StatusInternalServerError, "") + return + } + + // The disk path never reaches beads: it is this service's arrangement of its + // own storage, and the aggregation addresses a database by the identity the + // cache is keyed on. The opener closes over the map, so a database that was + // filtered out of the candidate list has no path to be opened by. + paths := make(map[int]string, len(repos)) + dbs := make([]beads.ReadyDatabase, 0, len(repos)) + for _, repo := range repos { + if !core.Allowed(caller, repo, a.effectiveACL(r, caller, repo), core.OpBrowse) { + continue + } + paths[repo.ID] = repo.Path + dbs = append(dbs, beads.ReadyDatabase{ + ID: repo.ID, + OwnerName: repo.OwnerName, + Name: repo.Name, + }) + } + + open := func(ctx context.Context, d beads.ReadyDatabase) (beads.ReadySession, error) { + path, ok := paths[d.ID] + if !ok { + return nil, fmt.Errorf("web: no store path for database %s", d.Slug()) + } + return a.cfg.Browse.Open(ctx, path) + } + + data := beads.ReadyAcross(r.Context(), dbs, open, a.ready, + beads.ParseReadyFilter(r.URL.Query()), timeNow()) + data.Query = r.URL.Query() + + // A store that cannot be read is a fact about this deployment, and it belongs + // in the log with its error. The page says only how many databases it could + // not read: echoing a browse error into the response is how a store path and + // a dolt internal end up in somebody's browser (sr-ht-dolt-7ta). + for _, f := range data.Failed { + slog.Warn("reading a database for the ready page failed", + "component", "web", "database", f.Database.Slug(), scribe.Err(f.Err)) + } + + view := struct { + chrome.Page + Data *beads.ReadyView + }{ + Page: a.page(r, "Ready — "+serviceName), + Data: data, + } + a.render(w, http.StatusOK, "ready", view) +} diff --git a/web/handlers_ready_test.go b/web/handlers_ready_test.go new file mode 100644 index 0000000000000000000000000000000000000000..ed3274d96e7e7cbf9b446d755a89b676768afee5 --- /dev/null +++ b/web/handlers_ready_test.go @@ -0,0 +1,347 @@ +package web + +import ( + "errors" + "fmt" + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "sourcecraft.dev/bigbes/sr-ht-dolt/beads" + "sourcecraft.dev/bigbes/sr-ht-dolt/browse" + "sourcecraft.dev/bigbes/sr-ht-dolt/core" +) + +// The cross-database ready page. What matters here is not the projection — that +// is beads' and is tested there — but the two things only this layer can get +// wrong: which databases a caller is shown, and how many stores one request is +// allowed to open. + +// readyStore builds a fake session for one tracker: a beads schema, a head +// commit four minutes old, and the given issue rows. +func readyStore(head string, issues [][]string) *fakeSession { + return &fakeSession{ + branches: []browse.Branch{{Name: "main", Head: head}}, + tables: beadsTables(), + commits: []browse.CommitInfo{{ + Hash: head, Author: "Eugene Blikh", Date: testNow.Add(-4 * time.Minute), + Message: "bd: create (auto-commit)", + }}, + rowsByTable: map[string]*browse.RowPage{ + "issues": { + Columns: []string{"id", "title", "status", "priority", "issue_type", "assignee", "is_blocked"}, + Rows: issues, + Total: len(issues), + }, + "dependencies": { + Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"}, + Total: 0, + }, + }, + } +} + +// addTracker registers a database with the metadata store and wires its store +// path to a fake session, the way the production pair (repo.Path → +// BrowseOpener) is wired. +func addTracker(h *harness, owner string, ownerID int, name string, vis core.Visibility, sess *fakeSession) *core.Repo { + repo := h.store.add(&core.Repo{ + Name: name, OwnerID: ownerID, OwnerName: owner, + Path: "/var/lib/dolt/~" + owner + "/" + name, Visibility: vis, + }) + if h.browse.byPath == nil { + h.browse.byPath = map[string]*fakeSession{} + } + h.browse.byPath[repo.Path] = sess + return repo +} + +// readyHarness is two public trackers: alpha with two ready issues among four, +// beta with one. +func readyHarness(t *testing.T) (*harness, *fakeSession, *fakeSession) { + t.Helper() + h := newHarness(t) + alpha := readyStore("h-alpha", [][]string{ + {"a-1", "Ready, top priority", "open", "0", "task", "alice", "0"}, + {"a-2", "Ready, lower", "open", "2", "bug", "bob", "0"}, + {"a-3", "Under way", "in_progress", "0", "task", "alice", "0"}, + {"a-4", "Waiting on something", "open", "0", "task", "bob", "1"}, + }) + beta := readyStore("h-beta", [][]string{ + {"b-1", "The one ready thing", "open", "1", "task", "carol", "0"}, + {"b-2", "Finished", "closed", "0", "task", "carol", "0"}, + }) + addTracker(h, "alice", 1, "alpha", core.VisibilityPublic, alpha) + addTracker(h, "bob", 2, "beta", core.VisibilityPublic, beta) + return h, alpha, beta +} + +func TestReadyPageRendersGroupsInOrder(t *testing.T) { + pinClock(t, testNow) + h, _, _ := readyHarness(t) + + rec := h.do("GET", "/ready", nil, nil) + require.Equal(t, http.StatusOK, rec.Code, "body=%s", rec.Body.String()) + body := rec.Body.String() + + // Both databases, the busier one first, each with its own count. + assert.Contains(t, body, "~alice/alpha") + assert.Contains(t, body, "~bob/beta") + assert.Less(t, strings.Index(body, "~alice/alpha"), strings.Index(body, "~bob/beta"), + "groups are ordered by ready count desc") + assert.Contains(t, body, "2 ready") + assert.Contains(t, body, "1 ready") + assert.Contains(t, body, "3 ready issues") + + // The ready ones, linked into their own database's board. + assert.Contains(t, body, `href="/~alice/alpha/view/beads?issue=a-1"`) + assert.Contains(t, body, `href="/~alice/alpha/view/beads?issue=a-2"`) + assert.Contains(t, body, `href="/~bob/beta/view/beads?issue=b-1"`) + // …and only those: in-progress, blocked and closed issues are not ready. + assert.NotContains(t, body, "issue=a-3") + assert.NotContains(t, body, "issue=a-4") + assert.NotContains(t, body, "issue=b-2") + + // Each group carries its own database's freshness, so "ready" from a store + // that stopped receiving pushes is not claimed silently. + assert.Contains(t, body, `class="beads-freshness"`) + assert.Contains(t, body, "main · last commit") + assert.Contains(t, body, "4 minutes ago") + assert.Contains(t, body, `href="/~alice/alpha/commit/h-alpha"`) + assert.Contains(t, body, `href="/~bob/beta/commit/h-beta"`) +} + +// A PRIVATE tracker the caller may not browse is absent. Not a 403, not a +// counted-but-unnamed group: nothing on the page may hint that it exists. +func TestReadyPageHidesADatabaseTheCallerMayNotBrowse(t *testing.T) { + pinClock(t, testNow) + h, _, _ := readyHarness(t) + secret := readyStore("h-secret", [][]string{ + {"s-1", "The secret thing", "open", "0", "task", "dave", "0"}, + }) + addTracker(h, "dave", 9, "secrets", core.VisibilityPrivate, secret) + + rec := h.do("GET", "/ready", nil, nil) + require.Equal(t, http.StatusOK, rec.Code, "a hidden database is not a refusal") + body := rec.Body.String() + + assert.NotContains(t, body, "secrets") + assert.NotContains(t, body, "s-1") + assert.NotContains(t, body, "The secret thing") + assert.NotContains(t, body, "dave") + assert.NotContains(t, body, "h-secret") + assert.Zero(t, secret.opens, "a database the caller may not browse is never opened") + // The visible ones are unaffected. + assert.Contains(t, body, "~alice/alpha") + assert.Contains(t, body, "3 ready issues") + + // Its owner sees it, which is what makes the absence above a visibility rule + // and not a broken page. + owner := h.do("GET", "/ready", testCaller(9, "dave"), nil) + require.Equal(t, http.StatusOK, owner.Code) + assert.Contains(t, owner.Body.String(), "~dave/secrets") + assert.Contains(t, owner.Body.String(), "s-1") +} + +// The head-hash gate: a second request whose heads have not moved reads no rows +// at all. Counted on the fake, never timed. +func TestReadyPageHeadHashGateReadsNothingTwice(t *testing.T) { + pinClock(t, testNow) + h, alpha, beta := readyHarness(t) + + first := h.do("GET", "/ready", nil, nil) + require.Equal(t, http.StatusOK, first.Code) + reads := alpha.rowReads + require.Greater(t, reads, 0, "the first request must read rows") + + second := h.do("GET", "/ready", nil, nil) + require.Equal(t, http.StatusOK, second.Code) + + assert.Equal(t, reads, alpha.rowReads, "an unmoved head must cost no row reads") + assert.Equal(t, 1, alpha.tableReads, "nor a second table listing") + assert.Equal(t, 1, alpha.logReads, "nor a second log read") + assert.Equal(t, 1, beta.tableReads, "the sibling database is gated too") + // Both stores are still opened and their branches listed — that is what the + // gate is gated on, and it is the cheap half. + assert.Equal(t, 2, alpha.opens) + // The page is the same page, cache or not. + assert.Equal(t, first.Body.String(), second.Body.String()) +} + +// The TTL: past it the projection is read again even though nothing moved, so a +// cache can never be the reason a reader sees yesterday's answer. +func TestReadyPageTTLExpiryForcesAReread(t *testing.T) { + pinClock(t, testNow) + h, alpha, _ := readyHarness(t) + + require.Equal(t, http.StatusOK, h.do("GET", "/ready", nil, nil).Code) + reads := alpha.rowReads + + // One tick short of the TTL: still cached. + pinClock(t, testNow.Add(beads.ReadyCacheTTL-time.Second)) + require.Equal(t, http.StatusOK, h.do("GET", "/ready", nil, nil).Code) + assert.Equal(t, reads, alpha.rowReads, "inside the TTL the projection stands") + + // Past it: read again. + pinClock(t, testNow.Add(beads.ReadyCacheTTL+time.Second)) + require.Equal(t, http.StatusOK, h.do("GET", "/ready", nil, nil).Code) + assert.Greater(t, alpha.rowReads, reads, "past the TTL the projection is re-read") +} + +// The ceiling: more candidate databases than beads.ReadyMaxDatabases and the +// page says so. A silent cap reads as "that is everything". +func TestReadyPageCeilingSaysItWasCapped(t *testing.T) { + pinClock(t, testNow) + h := newHarness(t) + var overflow []*fakeSession + for i := 1; i <= beads.ReadyMaxDatabases+2; i++ { + sess := readyStore(fmt.Sprintf("h%02d", i), [][]string{ + {fmt.Sprintf("t%02d-1", i), "Ready", "open", "1", "task", "alice", "0"}, + }) + addTracker(h, "alice", 1, fmt.Sprintf("db%02d", i), core.VisibilityPublic, sess) + if i > beads.ReadyMaxDatabases { + overflow = append(overflow, sess) + } + } + + rec := h.do("GET", "/ready", nil, nil) + require.Equal(t, http.StatusOK, rec.Code) + body := rec.Body.String() + + assert.Contains(t, body, fmt.Sprintf("only the first %d were read", beads.ReadyMaxDatabases)) + assert.Contains(t, body, fmt.Sprintf("%d ready issues", beads.ReadyMaxDatabases)) + for _, sess := range overflow { + assert.Zero(t, sess.opens, "a database past the ceiling is never opened") + } +} + +// A database that cannot be opened costs itself only: the rest of the page +// renders, and the browse error reaches the log and not the reader. +func TestReadyPageSurvivesADatabaseThatCannotBeOpened(t *testing.T) { + pinClock(t, testNow) + h, _, _ := readyHarness(t) + broken := h.store.add(&core.Repo{ + Name: "broken", OwnerID: 4, OwnerName: "erin", + Path: "/var/lib/dolt/~erin/broken", Visibility: core.VisibilityPublic, + }) + h.browse.errByPath = map[string]error{ + broken.Path: errors.New("browse: open /var/lib/dolt/~erin/broken: manifest is corrupt"), + } + + rec := h.do("GET", "/ready", nil, nil) + require.Equal(t, http.StatusOK, rec.Code) + body := rec.Body.String() + + // The rest of the page is the page. + assert.Contains(t, body, "~alice/alpha") + assert.Contains(t, body, "~bob/beta") + assert.Contains(t, body, "3 ready issues") + // The gap is admitted; the error is not printed. + assert.Contains(t, body, "could not be read") + assert.NotContains(t, body, "manifest is corrupt") + assert.NotContains(t, body, "/var/lib/dolt") +} + +// A database that is not a beads tracker is skipped silently: no group, no +// note, no mention. +func TestReadyPageSkipsNonBeadsDatabases(t *testing.T) { + pinClock(t, testNow) + h, _, _ := readyHarness(t) + plain := &fakeSession{ + branches: []browse.Branch{{Name: "main", Head: "h-plain"}}, + tables: []browse.TableInfo{{Name: "measurements", Columns: []browse.ColumnInfo{{Name: "id"}}}}, + } + addTracker(h, "frank", 5, "sensors", core.VisibilityPublic, plain) + + rec := h.do("GET", "/ready", nil, nil) + require.Equal(t, http.StatusOK, rec.Code) + body := rec.Body.String() + + assert.NotContains(t, body, "sensors") + assert.NotContains(t, body, "could not be read") + assert.Zero(t, plain.rowReads, "a non-beads database is never read for rows") + assert.Contains(t, body, "3 ready issues") +} + +func TestReadyPageFilters(t *testing.T) { + pinClock(t, testNow) + + get := func(t *testing.T, target string) string { + t.Helper() + h, _, _ := readyHarness(t) + rec := h.do("GET", target, nil, nil) + require.Equal(t, http.StatusOK, rec.Code, "body=%s", rec.Body.String()) + return rec.Body.String() + } + + t.Run("q narrows over id and title", func(t *testing.T) { + body := get(t, "/ready?q=one+ready+thing") + assert.Contains(t, body, "issue=b-1") + assert.NotContains(t, body, "issue=a-1") + assert.Contains(t, body, "1 ready issue") + }) + + t.Run("assignee narrows", func(t *testing.T) { + body := get(t, "/ready?assignee=bob") + assert.Contains(t, body, "issue=a-2") + assert.NotContains(t, body, "issue=a-1") + assert.NotContains(t, body, "issue=b-1") + }) + + t.Run("priority narrows", func(t *testing.T) { + body := get(t, "/ready?priority=0") + assert.Contains(t, body, "issue=a-1") + assert.NotContains(t, body, "issue=a-2") + }) + + t.Run("db narrows to one database", func(t *testing.T) { + body := get(t, "/ready?db=bob%2Fbeta") + assert.Contains(t, body, "~bob/beta") + assert.NotContains(t, body, "~alice/alpha") + assert.Contains(t, body, `href="/ready"`, "and offers a way back to all of them") + }) + + t.Run("a filter matching nothing says so", func(t *testing.T) { + body := get(t, "/ready?assignee=nobody") + assert.Contains(t, body, "Nothing ready matches these filters") + }) +} + +// An instance with no beads databases at all still answers, and says what it +// found rather than nothing. +func TestReadyPageEmptyInstance(t *testing.T) { + pinClock(t, testNow) + h := newHarness(t) + + rec := h.do("GET", "/ready", nil, nil) + require.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "Nothing is ready to work") +} + +// The page is reachable from the dashboard — the only place a reader would look +// for a question that belongs to no single database. +func TestDashboardLinksToTheReadyPage(t *testing.T) { + h := newHarness(t) + + signedIn := h.do("GET", "/", testCaller(3, "bob"), nil) + require.Equal(t, http.StatusOK, signedIn.Code) + assert.Contains(t, signedIn.Body.String(), `href="/ready"`) + + anon := h.do("GET", "/", nil, nil) + require.Equal(t, http.StatusOK, anon.Code) + assert.Contains(t, anon.Body.String(), `href="/ready"`) +} + +// HEAD is registered for this route like every other read route, and answers +// the same status as the GET. +func TestReadyPageAnswersHead(t *testing.T) { + pinClock(t, testNow) + h, _, _ := readyHarness(t) + + rec := h.do("HEAD", "/ready", nil, nil) + assert.Equal(t, http.StatusOK, rec.Code) +} diff --git a/web/router.go b/web/router.go index bb98aae467d6914f72457d3296d0d90225e7a8f9..179139ea9dae45c6649a436276797147dcd50798 100644 --- a/web/router.go +++ b/web/router.go @@ -18,6 +18,7 @@ import ( "sourcecraft.dev/bigbes/sr-ht-ecore/pages" "sourcecraft.dev/bigbes/sr-ht-dolt/authn" + "sourcecraft.dev/bigbes/sr-ht-dolt/beads" "sourcecraft.dev/bigbes/sr-ht-dolt/core" ) @@ -58,6 +59,12 @@ type app struct { // views is a snapshot of the global registeredViews taken at Register time. // Handlers read this (never the global) so tests can inject their own set. views []View + // ready is the /ready page's projection cache: one ready set per database, + // gated on the database's head hash and expiring on beads.ReadyCacheTTL. It + // lives on the app because it is the one piece of state that outlives a + // request here, and it holds projections rather than open stores — see + // beads.ReadyCache. + ready *beads.ReadyCache } // page builds the chrome for one request: the shared frame plus the per-page @@ -150,6 +157,7 @@ func newApp(cfg Config) (*app, error) { // Snapshot the registry so all handlers see a stable set and tests can // override it per-app without mutating the global. views: append([]View{}, registeredViews...), + ready: beads.NewReadyCache(), }, nil } @@ -211,6 +219,10 @@ func (a *app) mount(r chi.Router) { // of a form page is registered beside it with r.Post: a HEAD that // writes is not a HEAD. chimw.GetHead(r, "/", a.handleIndex) + // The cross-database ready page. It is a page of its own and not a View: + // a View is a rendering of one repository, and this is the question no + // single repository can answer. + chimw.GetHead(r, "/ready", a.handleReady) chimw.GetHead(r, "/create", a.handleCreateForm) r.Post("/create", a.handleCreate) diff --git a/web/templates/index.html b/web/templates/index.html index 359d384364332ad2c0aa15c4a5bfb7b3396124f3..38b7cfb749373a768def96ee607425d77d7f2c9b 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -10,6 +10,10 @@

Create new database {{icon "caret-right"}} + {{/* The one question no single database answers: what is ready to work + across every tracker this viewer can browse. */}} + What is ready to work {{icon "caret-right"}} Configure dolt credentials {{icon "caret-right"}} @@ -33,7 +37,8 @@

Log in to create and manage - databases. + databases. The databases carrying a bd issue tracker also answer + what is ready to work across all of them.

{{end}} {{- end}} diff --git a/web/templates/ready.html b/web/templates/ready.html new file mode 100644 index 0000000000000000000000000000000000000000..0f396f9a5c10d198ccf47299bf6cb64e37d30ae6 --- /dev/null +++ b/web/templates/ready.html @@ -0,0 +1,168 @@ +{{define "content" -}} + + +
+

Ready to work

+

+ {{.Data.Total}} ready {{if eq .Data.Total 1}}issue{{else}}issues{{end}} + across {{len .Data.Groups}} {{if eq (len .Data.Groups) 1}}database{{else}}databases{{end}} + — open, unblocked, and nobody is on {{if eq .Data.Total 1}}it{{else}}them{{end}} yet. +

+ +
+ {{/* The database selection is not a form control (narrowing to one is a link + on its header), but this form is a GET: without carrying it, searching + from one database would answer across all of them. */}} + {{range .Data.Filter.Databases}}{{end}} + + + + + {{if .Data.Filter.Active}}Clear{{end}} +
+ +{{if .Data.Filter.Databases}} +

+ Narrowed to {{len .Data.Filter.Databases}} of the databases you can browse. + Every database +

+{{end}} + +{{if .Data.Capped}} +{{/* A silent cap reads as "that is everything". */}} +

+ More than {{.Data.Max}} databases matched; only the first {{.Data.Max}} were read. + Narrow with the database links below to see the rest. +

+{{end}} + +{{if .Data.Failed}} +{{/* What went wrong is in the log, with the error. The reader gets the count, + because a page that hid the gap entirely would understate itself. */}} +

+ {{len .Data.Failed}} {{if eq (len .Data.Failed) 1}}database{{else}}databases{{end}} + could not be read and {{if eq (len .Data.Failed) 1}}is{{else}}are{{end}} not counted here. +

+{{end}} + +{{range .Data.Groups}} +
+
+ ~{{.Database.Slug}} + {{if ne (len $.Data.Filter.Databases) 1}}only this one{{end}} + {{len .Cards}} ready +
+ {{template "beadsHead" (dict "Repo" .Database "Ref" .Ref "Head" .Head)}} + +
+{{else}} +{{if .Data.Filter.Active}} +

Nothing ready matches these filters. Clear them.

+{{else}} +

Nothing is ready to work in the databases you can browse.

+{{end}} +{{end}} +
+{{- end}} diff --git a/web/web_test.go b/web/web_test.go index 32dca2408820a8b6c2e9409d25c16ee12bafd7c0..c0d7ede5286b0634e9bb62306618362e1225a549 100644 --- a/web/web_test.go +++ b/web/web_test.go @@ -11,6 +11,7 @@ import ( "net/url" "os" "path/filepath" + "sort" "strings" "testing" "time" @@ -107,6 +108,24 @@ func (f *fakeStore) ListReposByOwner(_ context.Context, owner string, viewer *co return out, nil } +// ListReposForViewer mirrors db.Store's instance-wide listing rule: PUBLIC to +// everyone, plus whatever the viewer owns or holds an ACL on. Sorted by id so a +// test that depends on the order it hands to /ready gets the same one twice. +func (f *fakeStore) ListReposForViewer(_ context.Context, viewer *core.Caller) ([]*core.Repo, error) { + var out []*core.Repo + for _, r := range f.byID { + visible := r.Visibility == core.VisibilityPublic + if viewer != nil && (viewer.UserID == r.OwnerID || f.hasACL(r.ID, viewer.UserID)) { + visible = true + } + if visible { + out = append(out, r) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out, nil +} + func (f *fakeStore) ListReposForDashboard(_ context.Context, userID int) ([]*core.Repo, error) { var out []*core.Repo for _, r := range f.byID { @@ -256,16 +275,26 @@ type fakeSession struct { // which every page reading the log for decoration has to survive. logErr error closed bool + + // Read counters. The /ready page's head-hash gate is a claim about reads not + // happening, and the only way to check that is to count them: a timing + // measurement would pass on a fast machine whatever the code did. + rowReads int + tableReads int + logReads int + opens int } func (s *fakeSession) Branches(context.Context) ([]browse.Branch, error) { return s.branches, nil } func (s *fakeSession) Log(_ context.Context, _, _ string, _ int) ([]browse.CommitInfo, string, error) { + s.logReads++ if s.logErr != nil { return nil, "", s.logErr } return s.commits, "", nil } func (s *fakeSession) Tables(_ context.Context, _ string) ([]browse.TableInfo, error) { + s.tableReads++ return s.tables, nil } func (s *fakeSession) TableHash(_ context.Context, refStr, table string) (string, bool, error) { @@ -273,6 +302,7 @@ func (s *fakeSession) TableHash(_ context.Context, refStr, table string) (string return h, ok, nil } func (s *fakeSession) Rows(_ context.Context, ref, table string, _, _ int) (*browse.RowPage, error) { + s.rowReads++ if byTable, ok := s.rowsByRef[ref]; ok { if p, ok := byTable[table]; ok { return p, nil @@ -292,12 +322,28 @@ func (s *fakeSession) CommitSummary(_ context.Context, _ string) (*browse.Commit } func (s *fakeSession) Close() error { s.closed = true; return nil } -type fakeBrowse struct{ sess *fakeSession } +type fakeBrowse struct { + sess *fakeSession + // byPath is a session per store path, for the pages that open more than one + // database in a request (/ready). A path absent here falls back to sess, so + // every single-database test is unaffected. + byPath map[string]*fakeSession + // errByPath is a store that refuses to open, keyed the same way. + errByPath map[string]error +} -func (b *fakeBrowse) Open(context.Context, string) (BrowseSession, error) { +func (b *fakeBrowse) Open(_ context.Context, path string) (BrowseSession, error) { + if err, ok := b.errByPath[path]; ok { + return nil, err + } + if s, ok := b.byPath[path]; ok { + s.opens++ + return s, nil + } if b.sess == nil { return &fakeSession{}, nil } + b.sess.opens++ return b.sess, nil }