// Package beads is the one reading of the beads (bd) issue schema a hosted Dolt
// database may carry: the table fingerprint, the lane bucketing, the ready rule,
// the status categories, the transitive dependency walk, the event humanizer and
// the milestone rollup.
//
// # Why it is its own package
//
// This reading grew inside web/beads.go, serving one HTML board. It has more
// than one consumer now — the web views and the read-only MCP surface — and a
// consumer that cannot reach it grows its own copy, which is how two surfaces
// start disagreeing quietly about what "ready" or "closed" means. There is one
// fingerprint, one ready rule and one row cap on this instance, and they live
// here.
//
// # What it is allowed to know
//
// Rows in, view model out. This package depends on browse (the row reader) and
// the standard library, and on nothing else in the module: no net/http, no
// html/template, no core. It renders nothing and it authorizes nothing — by the
// time a caller gets here it has already decided that this caller may read this
// database. The structs are plain data carrying a few display-derived methods
// (PriorityLabel, Pct, SubtaskPct) that the HTML templates call on the dot; no
// HTML is built here.
//
// There is no SQL engine behind any of this: a bare NBS store has no working
// set, so every table is read whole (up to Max rows) through the BrowseSession
// seam and projected in process.
package beads
import (
"context"
"strings"
"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
)
// BrowseSession is the read-only row surface this package reads a database
// through. It is declared here, consumer-side (the house style web/deps.go
// sets), and names exactly the one method the projections call: everything
// below reads whole tables and projects them, so nothing here needs branches,
// commits or a table listing. web's larger BrowseSession — and *browse.DB
// itself — satisfy it structurally, so a caller hands its own session straight
// through.
type BrowseSession interface {
// Rows reads a page of a table's rows at refStr. A table that does not exist
// must report browse.ErrTableNotFound, which readRowsOptional degrades to an
// empty page for the tables beads treats as optional.
Rows(ctx context.Context, refStr, table string, offset, limit int) (*browse.RowPage, error)
}
// Max caps how many rows of any single table a projection reads. Beads DBs are
// modest (hundreds–low thousands of issues); if a table exceeds this the board
// notes it is truncated rather than trying to page.
const Max = 2000
// Applies fingerprints a beads DB: both an "issues" and a "dependencies" table
// present, and "issues" carrying at least id + status columns (a cheap guard
// against an unrelated schema that happens to reuse those two table names).
func Applies(tables []browse.TableInfo) bool {
var haveIssues, haveDeps, haveID, haveStatus bool
for _, t := range tables {
switch t.Name {
case "issues":
haveIssues = true
for _, c := range t.Columns {
switch c.Name {
case "id":
haveID = true
case "status":
haveStatus = true
}
}
case "dependencies":
haveDeps = true
}
}
return haveIssues && haveDeps && haveID && haveStatus
}
// statusCategory maps a status name to one of open / in_progress / closed. It
// prefers the custom_statuses lookup and falls back to name heuristics when the
// status is unknown there (or the table was empty).
func statusCategory(status string, catByStatus map[string]string) string {
s := strings.ToLower(strings.TrimSpace(status))
if s == "" {
return "open"
}
if cat, ok := catByStatus[s]; ok && cat != "" {
switch cat {
case "in_progress", "closed", "open":
return cat
}
}
switch {
case strings.Contains(s, "progress"), strings.Contains(s, "doing"), strings.Contains(s, "active"), s == "wip":
return "in_progress"
case strings.Contains(s, "close"), strings.Contains(s, "done"), strings.Contains(s, "resolved"), strings.Contains(s, "complete"):
return "closed"
default:
return "open"
}
}
// laneForCategory returns the lane display name and accent for a status
// category (used by the detail pane; the board buckets inline because it also
// needs the blocked signal).
func laneForCategory(cat string) (name, accent string) {
switch cat {
case "closed":
return "Past Stand", "#868e96"
case "in_progress":
return "Rolling", "#c9930a"
default:
return "Lined Up", "#2f9e44"
}
}