| 1 |
|
package beads |
| 2 |
|
|
| 3 |
|
import ( |
| 4 |
|
"context" |
| 5 |
|
"errors" |
| 6 |
|
"sort" |
| 7 |
|
"strings" |
| 8 |
|
|
| 9 |
|
"sourcecraft.dev/bigbes/sr-ht-dolt/browse" |
| 10 |
|
) |
| 11 |
|
|
| 12 |
|
// --- helpers ----------------------------------------------------------------- |
| 13 |
|
|
| 14 |
|
// readRows reads up to Max rows of a required table and its reported total. |
| 15 |
351 |
func readRows(ctx context.Context, sess BrowseSession, ref, table string) (*browse.RowPage, int, error) { |
| 16 |
351 |
page, err := sess.Rows(ctx, ref, table, 0, Max) |
| 17 |
351 |
if err != nil { |
| 18 |
0 |
return nil, 0, err |
| 19 |
0 |
} |
| 20 |
351 |
return page, page.Total, nil |
| 21 |
|
} |
| 22 |
|
|
| 23 |
|
// readRowsOptional is readRows for a table that may not exist: ErrTableNotFound |
| 24 |
|
// degrades to (nil, 0, nil) so the caller can treat it as empty. |
| 25 |
536 |
func readRowsOptional(ctx context.Context, sess BrowseSession, ref, table string) (*browse.RowPage, int, error) { |
| 26 |
536 |
page, err := sess.Rows(ctx, ref, table, 0, Max) |
| 27 |
536 |
if err != nil { |
| 28 |
270 |
if errors.Is(err, browse.ErrTableNotFound) { |
| 29 |
270 |
return nil, 0, nil |
| 30 |
270 |
} |
| 31 |
0 |
return nil, 0, err |
| 32 |
|
} |
| 33 |
266 |
return page, page.Total, nil |
| 34 |
|
} |
| 35 |
|
|
| 36 |
|
// indexCols builds a column-name → cell-index map from a RowPage's Columns, so |
| 37 |
|
// cells are addressed by name regardless of the underlying column order. |
| 38 |
763 |
func indexCols(cols []string) map[string]int { |
| 39 |
763 |
m := make(map[string]int, len(cols)) |
| 40 |
3646 |
for i, c := range cols { |
| 41 |
3646 |
m[c] = i |
| 42 |
3646 |
} |
| 43 |
763 |
return m |
| 44 |
|
} |
| 45 |
|
|
| 46 |
|
// rowCells is one row of a page together with the NULL mask browse returned |
| 47 |
|
// beside it: the rendered strings, and the answer to "does this cell hold a |
| 48 |
|
// value at all?" that the strings cannot carry. |
| 49 |
|
// |
| 50 |
|
// It is what every projection here iterates and what cell reads, so a row and |
| 51 |
|
// its mask travel together and cannot be paired up wrongly at a call site. |
| 52 |
|
type rowCells struct { |
| 53 |
|
values []string |
| 54 |
|
|
| 55 |
|
// nulls is browse's mask for this row, or nil for a page that carried none — |
| 56 |
|
// which is a hand-built page, since browse fills one for every page it |
| 57 |
|
// returns. See cell for what an absent mask means. |
| 58 |
|
nulls []bool |
| 59 |
|
} |
| 60 |
|
|
| 61 |
|
// rowsOf pairs each row of a page with its own mask. A nil page — an optional |
| 62 |
|
// table that is absent — has no rows, which is what the callers of |
| 63 |
|
// readRowsOptional already treat it as. |
| 64 |
|
// |
| 65 |
|
// A row the mask does not cover gets a nil one rather than an all-false one: not |
| 66 |
|
// knowing whether a cell holds a value is a different answer from knowing that |
| 67 |
|
// it does, and cell reads the two differently. |
| 68 |
1005 |
func rowsOf(page *browse.RowPage) []rowCells { |
| 69 |
1005 |
if page == nil { |
| 70 |
1 |
return nil |
| 71 |
1 |
} |
| 72 |
1004 |
out := make([]rowCells, 0, len(page.Rows)) |
| 73 |
99758 |
for i, r := range page.Rows { |
| 74 |
99758 |
var mask []bool |
| 75 |
99758 |
if i < len(page.Nulls) { |
| 76 |
25 |
mask = page.Nulls[i] |
| 77 |
25 |
} |
| 78 |
99758 |
out = append(out, rowCells{values: r, nulls: mask}) |
| 79 |
|
} |
| 80 |
1004 |
return out |
| 81 |
|
} |
| 82 |
|
|
| 83 |
|
// cell returns the named column's value for a row, or "" when the column is |
| 84 |
|
// absent, out of range, or holds no value at all. |
| 85 |
|
// |
| 86 |
|
// A cell that holds no value reads as "": every projection renders a missing |
| 87 |
|
// timestamp, assignee or close reason as nothing, and that must not change. What |
| 88 |
|
// the mask changes is the other reading of the same string — browse renders a |
| 89 |
|
// real NULL as the text "NULL", so a row that *stores* those four characters |
| 90 |
|
// rendered identically and was flattened to "" too, which turned a stored title |
| 91 |
|
// into an empty one. The mask beside the row answers which of the two it is, so |
| 92 |
|
// it decides here rather than the string. |
| 93 |
|
// |
| 94 |
|
// A cell no mask covers is read the way this package read every cell before the |
| 95 |
|
// mask existed: "NULL" is absent. That is not a guess dressed up as an answer — |
| 96 |
|
// it is the older reading, kept for the only pages that lack a mask, which are |
| 97 |
|
// the ones built by hand rather than read from a store. browse fills a mask |
| 98 |
|
// parallel to the rows for every page it returns, so no read of a database |
| 99 |
|
// arrives here without one. |
| 100 |
271607 |
func cell(cols map[string]int, row rowCells, name string) string { |
| 101 |
271607 |
i, ok := cols[name] |
| 102 |
271607 |
if !ok || i < 0 || i >= len(row.values) { |
| 103 |
20556 |
return "" |
| 104 |
20556 |
} |
| 105 |
251051 |
if row.isNull(i) { |
| 106 |
530 |
return "" |
| 107 |
530 |
} |
| 108 |
250521 |
return row.values[i] |
| 109 |
|
} |
| 110 |
|
|
| 111 |
|
// isNull reports whether the i-th cell of this row holds no value. |
| 112 |
|
// |
| 113 |
|
// It is the one place in this package that decides, so cell — which flattens an |
| 114 |
|
// absent cell to "" — and the raw section — which must not — can never come to |
| 115 |
|
// different answers about the same cell. The reading is the one cell documents: |
| 116 |
|
// the mask decides when the row has one, and a row with none falls back to the |
| 117 |
|
// pre-mask reading, where the text "NULL" is how absence arrived. |
| 118 |
|
// |
| 119 |
|
// A cell past the end of the row is not a value, so it reads as absent. |
| 120 |
251929 |
func (r rowCells) isNull(i int) bool { |
| 121 |
251929 |
if i < 0 || i >= len(r.values) { |
| 122 |
0 |
return true |
| 123 |
0 |
} |
| 124 |
251929 |
if i < len(r.nulls) { |
| 125 |
103 |
return r.nulls[i] |
| 126 |
103 |
} |
| 127 |
251826 |
return r.values[i] == "NULL" |
| 128 |
|
} |
| 129 |
|
|
| 130 |
|
// indexStatusCategories maps a status name (lowercased) to its category, from |
| 131 |
|
// the optional custom_statuses table. A nil page — the table is absent — yields |
| 132 |
|
// an empty map, and statusCategory then falls back to its name heuristics. |
| 133 |
178 |
func indexStatusCategories(statuses *browse.RowPage) map[string]string { |
| 134 |
178 |
out := map[string]string{} |
| 135 |
178 |
if statuses == nil { |
| 136 |
106 |
return out |
| 137 |
106 |
} |
| 138 |
72 |
cols := indexCols(statuses.Columns) |
| 139 |
6205 |
for _, r := range rowsOf(statuses) { |
| 140 |
6205 |
if name := cell(cols, r, "name"); name != "" { |
| 141 |
6205 |
out[strings.ToLower(name)] = strings.ToLower(cell(cols, r, "category")) |
| 142 |
6205 |
} |
| 143 |
|
} |
| 144 |
72 |
return out |
| 145 |
|
} |
| 146 |
|
|
| 147 |
|
// indexLabels maps issue id → its label names, from the optional labels table. |
| 148 |
|
// A nil page yields an empty map. |
| 149 |
178 |
func indexLabels(labels *browse.RowPage) map[string][]string { |
| 150 |
178 |
out := map[string][]string{} |
| 151 |
178 |
if labels == nil { |
| 152 |
142 |
return out |
| 153 |
142 |
} |
| 154 |
36 |
cols := indexCols(labels.Columns) |
| 155 |
10076 |
for _, r := range rowsOf(labels) { |
| 156 |
10076 |
id := cell(cols, r, "issue_id") |
| 157 |
10076 |
lb := cell(cols, r, "label") |
| 158 |
10076 |
if id != "" && lb != "" { |
| 159 |
10076 |
out[id] = append(out[id], lb) |
| 160 |
10076 |
} |
| 161 |
|
} |
| 162 |
36 |
return out |
| 163 |
|
} |
| 164 |
|
|
| 165 |
|
// indexIssueCategories maps issue id → status category (open / in_progress / |
| 166 |
|
// closed), which is what decides whether a blocking target still blocks. |
| 167 |
173 |
func indexIssueCategories(issues *browse.RowPage, cols map[string]int, catByStatus map[string]string) map[string]string { |
| 168 |
173 |
out := make(map[string]string, len(issues.Rows)) |
| 169 |
20759 |
for _, r := range rowsOf(issues) { |
| 170 |
20759 |
out[cell(cols, r, "id")] = statusCategory(cell(cols, r, "status"), catByStatus) |
| 171 |
20759 |
} |
| 172 |
173 |
return out |
| 173 |
|
} |
| 174 |
|
|
| 175 |
|
// depIndex is the dependency edge set aggregated per issue: how many things an |
| 176 |
|
// issue waits on, how many wait on it, and whether any of the things it waits on |
| 177 |
|
// is still open (which is what "blocked" means). |
| 178 |
|
type depIndex struct { |
| 179 |
|
blockedByCount map[string]int // issue_id → #deps it has |
| 180 |
|
blocksCount map[string]int // depends_on_issue_id → #deps aimed at it |
| 181 |
|
blockedOpen map[string]bool // issue_id → has an open blocking dep |
| 182 |
|
} |
| 183 |
|
|
| 184 |
|
// indexDeps aggregates the dependencies table. A "blocks" edge to a still-open |
| 185 |
|
// target blocks its source; parent-child is hierarchy, not a blocker — a subtask |
| 186 |
|
// is not blocked by its (open) epic, matching bd's own is_blocked/ready |
| 187 |
|
// accounting. A nil page (the table is absent) yields empty maps. |
| 188 |
173 |
func indexDeps(deps *browse.RowPage, catByIssue map[string]string) depIndex { |
| 189 |
173 |
idx := depIndex{ |
| 190 |
173 |
blockedByCount: map[string]int{}, |
| 191 |
173 |
blocksCount: map[string]int{}, |
| 192 |
173 |
blockedOpen: map[string]bool{}, |
| 193 |
173 |
} |
| 194 |
173 |
if deps == nil { |
| 195 |
0 |
return idx |
| 196 |
0 |
} |
| 197 |
173 |
cols := indexCols(deps.Columns) |
| 198 |
6217 |
for _, r := range rowsOf(deps) { |
| 199 |
6217 |
from := cell(cols, r, "issue_id") |
| 200 |
6217 |
to := cell(cols, r, "depends_on_issue_id") |
| 201 |
6217 |
typ := strings.ToLower(cell(cols, r, "type")) |
| 202 |
6217 |
if from != "" { |
| 203 |
6217 |
idx.blockedByCount[from]++ |
| 204 |
6217 |
} |
| 205 |
6217 |
if to != "" { |
| 206 |
6217 |
idx.blocksCount[to]++ |
| 207 |
6217 |
} |
| 208 |
6217 |
if from != "" && typ == "blocks" && catByIssue[to] != "closed" { |
| 209 |
6200 |
idx.blockedOpen[from] = true |
| 210 |
6200 |
} |
| 211 |
|
} |
| 212 |
173 |
return idx |
| 213 |
|
} |
| 214 |
|
|
| 215 |
|
// readyRow is bd's ready rule, and the only copy of it: an issue is ready when |
| 216 |
|
// it is open (not in-progress, not closed), unblocked, and not a |
| 217 |
|
// template/ephemeral scaffold. Derived in-process from the issues rows already |
| 218 |
|
// loaded rather than by reading the ready_issues table. |
| 219 |
|
// |
| 220 |
|
// Every surface that says "ready" — the board's ⚡ marker and its ?ready=1 |
| 221 |
|
// filter, the cross-database /ready page, the MCP ready_work tool — comes |
| 222 |
|
// through here, because two surfaces that each spelled the rule out would |
| 223 |
|
// disagree the first time it changed. |
| 224 |
14533 |
func readyRow(cat string, blocked bool, row rowCells, cols map[string]int) bool { |
| 225 |
14533 |
return cat == "open" && !blocked && |
| 226 |
14533 |
!truthy(cell(cols, row, "is_template")) && !truthy(cell(cols, row, "ephemeral")) |
| 227 |
14533 |
} |
| 228 |
|
|
| 229 |
|
// truthy reports whether a cell reads as a set boolean/flag. |
| 230 |
23076 |
func truthy(s string) bool { |
| 231 |
23076 |
switch strings.ToLower(strings.TrimSpace(s)) { |
| 232 |
68 |
case "1", "true", "yes", "t", "y": |
| 233 |
68 |
return true |
| 234 |
|
} |
| 235 |
23008 |
return false |
| 236 |
|
} |
| 237 |
|
|
| 238 |
|
// sortedKeys returns a set's keys in ascending order. |
| 239 |
262 |
func sortedKeys(set map[string]bool) []string { |
| 240 |
262 |
out := make([]string, 0, len(set)) |
| 241 |
6628 |
for k := range set { |
| 242 |
6628 |
out = append(out, k) |
| 243 |
6628 |
} |
| 244 |
262 |
sort.Strings(out) |
| 245 |
262 |
return out |
| 246 |
|
} |
| 247 |
|
|
| 248 |
|
// containsString reports whether s is in xs. |
| 249 |
4 |
func containsString(xs []string, s string) bool { |
| 250 |
4 |
for _, x := range xs { |
| 251 |
2 |
if x == s { |
| 252 |
1 |
return true |
| 253 |
1 |
} |
| 254 |
|
} |
| 255 |
3 |
return false |
| 256 |
|
} |