package beads
import (
"context"
"net/url"
"sort"
"strconv"
"strings"
"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
)
// --- build -------------------------------------------------------------------
// Build reads the issue graph and produces either the board or, when ?issue=
// names an issue, that issue's detail pane.
func Build(ctx context.Context, sess BrowseSession, ref string, query url.Values) (*Data, error) {
issues, issuesTotal, err := readRows(ctx, sess, ref, "issues")
if err != nil {
return nil, err
}
deps, depsTotal, err := readRows(ctx, sess, ref, "dependencies")
if err != nil {
return nil, err
}
// Optional tables: absent ones degrade to empty rather than failing the view.
labels, _, _ := readRowsOptional(ctx, sess, ref, "labels")
statuses, _, _ := readRowsOptional(ctx, sess, ref, "custom_statuses")
truncated := issuesTotal > Max || depsTotal > Max
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)
}
}
}
// 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)
}
// 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
}
}
}
// 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)
}
}
}
// Detail mode: a named issue short-circuits the board build.
if want := query.Get("issue"); want != "" {
return buildDetail(ctx, sess, ref, want, issues, issueCols, deps, depCols,
labelsByIssue, catByStatus, catByIssue), nil
}
// Board mode: parse the sticky filters and collect dropdown options from the
// full issue set (options stay stable as filters narrow the board).
filter := Filter{
Query: strings.TrimSpace(query.Get("q")),
Type: query.Get("type"),
Priority: query.Get("priority"),
Assignee: query.Get("assignee"),
Label: query.Get("label"),
Ready: query.Get("ready") == "1",
}
opts := collectFilterOptions(issues, issueCols, labelsByIssue)
// Bucket every matching issue into exactly one lane.
var rolling, linedUp, stalled, pastStand []Card
for _, r := range issues.Rows {
id := cell(issueCols, r, "id")
if !filter.matches(id, r, issueCols, labelsByIssue[id]) {
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"))
if filter.Ready && !ready {
continue
}
card := 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: blockedByCount[id],
Blocks: blocksCount[id],
Ready: ready,
}
switch {
case cat == "closed":
pastStand = append(pastStand, card)
case cat == "in_progress":
rolling = append(rolling, card)
case blocked:
stalled = append(stalled, card)
default: // open (or unknown) and not blocked
linedUp = append(linedUp, card)
}
}
created := issueCreatedAt(issues, issueCols)
for _, lane := range [][]Card{rolling, linedUp, stalled, pastStand} {
sortCards(lane, created)
}
data := &Data{
Mode: "board",
Lanes: []Lane{
// Accents are muted Mardi Gras hues (gold / green / violet / gray)
// chosen to read on both the light and dark SourceHut themes. They
// are applied by the template as thin accents (card border, lane
// underline, tinted chips), never as body text, so contrast holds.
{Name: "Rolling", Slug: "rolling", Accent: "#c9930a", Issues: rolling},
{Name: "Lined Up", Slug: "lined-up", Accent: "#2f9e44", Issues: linedUp},
{Name: "Stalled", Slug: "stalled", Accent: "#9c36b5", Issues: stalled},
{Name: "Past Stand", Slug: "past-stand", Accent: "#868e96", Issues: pastStand},
},
Counts: Counts{
Rolling: len(rolling),
LinedUp: len(linedUp),
Stalled: len(stalled),
PastStand: len(pastStand),
Total: len(rolling) + len(linedUp) + len(stalled) + len(pastStand),
},
Total: len(rolling) + len(linedUp) + len(stalled) + len(pastStand),
Truncated: truncated,
ShownOf: shownOf,
Filter: filter,
FilterOpts: opts,
}
return data, nil
}
// buildDetail assembles the single-issue view: the issue's own fields, its
// dependency edges in both directions (target title/status resolved), its
// comments thread, and a merged history timeline. When the issue is an epic
// (issue_type == "epic") it switches to Mode "epic" and also gathers the
// parent-child children as a subtask rollup.
func buildDetail(
ctx context.Context, sess BrowseSession, ref, want string,
issues *browse.RowPage, issueCols map[string]int,
deps *browse.RowPage, depCols map[string]int,
labelsByIssue map[string][]string,
catByStatus, catByIssue map[string]string,
) *Data {
// id → (title, status, whole row) for edge labels and the subtask rollup.
titleByIssue := map[string]string{}
statusByIssue := map[string]string{}
rowByID := make(map[string][]string, len(issues.Rows))
var row []string
for _, r := range issues.Rows {
id := cell(issueCols, r, "id")
titleByIssue[id] = cell(issueCols, r, "title")
statusByIssue[id] = cell(issueCols, r, "status")
rowByID[id] = r
if id == want {
row = r
}
}
data := &Data{Mode: "detail"}
if row == nil {
// Unknown id: a detail pane with a nil Issue; the template shows a
// "not found" note and a link back to the board.
return data
}
// An epic gets its own rendering mode; the template branches on it to add the
// subtask rollup while reusing the shared detail chrome.
if strings.EqualFold(cell(issueCols, row, "issue_type"), "epic") {
data.Mode = "epic"
}
status := cell(issueCols, row, "status")
name, accent := laneForCategory(statusCategory(status, catByStatus))
data.Issue = &Issue{
ID: want,
Title: cell(issueCols, row, "title"),
Status: status,
Lane: name,
Accent: accent,
Priority: cell(issueCols, row, "priority"),
IssueType: cell(issueCols, row, "issue_type"),
Assignee: cell(issueCols, row, "assignee"),
CreatedBy: cell(issueCols, row, "created_by"),
Owner: cell(issueCols, row, "owner"),
EstimatedMinutes: cell(issueCols, row, "estimated_minutes"),
ExternalRef: cell(issueCols, row, "external_ref"),
SpecID: cell(issueCols, row, "spec_id"),
Description: cell(issueCols, row, "description"),
Design: cell(issueCols, row, "design"),
AcceptanceCriteria: cell(issueCols, row, "acceptance_criteria"),
Notes: cell(issueCols, row, "notes"),
CreatedAt: cell(issueCols, row, "created_at"),
StartedAt: cell(issueCols, row, "started_at"),
UpdatedAt: cell(issueCols, row, "updated_at"),
ClosedAt: cell(issueCols, row, "closed_at"),
CloseReason: cell(issueCols, row, "close_reason"),
Labels: labelsByIssue[want],
}
edge := func(id, typ string) Edge {
st := statusByIssue[id]
return Edge{
IssueID: id,
Title: titleByIssue[id],
Type: typ,
Status: st,
Closed: statusCategory(st, catByStatus) == "closed",
}
}
for _, r := range deps.Rows {
from := cell(depCols, r, "issue_id")
to := cell(depCols, r, "depends_on_issue_id")
typ := cell(depCols, r, "type")
if from == want && to != "" {
data.DependsOn = append(data.DependsOn, edge(to, typ))
}
if to == want && from != "" {
data.DependedOnBy = append(data.DependedOnBy, edge(from, typ))
// A parent-child edge pointing at this issue makes `from` a subtask,
// but that only matters when this issue is an epic.
if data.Mode == "epic" && strings.EqualFold(typ, "parent-child") {
cr := rowByID[from]
cat := catByIssue[from]
st := Subtask{
ID: from,
Title: titleByIssue[from],
Status: statusByIssue[from],
Category: cat,
Priority: cell(issueCols, cr, "priority"),
Assignee: cell(issueCols, cr, "assignee"),
Blocked: truthy(cell(issueCols, cr, "is_blocked")),
}
data.Subtasks = append(data.Subtasks, st)
data.SubtaskTotal++
if cat == "closed" {
data.SubtaskDone++
}
}
}
// beads logs no event for a dependency/subtask link, but the row records
// created_at/created_by — synthesize a timeline entry so "added subtask X"
// (and other edge additions) appear in History.
if act, ok := depActivity(want, from, to, typ,
cell(depCols, r, "created_at"), cell(depCols, r, "created_by")); ok {
data.History = append(data.History, act)
}
}
sortSubtasks(data.Subtasks)
// Transitive dependency trees over the full edge set. Kept only when they
// reach past the direct edges (a Depth>0 node), so they add the chain the
// flat Depends-on / Depended-on-by lists can't show, without duplicating them.
outAdj := map[string][]depLink{} // id → things it depends on
inAdj := map[string][]depLink{} // id → things that depend on it
for _, r := range deps.Rows {
from := cell(depCols, r, "issue_id")
to := cell(depCols, r, "depends_on_issue_id")
if from == "" || to == "" {
continue
}
typ := cell(depCols, r, "type")
outAdj[from] = append(outAdj[from], depLink{to: to, typ: typ})
inAdj[to] = append(inAdj[to], depLink{to: from, typ: typ})
}
if t := buildDepTree(want, outAdj, titleByIssue, statusByIssue, catByStatus); hasTransitive(t) {
data.DependsTree = t
}
if t := buildDepTree(want, inAdj, titleByIssue, statusByIssue, catByStatus); hasTransitive(t) {
data.DependentTree = t
}
// Comments are optional; a missing table just yields an empty thread. Each
// comment is also folded into the merged history timeline below.
if comments, _, err := readRowsOptional(ctx, sess, ref, "comments"); err == nil && comments != nil {
ccols := indexCols(comments.Columns)
for _, r := range comments.Rows {
if cell(ccols, r, "issue_id") != want {
continue
}
author := cell(ccols, r, "author")
text := cell(ccols, r, "text")
at := cell(ccols, r, "created_at")
data.Comments = append(data.Comments, Comment{Author: author, Text: text, CreatedAt: at})
data.History = append(data.History, Activity{
Kind: "comment",
Actor: author,
Summary: "commented",
Text: text,
CreatedAt: at,
})
}
}
// The audit log (events) is optional too; when present it joins the comments
// in the History tab as humanized, time-ordered entries.
if events, _, err := readRowsOptional(ctx, sess, ref, "events"); err == nil && events != nil {
ecols := indexCols(events.Columns)
for _, r := range events.Rows {
if cell(ecols, r, "issue_id") != want {
continue
}
et := cell(ecols, r, "event_type")
summary, text := humanizeEvent(et,
cell(ecols, r, "old_value"), cell(ecols, r, "new_value"), cell(ecols, r, "comment"))
data.History = append(data.History, Activity{
Kind: "event",
Event: et,
Actor: cell(ecols, r, "actor"),
Summary: summary,
Text: text,
CreatedAt: cell(ecols, r, "created_at"),
})
}
}
sortActivity(data.History)
return data
}
// collectFilterOptions gathers the distinct issue_type / priority / assignee
// values and label names across all issues, sorted, for the filter dropdowns.
func collectFilterOptions(issues *browse.RowPage, cols map[string]int, labelsByIssue map[string][]string) FilterOptions {
types, prios, assignees, labels := map[string]bool{}, map[string]bool{}, map[string]bool{}, map[string]bool{}
for _, r := range issues.Rows {
if t := cell(cols, r, "issue_type"); t != "" {
types[t] = true
}
if p := cell(cols, r, "priority"); p != "" {
prios[p] = true
}
if a := cell(cols, r, "assignee"); a != "" {
assignees[a] = true
}
}
for _, lbs := range labelsByIssue {
for _, l := range lbs {
labels[l] = true
}
}
return FilterOptions{
Types: sortedKeys(types),
Priorities: sortedKeys(prios), // single digits sort numerically as strings
Assignees: sortedKeys(assignees),
Labels: sortedKeys(labels),
}
}
// issueCreatedAt maps issue id → created_at string, for lane sorting.
func issueCreatedAt(issues *browse.RowPage, cols map[string]int) map[string]string {
m := make(map[string]string, len(issues.Rows))
for _, r := range issues.Rows {
m[cell(cols, r, "id")] = cell(cols, r, "created_at")
}
return m
}
// sortCards orders a lane by priority (0 = highest first), then created_at
// ascending, then id — a stable, deterministic parade order.
func sortCards(cards []Card, created map[string]string) {
sort.SliceStable(cards, func(i, j int) bool {
pi, pj := priorityRank(cards[i].Priority), priorityRank(cards[j].Priority)
if pi != pj {
return pi < pj
}
ci, cj := created[cards[i].ID], created[cards[j].ID]
if ci != cj {
return ci < cj
}
return cards[i].ID < cards[j].ID
})
}
// priorityRank parses a priority to an int for sorting; unset/unparseable sorts
// last (a large rank).
func priorityRank(p string) int {
if p == "" {
return 1 << 30
}
n, err := strconv.Atoi(strings.TrimSpace(p))
if err != nil {
return 1 << 30
}
return n
}
// sortSubtasks orders an epic's children open-work-first: unclosed before
// closed, then by priority (0 highest), then id — closed subtasks sink to the
// bottom so the actionable ones lead.
func sortSubtasks(subs []Subtask) {
sort.SliceStable(subs, func(i, j int) bool {
ci, cj := subs[i].Category == "closed", subs[j].Category == "closed"
if ci != cj {
return !ci // open (false) sorts before closed (true)
}
pi, pj := priorityRank(subs[i].Priority), priorityRank(subs[j].Priority)
if pi != pj {
return pi < pj
}
return subs[i].ID < subs[j].ID
})
}
// sortActivity orders the merged history oldest-first (chronological). Timestamps
// share the "YYYY-MM-DD HH:MM:SS" shape across events and comments, so a lexical
// compare is a time compare; ties fall back to id-free but stable order.
func sortActivity(acts []Activity) {
sort.SliceStable(acts, func(i, j int) bool {
return acts[i].CreatedAt < acts[j].CreatedAt
})
}