package beads import ( "context" "errors" "sort" "strings" "sourcecraft.dev/bigbes/sr-ht-dolt/browse" ) // --- helpers ----------------------------------------------------------------- // readRows reads up to Max rows of a required table and its reported total. func readRows(ctx context.Context, sess BrowseSession, ref, table string) (*browse.RowPage, int, error) { page, err := sess.Rows(ctx, ref, table, 0, Max) if err != nil { return nil, 0, err } return page, page.Total, nil } // readRowsOptional is readRows for a table that may not exist: ErrTableNotFound // degrades to (nil, 0, nil) so the caller can treat it as empty. func readRowsOptional(ctx context.Context, sess BrowseSession, ref, table string) (*browse.RowPage, int, error) { page, err := sess.Rows(ctx, ref, table, 0, Max) if err != nil { if errors.Is(err, browse.ErrTableNotFound) { return nil, 0, nil } return nil, 0, err } return page, page.Total, nil } // indexCols builds a column-name → cell-index map from a RowPage's Columns, so // cells are addressed by name regardless of the underlying column order. func indexCols(cols []string) map[string]int { m := make(map[string]int, len(cols)) for i, c := range cols { m[c] = i } return m } // rowCells is one row of a page together with the NULL mask browse returned // beside it: the rendered strings, and the answer to "does this cell hold a // value at all?" that the strings cannot carry. // // It is what every projection here iterates and what cell reads, so a row and // its mask travel together and cannot be paired up wrongly at a call site. type rowCells struct { values []string // nulls is browse's mask for this row, or nil for a page that carried none — // which is a hand-built page, since browse fills one for every page it // returns. See cell for what an absent mask means. nulls []bool } // rowsOf pairs each row of a page with its own mask. A nil page — an optional // table that is absent — has no rows, which is what the callers of // readRowsOptional already treat it as. // // A row the mask does not cover gets a nil one rather than an all-false one: not // knowing whether a cell holds a value is a different answer from knowing that // it does, and cell reads the two differently. func rowsOf(page *browse.RowPage) []rowCells { if page == nil { return nil } out := make([]rowCells, 0, len(page.Rows)) for i, r := range page.Rows { var mask []bool if i < len(page.Nulls) { mask = page.Nulls[i] } out = append(out, rowCells{values: r, nulls: mask}) } return out } // cell returns the named column's value for a row, or "" when the column is // absent, out of range, or holds no value at all. // // A cell that holds no value reads as "": every projection renders a missing // timestamp, assignee or close reason as nothing, and that must not change. What // the mask changes is the other reading of the same string — browse renders a // real NULL as the text "NULL", so a row that *stores* those four characters // rendered identically and was flattened to "" too, which turned a stored title // into an empty one. The mask beside the row answers which of the two it is, so // it decides here rather than the string. // // A cell no mask covers is read the way this package read every cell before the // mask existed: "NULL" is absent. That is not a guess dressed up as an answer — // it is the older reading, kept for the only pages that lack a mask, which are // the ones built by hand rather than read from a store. browse fills a mask // parallel to the rows for every page it returns, so no read of a database // arrives here without one. func cell(cols map[string]int, row rowCells, name string) string { i, ok := cols[name] if !ok || i < 0 || i >= len(row.values) { return "" } if i < len(row.nulls) { if row.nulls[i] { return "" } return row.values[i] } v := row.values[i] if v == "NULL" { return "" } 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 rowsOf(statuses) { 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 rowsOf(labels) { 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 rowsOf(issues) { 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 rowsOf(deps) { 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 rowCells, 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)) { case "1", "true", "yes", "t", "y": return true } return false } // sortedKeys returns a set's keys in ascending order. func sortedKeys(set map[string]bool) []string { out := make([]string, 0, len(set)) for k := range set { out = append(out, k) } sort.Strings(out) return out } // containsString reports whether s is in xs. func containsString(xs []string, s string) bool { for _, x := range xs { if x == s { return true } } return false }