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 row.isNull(i) {
return ""
}
return row.values[i]
}
// isNull reports whether the i-th cell of this row holds no value.
//
// It is the one place in this package that decides, so cell — which flattens an
// absent cell to "" — and the raw section — which must not — can never come to
// different answers about the same cell. The reading is the one cell documents:
// the mask decides when the row has one, and a row with none falls back to the
// pre-mask reading, where the text "NULL" is how absence arrived.
//
// A cell past the end of the row is not a value, so it reads as absent.
func (r rowCells) isNull(i int) bool {
if i < 0 || i >= len(r.values) {
return true
}
if i < len(r.nulls) {
return r.nulls[i]
}
return r.values[i] == "NULL"
}
// 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
}